From d946975f2d2e449be8f640facb30f12681b5a36e Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 13 Jul 2026 14:59:05 +0200 Subject: [PATCH] feat(kanban): add local recovery controls req: local/003\nreq: security/007 --- PLAN.md | 4 +- examples/kanban/static/command-log.js | 91 ++++++++- examples/kanban/templates/client_board.heml | 6 + hemx-wasm/tests/browser.rs | 194 ++++++++++++++++++++ 4 files changed, 290 insertions(+), 5 deletions(-) diff --git a/PLAN.md b/PLAN.md index e0855c6..0619540 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; 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. +- **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. Native recovery controls export a versioned credential-free command envelope, delete queued commands while preserving actor/causal identity, and reset command data, identity, cache, and registration behind explicit confirmation. Unknown schemas stop with an explicit diagnostic. 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, 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. +- **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. `cargo test -p hemx-wasm --test browser kanban_command_export_delete_and_reset_are_recoverable -- --exact` proves accessible export/delete/reset entry points, versioned credential-free export, confirmation before destructive actions, preserved identity after queue deletion, and fresh identity/baseline projection after reset. The completed slice proof must additionally fail recoverably under quota and corruption without claiming durability after persistence failure, bound replay, and satisfy the performance budget. ## Slice 4 — authoritative reconnect and convergence diff --git a/examples/kanban/static/command-log.js b/examples/kanban/static/command-log.js index b844e5a..4790ef8 100644 --- a/examples/kanban/static/command-log.js +++ b/examples/kanban/static/command-log.js @@ -2,6 +2,8 @@ const DATABASE = "hemx-kanban-v1"; const COMMANDS = "commands"; const META = "meta"; const COMMAND_SCHEMA = 1; +const EXPORT_SCHEMA = 1; +const SESSION = "hemx-kanban-session-v1"; const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]'; function result(request) { @@ -52,11 +54,10 @@ async function prepareOfflineShell(root) { } function stableSession() { - const key = "hemx-kanban-session-v1"; - let session = sessionStorage.getItem(key); + let session = sessionStorage.getItem(SESSION); if (!session) { session = crypto.randomUUID(); - sessionStorage.setItem(key, session); + sessionStorage.setItem(SESSION, session); } return session; } @@ -126,6 +127,89 @@ function report(root, stage, error) { root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } })); } +function announce(root, message) { + const status = root.querySelector('[role="status"]'); + if (status) status.textContent = message; +} + +function exportCommands(root, commands) { + const payload = { schemaVersion: EXPORT_SCHEMA, commands }; + const json = JSON.stringify(payload, null, 2); + const url = URL.createObjectURL(new Blob([json], { type: "application/json" })); + const download = document.createElement("a"); + download.href = url; + download.download = "hemx-kanban-commands.json"; + download.hidden = true; + document.body.append(download); + download.click(); + download.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + announce(root, `Exported ${commands.length} command${commands.length === 1 ? "" : "s"}.`); + root.dispatchEvent(new CustomEvent("kanban:commands-exported", { detail: payload })); +} + +async function clearCommands(database) { + const transaction = database.transaction(COMMANDS, "readwrite"); + const done = completed(transaction); + transaction.objectStore(COMMANDS).clear(); + await done; +} + +async function resetLocalData(database) { + database.close(); + await result(indexedDB.deleteDatabase(DATABASE)); + sessionStorage.removeItem(SESSION); + await Promise.all((await caches.keys()).filter((name) => name.startsWith("hemx-kanban-shell-")).map((name) => caches.delete(name))); + await Promise.all((await navigator.serviceWorker.getRegistrations()).map((registration) => registration.unregister())); +} + +function disarmRecoveryControls(controls) { + for (const control of controls) { + if (!control.dataset.confirmLabel) continue; + control.textContent = control.dataset.confirmLabel; + delete control.dataset.confirmLabel; + } +} + +function installRecoveryControls(root, database) { + const controls = [...root.querySelectorAll("[data-kanban-command-action]")]; + for (const control of controls) { + control.addEventListener("click", async () => { + const action = control.getAttribute("data-kanban-command-action"); + if ((action === "delete" || action === "reset") && !control.dataset.confirmLabel) { + disarmRecoveryControls(controls); + control.dataset.confirmLabel = control.textContent; + control.textContent = `Confirm ${control.textContent.toLowerCase()}`; + announce(root, `${control.dataset.confirmLabel} requires confirmation.`); + return; + } + if (action === "export") disarmRecoveryControls(controls); + controls.forEach((item) => { item.disabled = true; }); + try { + if (action === "export") { + exportCommands(root, await storedCommands(database)); + controls.forEach((item) => { item.disabled = false; }); + return; + } + if (action === "delete") { + await clearCommands(database); + root.dispatchEvent(new CustomEvent("kanban:commands-deleted")); + } else if (action === "reset") { + await resetLocalData(database); + root.dispatchEvent(new CustomEvent("kanban:local-data-reset")); + } else { + throw new Error(`unsupported recovery action ${action}`); + } + location.reload(); + } catch (error) { + controls.forEach((item) => { item.disabled = false; }); + disarmRecoveryControls(controls); + report(root, action || "recovery", error); + } + }); + } +} + async function start() { const root = document.querySelector(ROOT); if (!root) return; @@ -163,6 +247,7 @@ async function start() { wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler); if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered"); const database = await databasePromise; + installRecoveryControls(root, database); try { const commands = await storedCommands(database); for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root); diff --git a/examples/kanban/templates/client_board.heml b/examples/kanban/templates/client_board.heml index 5fe23a8..f1dfba8 100644 --- a/examples/kanban/templates/client_board.heml +++ b/examples/kanban/templates/client_board.heml @@ -5,5 +5,11 @@ {+ card +} +
+ Offline commands + + + +
Drop card
diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index 28b7997..ebae0eb 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -350,6 +350,184 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() - result.and(quit) } +#[tokio::test] +async fn kanban_command_export_delete_and_reset_are_recoverable() -> WebDriverResult<()> { + // test req: security/007 req: local/003 + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_owned(); + let (package, bootstrap, rendered) = build_kanban_artifact(&workspace); + let runtime = workspace.join("hemx-js/runtime/hemx.js"); + let server = StaticServer::start( + package, + runtime, + bootstrap, + rendered, + "kanban_client", + Some(kanban_app_assets(&workspace)), + ); + + let webdriver_port = available_port(); + let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); + let mut webdriver = Command::new("geckodriver"); + webdriver.arg("--port").arg(webdriver_port.to_string()); + let _webdriver = ProcessGuard::start(webdriver, &webdriver_addr); + let mut caps = DesiredCapabilities::firefox(); + caps.set_headless()?; + let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; + let result = async { + driver.goto(&server.url()).await?; + wait_until( + &driver, + "const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready')", + ) + .await?; + let controls = driver + .execute( + "return [...document.querySelectorAll('[data-kanban-command-action]')].map((button) => ({ tag: button.tagName, action: button.getAttribute('data-kanban-command-action'), text: button.textContent.trim() }))", + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(controls.as_array().map(Vec::len), Some(3)); + for control in controls.as_array().expect("recovery controls") { + assert_eq!(control["tag"], "BUTTON"); + assert!(!control["text"].as_str().unwrap_or_default().is_empty()); + } + + driver.find(By::Css("[data-card-id='1']")).await?.click().await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'", + ) + .await?; + let first_export = export_commands(&driver).await?.json().clone(); + assert_eq!(first_export["schemaVersion"], 1); + assert_eq!(first_export["commands"].as_array().map(Vec::len), Some(1)); + assert_eq!(first_export["commands"][0]["schemaVersion"], 1); + assert_eq!(first_export["commands"][0]["kind"], "reorder_card"); + assert_eq!(first_export["commands"][0]["cardId"], "1"); + let mut exported_keys = first_export["commands"][0] + .as_object() + .expect("exported command") + .keys() + .map(String::as_str) + .collect::>(); + exported_keys.sort_unstable(); + assert_eq!( + exported_keys, + [ + "actor", + "cardId", + "causal", + "eventKind", + "id", + "key", + "kind", + "schemaVersion", + "session", + ] + ); + let first_actor = first_export["commands"][0]["actor"] + .as_str() + .expect("first actor") + .to_owned(); + + driver + .execute("window.__reloadPending = true", Vec::new()) + .await?; + let delete = driver + .find(By::Css("[data-kanban-command-action='delete']")) + .await?; + delete.click().await?; + assert_eq!(delete.text().await?, "Confirm delete commands"); + assert_eq!( + driver + .find(By::Css("[data-hemx-root]")) + .await? + .attr("data-kanban-command-count") + .await? + .as_deref(), + Some("1") + ); + delete.click().await?; + wait_until( + &driver, + "const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-ready') && root.getAttribute('data-kanban-command-count') === '0'", + ) + .await?; + let after_delete = driver + .execute( + "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), error: root.getAttribute('data-kanban-command-error') }", + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(after_delete["order"], "1|2"); + assert!(after_delete["error"].is_null()); + + driver.find(By::Css("[data-card-id='1']")).await?.click().await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'", + ) + .await?; + let after_delete_export = export_commands(&driver).await?.json().clone(); + assert_eq!(after_delete_export["commands"][0]["actor"], first_actor); + assert_eq!(after_delete_export["commands"][0]["causal"], 2); + + driver + .execute("window.__reloadPending = true", Vec::new()) + .await?; + let reset = driver + .find(By::Css("[data-kanban-command-action='reset']")) + .await?; + reset.click().await?; + assert_eq!(reset.text().await?, "Confirm reset local data"); + assert_eq!( + driver + .find(By::Css("[data-hemx-root]")) + .await? + .attr("data-kanban-command-count") + .await? + .as_deref(), + Some("1") + ); + reset.click().await?; + wait_until( + &driver, + "const root = document.querySelector('[data-hemx-root]'); return !window.__reloadPending && root.hasAttribute('data-kanban-command-ready') && root.hasAttribute('data-kanban-offline-ready') && root.getAttribute('data-kanban-command-count') === '0'", + ) + .await?; + let after_reset = driver + .execute( + "const root = document.querySelector('[data-hemx-root]'); return [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|')", + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(after_reset, "1|2"); + + driver.find(By::Css("[data-card-id='1']")).await?.click().await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') === '1'", + ) + .await?; + let after_reset_export = export_commands(&driver).await?.json().clone(); + assert_eq!(after_reset_export["commands"][0]["causal"], 1); + assert_ne!(after_reset_export["commands"][0]["actor"], first_actor); + Ok(()) + } + .await; + let quit = driver.quit().await; + result.and(quit) +} + #[tokio::test] async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()> { @@ -759,6 +937,22 @@ fn fixture_html(rendered: &str, has_app_module: bool) -> String { ) } +async fn export_commands(driver: &WebDriver) -> WebDriverResult { + driver + .execute( + "window.__exported = null; document.querySelector('[data-hemx-root]').addEventListener('kanban:commands-exported', (event) => { window.__exported = event.detail; }, { once: true }); return true;", + Vec::new(), + ) + .await?; + driver + .find(By::Css("[data-kanban-command-action='export']")) + .await? + .click() + .await?; + wait_until(driver, "return window.__exported !== null").await?; + driver.execute("return window.__exported", Vec::new()).await +} + async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { let deadline = Instant::now() + STARTUP_TIMEOUT; loop {