Phase 7c+7d: cache invalidation + offline data layer

7c: Client data cache management via element attributes
(sx-cache-invalidate) and response headers (SX-Cache-Invalidate,
SX-Cache-Update). Programmatic API: invalidate-page-cache,
invalidate-all-page-cache, update-page-cache.

7d: Service Worker (sx-sw.js) with IndexedDB for offline-capable
data caching. Network-first for /sx/data/ and /sx/io/, stale-while-
revalidate for /static/. Cache invalidation propagates from
in-memory cache to SW via postMessage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 00:45:33 +00:00
parent 9a707dbe56
commit 0ce3f95d6c
7 changed files with 519 additions and 49 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-08T00:10:07Z";
var SX_VERSION = "2026-03-08T00:44:09Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1472,7 +1472,7 @@ continue; } else { return NIL; } } };
})(); };
// process-response-headers
var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type")}; };
var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type"), ["cache-invalidate"]: getHeader("SX-Cache-Invalidate"), ["cache-update"]: getHeader("SX-Cache-Update")}; };
// parse-swap-spec
var parseSwapSpec = function(rawSwap, globalTransitions_p) { return (function() {
@@ -1746,6 +1746,7 @@ return forEach(function(attr) { return (isSxTruthy(!isSxTruthy(domHasAttr(newEl,
return (isSxTruthy(newHash) ? (_cssHash = newHash) : NIL);
})();
dispatchTriggerEvents(el, get(respHeaders, "trigger"));
processCacheDirectives(el, respHeaders, text);
return (isSxTruthy(get(respHeaders, "redirect")) ? browserNavigate(get(respHeaders, "redirect")) : (isSxTruthy(get(respHeaders, "refresh")) ? browserReload() : (isSxTruthy(get(respHeaders, "location")) ? fetchLocation(get(respHeaders, "location")) : (function() {
var targetEl = (isSxTruthy(get(respHeaders, "retarget")) ? domQuery(get(respHeaders, "retarget")) : resolveTarget(el));
var swapSpec = parseSwapSpec(sxOr(get(respHeaders, "reswap"), domGetAttr(el, "sx-swap")), domHasClass(domBody(), "sx-transitions"));
@@ -1955,6 +1956,42 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
// page-data-cache-set
var pageDataCacheSet = function(cacheKey, data) { return dictSet(_pageDataCache, cacheKey, {"data": data, "ts": nowMs()}); };
// invalidate-page-cache
var invalidatePageCache = function(pageName) { { var _c = keys(_pageDataCache); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; if (isSxTruthy(sxOr((k == pageName), startsWith(k, (String(pageName) + String(":")))))) {
_pageDataCache[k] = NIL;
} } }
swPostMessage({"type": "invalidate", "page": pageName});
return logInfo((String("sx:cache invalidate ") + String(pageName))); };
// invalidate-all-page-cache
var invalidateAllPageCache = function() { _pageDataCache = {};
swPostMessage({"type": "invalidate", "page": "*"});
return logInfo("sx:cache invalidate *"); };
// update-page-cache
var updatePageCache = function(pageName, data) { return (function() {
var cacheKey = pageDataCacheKey(pageName, {});
pageDataCacheSet(cacheKey, data);
return logInfo((String("sx:cache update ") + String(pageName)));
})(); };
// process-cache-directives
var processCacheDirectives = function(el, respHeaders, responseText) { (function() {
var elInvalidate = domGetAttr(el, "sx-cache-invalidate");
return (isSxTruthy(elInvalidate) ? (isSxTruthy((elInvalidate == "*")) ? invalidateAllPageCache() : invalidatePageCache(elInvalidate)) : NIL);
})();
(function() {
var hdrInvalidate = get(respHeaders, "cache-invalidate");
return (isSxTruthy(hdrInvalidate) ? (isSxTruthy((hdrInvalidate == "*")) ? invalidateAllPageCache() : invalidatePageCache(hdrInvalidate)) : NIL);
})();
return (function() {
var hdrUpdate = get(respHeaders, "cache-update");
return (isSxTruthy(hdrUpdate) ? (function() {
var data = parseSxData(responseText);
return (isSxTruthy(data) ? updatePageCache(hdrUpdate, data) : NIL);
})() : NIL);
})(); };
// current-page-layout
var currentPageLayout = function() { return (function() {
var pathname = urlPathname(browserLocationHref());
@@ -2541,6 +2578,7 @@ callExpr.push(dictGet(kwargs, k)); } }
renderDomElement = function(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var extraClasses = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
@@ -2563,6 +2601,10 @@ callExpr.push(dictGet(kwargs, k)); } }
}
}
}
if (extraClasses.length) {
var existing = el.getAttribute("class") || "";
el.setAttribute("class", (existing ? existing + " " : "") + extraClasses.join(" "));
}
return el;
};
@@ -3291,6 +3333,27 @@ callExpr.push(dictGet(kwargs, k)); } }
});
}
function parseSxData(text) {
// Parse SX text into a data value. Returns the first parsed expression,
// or NIL on error. Used by cache update directives.
try {
var exprs = parse(text);
return exprs.length >= 1 ? exprs[0] : NIL;
} catch (e) {
logWarn("sx:cache parse error: " + (e && e.message ? e.message : e));
return NIL;
}
}
function swPostMessage(msg) {
// Send a message to the active service worker (if registered).
// Used to notify SW of cache invalidation.
if (typeof navigator !== "undefined" && navigator.serviceWorker &&
navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage(msg);
}
}
function urlPathname(href) {
try {
return new URL(href, location.href).pathname;
@@ -3425,37 +3488,6 @@ callExpr.push(dictGet(kwargs, k)); } }
}
// =========================================================================
// Platform interface — CSSX (style dictionary)
// =========================================================================
function fnv1aHash(input) {
var h = 0x811c9dc5;
for (var i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0").substring(0, 6);
}
function compileRegex(pattern) {
try { return new RegExp(pattern); } catch (e) { return null; }
}
function regexMatch(re, s) {
if (!re) return NIL;
var m = s.match(re);
return m ? Array.prototype.slice.call(m) : NIL;
}
function regexReplaceGroups(tmpl, match) {
var result = tmpl;
for (var j = 1; j < match.length; j++) {
result = result.split("{" + (j - 1) + "}").join(match[j]);
}
return result;
}
// =========================================================================
// Platform interface — Boot (mount, hydrate, scripts, cookies)
// =========================================================================
@@ -3591,6 +3623,8 @@ callExpr.push(dictGet(kwargs, k)); } }
}
}
// =========================================================================
// Post-transpilation fixups
// =========================================================================
@@ -4359,6 +4393,14 @@ callExpr.push(dictGet(kwargs, k)); } }
}
// Set up direct resolution for future chunks
global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); };
// Register service worker for offline data caching
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sx-sw.js", { scope: "/" }).then(function(reg) {
logInfo("sx:sw registered (scope: " + reg.scope + ")");
}).catch(function(err) {
logWarn("sx:sw registration failed: " + (err && err.message ? err.message : err));
});
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _sxInit);
@@ -4369,4 +4411,4 @@ callExpr.push(dictGet(kwargs, k)); } }
if (typeof module !== "undefined" && module.exports) module.exports = Sx;
else global.Sx = Sx;
})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);
})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);

