diff --git a/PLAN.md b/PLAN.md index ac9635f..e0855c6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,11 +33,11 @@ encryption, retention, backup, and deployment policy remain host concerns. ## Slice 3 — durable offline command log - [ ] **User value:** an opted-in Kanban mutation remains available after network loss and browser reload without storing DOM patches as truth. -- **State:** In progress — the first app-owned `reorder_card` command is transactionally persisted with schema, actor, session, causal id, and app payload before projection; reload restores the projection from commands, while unknown schemas stop with an explicit diagnostic. Offline shell reload, export/delete/reset, quota/corruption recovery, bounded replay, and performance proof remain. +- **State:** In progress — the first app-owned `reorder_card` command is transactionally persisted with schema, actor, session, causal id, and app payload before projection; an app-owned service worker caches only the generated shell/resources, and reload restores the projection after the fixture server is stopped and proven unreachable. Unknown schemas stop with an explicit diagnostic. Export/delete/reset, quota/corruption recovery, bounded replay, and performance proof remain. - **Build:** add an optional durable command-log adapter around platform transactional storage; persist versioned command ids and app payload before projection; restore projection after reload; expose queue state, export/delete/reset, quota/corruption failure, and migration refusal. - **Refusals:** no server reconciliation, CRDT, mandatory IndexedDB, credential storage, or policy hidden in core. - **Requirements:** `local/001-004`, `sync/009`, `sync/014-015`, `sync/020`, `security/007`, `performance/006`. -- **Proof:** `cargo test -p hemx-wasm --test browser kanban_command_persists_before_projection_and_restores_after_reload -- --exact` proves transactional persist-before-project ordering, current-version replay after reload, stable identity metadata, and explicit unknown-schema refusal in real Firefox/WASM. The completed slice proof must additionally mutate and reload offline, export/delete/reset data, and fail recoverably under quota and corruption without claiming durability after persistence failure. +- **Proof:** `cargo test -p hemx-wasm --test browser kanban_command_persists_before_projection_and_restores_after_reload -- --exact` proves transactional persist-before-project ordering, app-owned shell caching, current-version replay after a real Firefox reload with the fixture server unreachable, stable identity metadata, and explicit unknown-schema refusal through real WASM. The completed slice proof must additionally export/delete/reset data and fail recoverably under quota and corruption without claiming durability after persistence failure. ## Slice 4 — authoritative reconnect and convergence diff --git a/examples/kanban/static/command-log.js b/examples/kanban/static/command-log.js index 77a379d..b844e5a 100644 --- a/examples/kanban/static/command-log.js +++ b/examples/kanban/static/command-log.js @@ -41,6 +41,16 @@ function clientReady(root) { }); } +async function prepareOfflineShell(root) { + if (!("serviceWorker" in navigator)) throw new Error("service workers are unavailable"); + await navigator.serviceWorker.register("/offline.js", { scope: "/" }); + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })); + } + root.setAttribute("data-kanban-offline-ready", ""); +} + function stableSession() { const key = "hemx-kanban-session-v1"; let session = sessionStorage.getItem(key); @@ -120,6 +130,7 @@ async function start() { const root = document.querySelector(ROOT); if (!root) return; const databasePromise = openCommandLog(); + const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error)); await clientReady(root); let wasmHandler; const durableHandler = async (...wire) => { @@ -157,6 +168,7 @@ async function start() { for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root); root.setAttribute("data-kanban-command-count", String(commands.length)); root.setAttribute("data-kanban-command-ready", ""); + await offlineReady; } catch (error) { report(root, "restore", error); throw error; diff --git a/examples/kanban/static/offline.js b/examples/kanban/static/offline.js new file mode 100644 index 0000000..157c51e --- /dev/null +++ b/examples/kanban/static/offline.js @@ -0,0 +1,30 @@ +const CACHE = "hemx-kanban-shell-v1"; +const SHELL = [ + "/", + "/hemx.js", + "/hemx.client.js", + "/kanban_client.js", + "/kanban_client_bg.wasm", + "/app.js", +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((names) => Promise.all(names.filter((name) => name.startsWith("hemx-kanban-shell-") && name !== CACHE).map((name) => caches.delete(name)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || !SHELL.includes(url.pathname)) return; + event.respondWith( + caches.match(event.request, { ignoreSearch: true }).then((cached) => cached || fetch(event.request)), + ); +}); diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index 94977ff..28b7997 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -201,13 +201,13 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() - .to_owned(); let (package, bootstrap, rendered) = build_kanban_artifact(&workspace); let runtime = workspace.join("hemx-js/runtime/hemx.js"); - let server = StaticServer::start( + let mut server = StaticServer::start( package, runtime, bootstrap, rendered, "kanban_client", - Some(workspace.join("examples/kanban/static/command-log.js")), + Some(kanban_app_assets(&workspace)), ); let webdriver_port = available_port(); @@ -222,7 +222,7 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() - driver.goto(&server.url()).await?; wait_until( &driver, - "return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')", + "const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')", ) .await?; driver @@ -268,11 +268,26 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() - assert!(!session.is_empty()); assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1")); assert_eq!(persisted["count"], "1"); + let offline_shell = driver + .execute_async( + "const done = arguments[arguments.length - 1]; Promise.all([caches.keys(), caches.match('/')]).then(([keys, shell]) => done({ controlled: Boolean(navigator.serviceWorker.controller), keys, shell: Boolean(shell) })).catch((error) => done({ error: String(error) }))", + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(offline_shell["controlled"], true, "{offline_shell}"); + assert_eq!(offline_shell["shell"], true, "{offline_shell}"); + assert_eq!(offline_shell["keys"][0], "hemx-kanban-shell-v1"); - driver.refresh().await?; + server.stop(); + assert!(!server.is_reachable(), "fixture server must be unreachable"); + driver + .execute("window.__reloadPending = true; location.reload()", Vec::new()) + .await?; wait_until( &driver, - "return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')", + "return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')", ) .await?; let restored = driver @@ -307,10 +322,12 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() - ) .await?; wait_until(&driver, "return window.__futureCommandStored === true").await?; - driver.refresh().await?; + driver + .execute("window.__reloadPending = true; location.reload()", Vec::new()) + .await?; wait_until( &driver, - "return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')", + "return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')", ) .await?; let rejected = driver @@ -350,7 +367,7 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() - bootstrap, rendered, "kanban_client", - Some(workspace.join("examples/kanban/static/command-log.js")), + Some(kanban_app_assets(&workspace)), ); let webdriver_port = available_port(); @@ -592,9 +609,22 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf { .expect("generated hemx client bootstrap") } +struct AppAssets { + module: PathBuf, + service_worker: PathBuf, +} + +fn kanban_app_assets(workspace: &Path) -> AppAssets { + AppAssets { + module: workspace.join("examples/kanban/static/command-log.js"), + service_worker: workspace.join("examples/kanban/static/offline.js"), + } +} + struct StaticServer { address: String, stop: Arc, + thread: Option>, } impl StaticServer { @@ -604,14 +634,14 @@ impl StaticServer { bootstrap: PathBuf, rendered: String, asset_stem: &'static str, - app_module: Option, + app_assets: Option, ) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture"); listener.set_nonblocking(true).expect("nonblocking fixture"); let address = listener.local_addr().expect("fixture address").to_string(); let stop = Arc::new(AtomicBool::new(false)); let thread_stop = Arc::clone(&stop); - thread::spawn(move || { + let thread = thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { Ok((stream, _)) => serve( @@ -621,7 +651,7 @@ impl StaticServer { &bootstrap, &rendered, asset_stem, - app_module.as_deref(), + app_assets.as_ref(), ), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)) @@ -630,17 +660,32 @@ impl StaticServer { } } }); - Self { address, stop } + Self { + address, + stop, + thread: Some(thread), + } } fn url(&self) -> String { format!("http://{}", self.address) } + + fn stop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + thread.join().expect("stop browser fixture"); + } + } + + fn is_reachable(&self) -> bool { + TcpStream::connect(&self.address).is_ok() + } } impl Drop for StaticServer { fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); + self.stop(); } } @@ -651,7 +696,7 @@ fn serve( bootstrap: &Path, rendered: &str, asset_stem: &str, - app_module: Option<&Path>, + app_assets: Option<&AppAssets>, ) { let mut request = [0_u8; 2048]; let length = stream.read(&mut request).unwrap_or(0); @@ -660,7 +705,7 @@ fn serve( let (content_type, body) = match path { "/" => ( "text/html; charset=utf-8", - fixture_html(rendered, app_module.is_some()).into_bytes(), + fixture_html(rendered, app_assets.is_some()).into_bytes(), ), "/hemx.js" => ( "text/javascript; charset=utf-8", @@ -678,16 +723,21 @@ fn serve( "text/javascript; charset=utf-8", fs::read(bootstrap).expect("read generated client bootstrap"), ), - "/app.js" if app_module.is_some() => ( + "/app.js" if app_assets.is_some() => ( "text/javascript; charset=utf-8", - fs::read(app_module.expect("checked app module")).expect("read app module"), + fs::read(&app_assets.expect("checked app assets").module).expect("read app module"), + ), + "/offline.js" if app_assets.is_some() => ( + "text/javascript; charset=utf-8", + fs::read(&app_assets.expect("checked app assets").service_worker) + .expect("read service worker"), ), _ => ("text/plain", b"not found".to_vec()), }; let status = if path == "/" || path == "/hemx.js" || path == "/hemx.client.js" - || (path == "/app.js" && app_module.is_some()) + || ((path == "/app.js" || path == "/offline.js") && app_assets.is_some()) || path.starts_with(&format!("/{asset_stem}")) { "200 OK"