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 +} +