View File

@@ -0,0 +1,219 @@
// ==========================================================================
// sx-sw.js — SX Service Worker
//
// Provides offline-capable caching for the SX platform:
// /sx/data/* — Network-first, IndexedDB fallback (page data)
// /sx/io/* — Network-first, IndexedDB fallback (IO proxy)
// /static/* — Stale-while-revalidate (CSS, JS, images)
// Other — Network-only (pass through)
//
// IndexedDB stores response text + content-type with timestamps.
// Stale IDB entries are served when offline but refreshed when online.
// ==========================================================================
var IDB_NAME = "sx-offline";
var IDB_VERSION = 1;
var IDB_STORE = "responses";
var STATIC_CACHE = "sx-static-v1";
// ---------------------------------------------------------------------------
// IndexedDB helpers
// ---------------------------------------------------------------------------
function openDB() {
return new Promise(function(resolve, reject) {
var req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = function() {
if (!req.result.objectStoreNames.contains(IDB_STORE)) {
req.result.createObjectStore(IDB_STORE);
}
};
req.onsuccess = function() { resolve(req.result); };
req.onerror = function() { reject(req.error); };
});
}
function idbGet(key) {
return openDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(IDB_STORE, "readonly");
var req = tx.objectStore(IDB_STORE).get(key);
req.onsuccess = function() { resolve(req.result || null); };
req.onerror = function() { reject(req.error); };
});
});
}
function idbSet(key, value) {
return openDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(IDB_STORE, "readwrite");
tx.objectStore(IDB_STORE).put(value, key);
tx.oncomplete = function() { resolve(); };
tx.onerror = function() { reject(tx.error); };
});
});
}
function idbDelete(key) {
return openDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(IDB_STORE, "readwrite");
tx.objectStore(IDB_STORE).delete(key);
tx.oncomplete = function() { resolve(); };
tx.onerror = function() { reject(tx.error); };
});
});
}
function idbClear() {
return openDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(IDB_STORE, "readwrite");
tx.objectStore(IDB_STORE).clear();
tx.oncomplete = function() { resolve(); };
tx.onerror = function() { reject(tx.error); };
});
});
}
// ---------------------------------------------------------------------------
// Fetch strategies
// ---------------------------------------------------------------------------
// Network-first with IndexedDB fallback.
// Used for /sx/data/ and /sx/io/ — dynamic data that should be fresh
// when online but available from cache when offline.
function networkFirstIDB(request) {
var key = request.url;
return fetch(request.clone()).then(function(resp) {
if (resp.ok) {
var ct = resp.headers.get("Content-Type") || "text/sx";
resp.clone().text().then(function(text) {
idbSet(key, { text: text, contentType: ct, ts: Date.now() });
});
}
return resp;
}).catch(function() {
// Network failed — try IndexedDB
return idbGet(key).then(function(cached) {
if (cached) {
return new Response(cached.text, {
status: 200,
headers: {
"Content-Type": cached.contentType || "text/sx",
"X-SX-Offline": "1",
"X-SX-Cached-At": String(cached.ts)
}
});
}
return new Response("", { status: 503, statusText: "Offline" });
});
});
}
// Stale-while-revalidate for static assets.
// Serve from Cache API immediately, update in background.
function staleWhileRevalidate(request) {
return caches.open(STATIC_CACHE).then(function(cache) {
return cache.match(request).then(function(cached) {
var fetchPromise = fetch(request.clone()).then(function(resp) {
if (resp.ok) {
cache.put(request, resp.clone());
}
return resp;
}).catch(function() {
return cached || new Response("", { status: 503 });
});
return cached || fetchPromise;
});
});
}
// ---------------------------------------------------------------------------
// Service Worker lifecycle
// ---------------------------------------------------------------------------
self.addEventListener("install", function(event) {
// Activate immediately — don't wait for old SW to stop
self.skipWaiting();
});
self.addEventListener("activate", function(event) {
// Claim all clients immediately
event.waitUntil(
Promise.all([
self.clients.claim(),
// Clean old static caches
caches.keys().then(function(names) {
return Promise.all(
names.filter(function(n) { return n !== STATIC_CACHE; })
.map(function(n) { return caches.delete(n); })
);
})
])
);
});
// ---------------------------------------------------------------------------
// Fetch routing
// ---------------------------------------------------------------------------
self.addEventListener("fetch", function(event) {
var url = new URL(event.request.url);
// Only handle same-origin GET requests
if (event.request.method !== "GET") return;
if (url.origin !== self.location.origin) return;
// SX data endpoints — network-first with IDB fallback
if (url.pathname.indexOf("/sx/data/") === 0 ||
url.pathname.indexOf("/sx/io/") === 0) {
event.respondWith(networkFirstIDB(event.request));
return;
}
// Static assets — stale-while-revalidate
if (url.pathname.indexOf("/static/") === 0) {
event.respondWith(staleWhileRevalidate(event.request));
return;
}
// Everything else: pass through to network (default behavior)
});
// ---------------------------------------------------------------------------
// Message handling — cache control from main thread
// ---------------------------------------------------------------------------
self.addEventListener("message", function(event) {
var msg = event.data;
if (!msg || !msg.type) return;
if (msg.type === "invalidate") {
// Invalidate specific page data from IDB
if (msg.page === "*") {
idbClear();
} else if (msg.page) {
// Remove all IDB entries matching this page
openDB().then(function(db) {
var tx = db.transaction(IDB_STORE, "readwrite");
var store = tx.objectStore(IDB_STORE);
var req = store.openCursor();
req.onsuccess = function() {
var cursor = req.result;
if (cursor) {
if (cursor.key.indexOf("/sx/data/" + msg.page) >= 0) {
cursor.delete();
}
cursor.continue();
}
};
});
}
}
if (msg.type === "clear-static") {
caches.delete(STATIC_CACHE);
}
});

