feat(kanban): restore commands offline

req: local/001\nreq: local/002\nreq: local/003\nreq: local/004
This commit is contained in:
slhx agent
2026-07-13 14:52:51 +02:00
parent 7298405e27
commit 9a3c0335eb
4 changed files with 112 additions and 20 deletions
+2 -2
View File
@@ -33,11 +33,11 @@ encryption, retention, backup, and deployment policy remain host concerns.
## Slice 3 — durable offline command log ## 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. - [ ] **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. - **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. - **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`. - **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 ## Slice 4 — authoritative reconnect and convergence
+12
View File
@@ -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() { function stableSession() {
const key = "hemx-kanban-session-v1"; const key = "hemx-kanban-session-v1";
let session = sessionStorage.getItem(key); let session = sessionStorage.getItem(key);
@@ -120,6 +130,7 @@ async function start() {
const root = document.querySelector(ROOT); const root = document.querySelector(ROOT);
if (!root) return; if (!root) return;
const databasePromise = openCommandLog(); const databasePromise = openCommandLog();
const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error));
await clientReady(root); await clientReady(root);
let wasmHandler; let wasmHandler;
const durableHandler = async (...wire) => { 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); 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-count", String(commands.length));
root.setAttribute("data-kanban-command-ready", ""); root.setAttribute("data-kanban-command-ready", "");
await offlineReady;
} catch (error) { } catch (error) {
report(root, "restore", error); report(root, "restore", error);
throw error; throw error;
+30
View File
@@ -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)),
);
});
+68 -18
View File
@@ -201,13 +201,13 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
.to_owned(); .to_owned();
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace); let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
let runtime = workspace.join("hemx-js/runtime/hemx.js"); let runtime = workspace.join("hemx-js/runtime/hemx.js");
let server = StaticServer::start( let mut server = StaticServer::start(
package, package,
runtime, runtime,
bootstrap, bootstrap,
rendered, rendered,
"kanban_client", "kanban_client",
Some(workspace.join("examples/kanban/static/command-log.js")), Some(kanban_app_assets(&workspace)),
); );
let webdriver_port = available_port(); 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?; driver.goto(&server.url()).await?;
wait_until( wait_until(
&driver, &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?; .await?;
driver driver
@@ -268,11 +268,26 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
assert!(!session.is_empty()); assert!(!session.is_empty());
assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1")); assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1"));
assert_eq!(persisted["count"], "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( wait_until(
&driver, &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?; .await?;
let restored = driver let restored = driver
@@ -307,10 +322,12 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
) )
.await?; .await?;
wait_until(&driver, "return window.__futureCommandStored === true").await?; wait_until(&driver, "return window.__futureCommandStored === true").await?;
driver.refresh().await?; driver
.execute("window.__reloadPending = true; location.reload()", Vec::new())
.await?;
wait_until( wait_until(
&driver, &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?; .await?;
let rejected = driver let rejected = driver
@@ -350,7 +367,7 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -
bootstrap, bootstrap,
rendered, rendered,
"kanban_client", "kanban_client",
Some(workspace.join("examples/kanban/static/command-log.js")), Some(kanban_app_assets(&workspace)),
); );
let webdriver_port = available_port(); let webdriver_port = available_port();
@@ -592,9 +609,22 @@ fn newest_generated_bootstrap(build_dir: &Path, prefix: &str) -> PathBuf {
.expect("generated hemx client bootstrap") .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 { struct StaticServer {
address: String, address: String,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
} }
impl StaticServer { impl StaticServer {
@@ -604,14 +634,14 @@ impl StaticServer {
bootstrap: PathBuf, bootstrap: PathBuf,
rendered: String, rendered: String,
asset_stem: &'static str, asset_stem: &'static str,
app_module: Option<PathBuf>, app_assets: Option<AppAssets>,
) -> Self { ) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture"); let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
listener.set_nonblocking(true).expect("nonblocking fixture"); listener.set_nonblocking(true).expect("nonblocking fixture");
let address = listener.local_addr().expect("fixture address").to_string(); let address = listener.local_addr().expect("fixture address").to_string();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop); let thread_stop = Arc::clone(&stop);
thread::spawn(move || { let thread = thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) { while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() { match listener.accept() {
Ok((stream, _)) => serve( Ok((stream, _)) => serve(
@@ -621,7 +651,7 @@ impl StaticServer {
&bootstrap, &bootstrap,
&rendered, &rendered,
asset_stem, asset_stem,
app_module.as_deref(), app_assets.as_ref(),
), ),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10)) 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 { fn url(&self) -> String {
format!("http://{}", self.address) 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 { impl Drop for StaticServer {
fn drop(&mut self) { fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed); self.stop();
} }
} }
@@ -651,7 +696,7 @@ fn serve(
bootstrap: &Path, bootstrap: &Path,
rendered: &str, rendered: &str,
asset_stem: &str, asset_stem: &str,
app_module: Option<&Path>, app_assets: Option<&AppAssets>,
) { ) {
let mut request = [0_u8; 2048]; let mut request = [0_u8; 2048];
let length = stream.read(&mut request).unwrap_or(0); let length = stream.read(&mut request).unwrap_or(0);
@@ -660,7 +705,7 @@ fn serve(
let (content_type, body) = match path { let (content_type, body) = match path {
"/" => ( "/" => (
"text/html; charset=utf-8", "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" => ( "/hemx.js" => (
"text/javascript; charset=utf-8", "text/javascript; charset=utf-8",
@@ -678,16 +723,21 @@ fn serve(
"text/javascript; charset=utf-8", "text/javascript; charset=utf-8",
fs::read(bootstrap).expect("read generated client bootstrap"), 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", "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()), _ => ("text/plain", b"not found".to_vec()),
}; };
let status = if path == "/" let status = if path == "/"
|| path == "/hemx.js" || path == "/hemx.js"
|| path == "/hemx.client.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}")) || path.starts_with(&format!("/{asset_stem}"))
{ {
"200 OK" "200 OK"