View File

@@ -884,6 +884,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
mount_io_endpoint(app, service_name)
# Mount service worker at root scope for offline data layer
mount_service_worker(app)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
@@ -1186,3 +1189,39 @@ def mount_io_endpoint(app: Any, service_name: str) -> None:
methods=["GET", "POST"],
)
logger.info("Mounted IO proxy for %s: %s", service_name, sorted(_ALLOWED_IO))
# ---------------------------------------------------------------------------
# Service Worker mount
# ---------------------------------------------------------------------------
_SW_MOUNTED = False
def mount_service_worker(app: Any) -> None:
"""Mount the SX service worker at /sx-sw.js (root scope).
The service worker provides offline data caching:
- /sx/data/* and /sx/io/* responses cached in IndexedDB
- /static/* assets cached via Cache API (stale-while-revalidate)
- Everything else passes through to network
"""
global _SW_MOUNTED
if _SW_MOUNTED:
return
_SW_MOUNTED = True
sw_path = os.path.join(
os.path.dirname(__file__), "..", "static", "scripts", "sx-sw.js"
)
sw_path = os.path.abspath(sw_path)
async def serve_sw():
from quart import send_file
return await send_file(
sw_path,
mimetype="application/javascript",
cache_timeout=0, # no caching — SW updates checked by browser
)
app.add_url_rule("/sx-sw.js", endpoint="sx_service_worker", view_func=serve_sw)
logger.info("Mounted service worker at /sx-sw.js")

View File

@@ -3551,6 +3551,27 @@ PLATFORM_ORCHESTRATION_JS = """
});
}
function parseSxData(text) {
// Parse SX text into a data value. Returns the first parsed expression,
// or NIL on error. Used by cache update directives.
try {
var exprs = parse(text);
return exprs.length >= 1 ? exprs[0] : NIL;
} catch (e) {
logWarn("sx:cache parse error: " + (e && e.message ? e.message : e));
return NIL;
}
}
function swPostMessage(msg) {
// Send a message to the active service worker (if registered).
// Used to notify SW of cache invalidation.
if (typeof navigator !== "undefined" && navigator.serviceWorker &&
navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage(msg);
}
}
function urlPathname(href) {
try {
return new URL(href, location.href).pathname;
@@ -4025,6 +4046,14 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has
}
// Set up direct resolution for future chunks
global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); };
// Register service worker for offline data caching
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sx-sw.js", { scope: "/" }).then(function(reg) {
logInfo("sx:sw registered (scope: " + reg.scope + ")");
}).catch(function(err) {
logWarn("sx:sw registration failed: " + (err && err.message ? err.message : err));
});
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _sxInit);

View File

@@ -154,17 +154,19 @@
;; Extract all SX response header directives into a dict.
;; get-header is (fn (name) → string or nil).
(dict
"redirect" (get-header "SX-Redirect")
"refresh" (get-header "SX-Refresh")
"trigger" (get-header "SX-Trigger")
"retarget" (get-header "SX-Retarget")
"reswap" (get-header "SX-Reswap")
"location" (get-header "SX-Location")
"replace-url" (get-header "SX-Replace-Url")
"css-hash" (get-header "SX-Css-Hash")
"trigger-swap" (get-header "SX-Trigger-After-Swap")
"trigger-settle" (get-header "SX-Trigger-After-Settle")
"content-type" (get-header "Content-Type"))))
"redirect" (get-header "SX-Redirect")
"refresh" (get-header "SX-Refresh")
"trigger" (get-header "SX-Trigger")
"retarget" (get-header "SX-Retarget")
"reswap" (get-header "SX-Reswap")
"location" (get-header "SX-Location")
"replace-url" (get-header "SX-Replace-Url")
"css-hash" (get-header "SX-Css-Hash")
"trigger-swap" (get-header "SX-Trigger-After-Swap")
"trigger-settle" (get-header "SX-Trigger-After-Settle")
"content-type" (get-header "Content-Type")
"cache-invalidate" (get-header "SX-Cache-Invalidate")
"cache-update" (get-header "SX-Cache-Update"))))
;; --------------------------------------------------------------------------

View File

@@ -195,6 +195,10 @@
;; Triggers (before swap)
(dispatch-trigger-events el (get resp-headers "trigger"))
;; Cache directives — process before navigation so cache is
;; ready when the target page loads.
(process-cache-directives el resp-headers text)
(cond
;; Redirect
(get resp-headers "redirect")
@@ -589,6 +593,76 @@
{"data" data "ts" (now-ms)})))
;; --------------------------------------------------------------------------
;; Client-side routing — cache management
;; --------------------------------------------------------------------------
(define invalidate-page-cache
(fn (page-name)
;; Clear cached data for a page. Removes all cache entries whose key
;; matches page-name (exact) or starts with "page-name:" (with params).
;; Also notifies the service worker to clear its IndexedDB entries.
(for-each
(fn (k)
(when (or (= k page-name) (starts-with? k (str page-name ":")))
(dict-set! _page-data-cache k nil)))
(keys _page-data-cache))
(sw-post-message {"type" "invalidate" "page" page-name})
(log-info (str "sx:cache invalidate " page-name))))
(define invalidate-all-page-cache
(fn ()
;; Clear all cached page data and notify service worker.
(set! _page-data-cache (dict))
(sw-post-message {"type" "invalidate" "page" "*"})
(log-info "sx:cache invalidate *")))
(define update-page-cache
(fn (page-name data)
;; Replace cached data for a page with server-provided data.
;; Uses a bare page-name key (no params) — the server knows the
;; canonical data shape for the page.
(let ((cache-key (page-data-cache-key page-name (dict))))
(page-data-cache-set cache-key data)
(log-info (str "sx:cache update " page-name)))))
(define process-cache-directives
(fn (el resp-headers response-text)
;; Process cache invalidation and update directives from both
;; element attributes and response headers.
;;
;; Element attributes (set by component author):
;; sx-cache-invalidate="page-name" — clear page cache on success
;; sx-cache-invalidate="*" — clear all page caches
;;
;; Response headers (set by server):
;; SX-Cache-Invalidate: page-name — clear page cache
;; SX-Cache-Update: page-name — replace cache with response data
;; 1. Element-level invalidation
(let ((el-invalidate (dom-get-attr el "sx-cache-invalidate")))
(when el-invalidate
(if (= el-invalidate "*")
(invalidate-all-page-cache)
(invalidate-page-cache el-invalidate))))
;; 2. Response header invalidation
(let ((hdr-invalidate (get resp-headers "cache-invalidate")))
(when hdr-invalidate
(if (= hdr-invalidate "*")
(invalidate-all-page-cache)
(invalidate-page-cache hdr-invalidate))))
;; 3. Response header cache update (server pushes fresh data)
;; parse-sx-data is a platform-provided function that parses SX text
;; into a data value (returns nil on parse error).
(let ((hdr-update (get resp-headers "cache-update")))
(when hdr-update
(let ((data (parse-sx-data response-text)))
(when data
(update-page-cache hdr-update data)))))))
;; --------------------------------------------------------------------------
;; Client-side routing
;; --------------------------------------------------------------------------
@@ -1061,6 +1135,8 @@
;; (resolve-page-data name params cb) → void; resolves data for a named page.
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
;; when data is available. params is a dict of URL/route parameters.
;; (parse-sx-data text) → parsed SX data value, or nil on error.
;; Used by cache update to parse server-provided data in SX format.
;;
;; From boot.sx:
;; _page-routes → list of route entries
@@ -1080,4 +1156,8 @@
;; (csrf-token) → string
;; (cross-origin? url) → boolean
;; (now-ms) → timestamp ms
;;
;; === Cache management ===
;; (parse-sx-data text) → parsed SX data value, or nil on error
;; (sw-post-message msg) → void; post message to active service worker
;; --------------------------------------------------------------------------

View File

@@ -1974,7 +1974,7 @@
(div :class "flex items-center gap-2 mb-2"
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
(p :class "text-green-900 font-medium" "What it enables")
(p :class "text-green-800" "Same SX code runs on either side. Runtime chooses optimal split via affinity annotations and render plans. Cross-host isomorphism verified by 61 automated tests."))
(p :class "text-green-800" "Same SX code runs on either side. Runtime chooses optimal split via affinity annotations and render plans. Client data cache managed via invalidation headers and server-driven updates. Cross-host isomorphism verified by 61 automated tests."))
(~doc-subsection :title "7a. Affinity Annotations & Render Target"
@@ -2039,11 +2039,70 @@
(li "Render plans visible on " (a :href "/isomorphism/affinity" "affinity demo page"))
(li "Client page registry includes :render-plan for each page"))))
(~doc-subsection :title "7c. Optimistic Data Updates"
(p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection."))
(~doc-subsection :title "7c. Cache Invalidation & Data Updates"
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
(div :class "flex items-center gap-2 mb-1"
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
(p :class "text-green-800 text-sm" "Client data cache management: invalidation on mutation, server-driven cache updates, and programmatic cache control from SX code."))
(p "The client-side page data cache (30-second TTL) now supports cache invalidation and server-driven updates, extending the existing DOM-level " (code "apply-optimistic") "/" (code "revert-optimistic") " to data-level cache management.")
(~doc-subsection :title "Element Attributes"
(p "Component authors can declare cache invalidation on elements that trigger mutations:")
(~doc-code :code (highlight ";; Clear specific page's cache after successful action\n(form :sx-post \"/cart/remove\"\n :sx-cache-invalidate \"cart-page\"\n ...)\n\n;; Clear ALL page caches after action\n(button :sx-post \"/admin/reset\"\n :sx-cache-invalidate \"*\")" "lisp"))
(p "When the request succeeds, the named page's data cache is cleared. The next client-side navigation to that page will re-fetch fresh data from the server."))
(~doc-subsection :title "Response Headers"
(p "The server can also control client cache via response headers:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (code "SX-Cache-Invalidate: page-name") " — clear cache for a page")
(li (code "SX-Cache-Invalidate: *") " — clear all page caches")
(li (code "SX-Cache-Update: page-name") " — replace cache with the response body (SX-format data)"))
(p (code "SX-Cache-Update") " is the strongest form: the server pushes authoritative data directly into the client cache, so the user sees fresh data immediately on next navigation — no re-fetch needed."))
(~doc-subsection :title "Programmatic API"
(p "Three functions available from SX orchestration code:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "(invalidate-page-cache page-name)")
(li "(invalidate-all-page-cache)")
(li "(update-page-cache page-name data)")))
(~doc-subsection :title "Files"
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "shared/sx/ref/engine.sx — SX-Cache-Invalidate, SX-Cache-Update response headers")
(li "shared/sx/ref/orchestration.sx — cache management functions, process-cache-directives")
(li "shared/sx/ref/bootstrap_js.py — parseSxData platform function"))))
(~doc-subsection :title "7d. Offline Data Layer"
(p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online."))
(div :class "rounded border border-green-300 bg-green-50 p-3 mb-4"
(div :class "flex items-center gap-2 mb-1"
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete"))
(p :class "text-green-800 text-sm" "Service Worker with IndexedDB caching for offline-capable page data and IO responses. Static assets cached via Cache API."))
(p "A Service Worker registered at " (code "/sx-sw.js") " provides three-tier caching:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "/sx/data/* ") "— network-first with IndexedDB fallback. Page data is cached on successful fetch and served from IndexedDB when offline.")
(li (strong "/sx/io/* ") "— network-first with IndexedDB fallback. IO proxy responses cached the same way.")
(li (strong "/static/* ") "— stale-while-revalidate via Cache API. Serves cached CSS/JS/images immediately, updates in background."))
(p "Cache invalidation flows through to the Service Worker: when " (code "invalidate-page-cache") " clears the in-memory cache, it also sends a " (code "postMessage") " to the SW which removes matching entries from IndexedDB.")
(~doc-subsection :title "How It Works"
(ol :class "list-decimal list-inside text-stone-700 space-y-2"
(li "On boot, " (code "sx-browser.js") " registers the SW at " (code "/sx-sw.js") " (root scope)")
(li "SW intercepts fetch events and routes by URL pattern")
(li "For data/IO: try network first, on failure serve from IndexedDB")
(li "For static assets: serve from Cache API, revalidate in background")
(li "Cache invalidation propagates: element attr / response header → in-memory cache → SW message → IndexedDB")))
(~doc-subsection :title "Files"
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "shared/static/scripts/sx-sw.js — Service Worker (network-first + stale-while-revalidate)")
(li "shared/sx/pages.py — mount_service_worker() serves SW at /sx-sw.js")
(li "shared/sx/ref/bootstrap_js.py — SW registration in boot init")
(li "shared/sx/ref/orchestration.sx — sw-post-message for cache invalidation"))))
(~doc-subsection :title "7e. Isomorphic Testing"