diff --git a/PLAN.md b/PLAN.md index 0bcfeb0..ac9635f 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:** Blocked by Slice 2. +- **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. - **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:** browser scenario mutates offline, reloads, restores the projection, exports/deletes/reset data, and fails recoverably under quota, corruption, and unknown schema 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, 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. ## Slice 4 — authoritative reconnect and convergence diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs index ecbb9e4..45477c0 100644 --- a/examples/kanban/src/lib.rs +++ b/examples/kanban/src/lib.rs @@ -1,25 +1,90 @@ #[hemx::surface] pub mod ui {} +#[cfg(feature = "client")] +#[derive(Clone, Debug, Eq, PartialEq)] +struct CardId(String); + +#[cfg(feature = "client")] +struct ReorderCommand { + card: CardId, + input_kind: String, +} + +#[cfg(feature = "client")] +struct CardReordered { + card: CardId, + input_kind: String, +} + +#[cfg(feature = "client")] +struct BoardProjection { + first: CardId, +} + +#[cfg(feature = "client")] +struct ProjectedReorder { + card: CardId, + before: Option, + input_kind: String, +} + +#[cfg(feature = "client")] +impl ReorderCommand { + fn from_client(event: hemx::wasm::ClientEvent) -> Self { + Self { + card: CardId( + event + .value + .filter(|card| !card.is_empty()) + .unwrap_or_else(|| "1".into()), + ), + input_kind: event.kind, + } + } + + fn decide(self) -> CardReordered { + CardReordered { + card: self.card, + input_kind: self.input_kind, + } + } +} + +#[cfg(feature = "client")] +impl BoardProjection { + fn restore(state: hemx::wasm::ClientState) -> Self { + Self { + first: CardId(state.encoded.split('|').next().unwrap_or("1").to_owned()), + } + } + + fn apply(self, event: CardReordered) -> ProjectedReorder { + let before = (event.card != self.first).then_some(self.first); + ProjectedReorder { + card: event.card, + before, + input_kind: event.input_kind, + } + } +} + #[cfg(feature = "client")] #[hemx::handler(client)] pub fn reorder_card( event: hemx::wasm::ClientEvent, state: hemx::wasm::ClientState, ) -> impl hemx::IntoEffect { - let card = event.value.unwrap_or_else(|| "1".to_owned()); - let order = state.encoded.split('|').collect::>(); - let move_effect = if card == order.first().copied().unwrap_or("1") { - ui::client_board::client_cards.move_to_end(card.clone()) - } else { - ui::client_board::client_cards.move_before( - card.clone(), - order.first().copied().unwrap_or("1").to_owned(), - ) + let projected = + BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide()); + let card = projected.card.0; + let move_effect = match projected.before { + Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0), + None => ui::client_board::client_cards.move_to_end(card.clone()), }; vec![ move_effect, - ui::client_board::client_notice.text(format!("Moved {card} with {}", event.kind)), + ui::client_board::client_notice.text(format!("Moved {card} with {}", projected.input_kind)), ] } diff --git a/examples/kanban/static/command-log.js b/examples/kanban/static/command-log.js new file mode 100644 index 0000000..77a379d --- /dev/null +++ b/examples/kanban/static/command-log.js @@ -0,0 +1,170 @@ +const DATABASE = "hemx-kanban-v1"; +const COMMANDS = "commands"; +const META = "meta"; +const COMMAND_SCHEMA = 1; +const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]'; + +function result(request) { + return new Promise((resolve, reject) => { + request.addEventListener("success", () => resolve(request.result), { once: true }); + request.addEventListener("error", () => reject(request.error || new Error("IndexedDB request failed")), { once: true }); + }); +} + +function completed(transaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", resolve, { once: true }); + transaction.addEventListener("abort", () => reject(transaction.error || new Error("IndexedDB transaction aborted")), { once: true }); + transaction.addEventListener("error", () => reject(transaction.error || new Error("IndexedDB transaction failed")), { once: true }); + }); +} + +function openCommandLog() { + const request = indexedDB.open(DATABASE, 1); + request.addEventListener("upgradeneeded", () => { + const database = request.result; + if (!database.objectStoreNames.contains(COMMANDS)) database.createObjectStore(COMMANDS, { keyPath: "id" }); + if (!database.objectStoreNames.contains(META)) database.createObjectStore(META); + }); + return result(request); +} + +function clientReady(root) { + if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve(); + return new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (!root.hasAttribute("data-hemx-client-ready")) return; + observer.disconnect(); + resolve(); + }); + observer.observe(root, { attributes: true, attributeFilter: ["data-hemx-client-ready"] }); + }); +} + +function stableSession() { + const key = "hemx-kanban-session-v1"; + let session = sessionStorage.getItem(key); + if (!session) { + session = crypto.randomUUID(); + sessionStorage.setItem(key, session); + } + return session; +} + +async function appendReorder(database, wire) { + const transaction = database.transaction([COMMANDS, META], "readwrite"); + const done = completed(transaction); + const meta = transaction.objectStore(META); + const commands = transaction.objectStore(COMMANDS); + const actorRequest = result(meta.get("actor")); + const causalRequest = result(meta.get("causal")); + const [storedActor, storedCausal] = await Promise.all([actorRequest, causalRequest]); + const actor = storedActor || crypto.randomUUID(); + const causal = (storedCausal || 0) + 1; + const command = { + id: `${actor}:${causal}`, + schemaVersion: COMMAND_SCHEMA, + actor, + session: stableSession(), + causal, + kind: "reorder_card", + cardId: String(wire[2] || "1"), + eventKind: String(wire[1] || "click"), + key: wire[4] ? String(wire[4]) : null, + }; + meta.put(actor, "actor"); + meta.put(causal, "causal"); + commands.add(command); + const count = await result(commands.count()); + await done; + return { command, count }; +} + +async function storedCommands(database) { + const transaction = database.transaction(COMMANDS, "readonly"); + const done = completed(transaction); + const commands = await result(transaction.objectStore(COMMANDS).getAll()); + await done; + return commands.sort((left, right) => left.causal - right.causal); +} + +function validate(command) { + if (command.schemaVersion !== COMMAND_SCHEMA || command.kind !== "reorder_card" || !command.id || !command.actor || !command.session || !command.cardId || !Number.isSafeInteger(command.causal)) { + throw new Error(`unsupported durable command ${command && command.id ? command.id : "record"}`); + } + return command; +} + +async function project(root, wasmHandler, command) { + const checked = validate(command); + const batch = await wasmHandler( + 1, + checked.eventKind, + checked.cardId, + undefined, + checked.key || undefined, + 1, + root.getAttribute("data-hemx-st") || "", + ); + if (!(batch instanceof Uint8Array)) throw new Error("reorder_card returned an invalid effect batch"); + return batch; +} + +function report(root, stage, error) { + const message = error instanceof Error ? error.message : String(error); + root.setAttribute("data-kanban-command-error", `${stage}: ${message}`); + root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } })); +} + +async function start() { + const root = document.querySelector(ROOT); + if (!root) return; + const databasePromise = openCommandLog(); + await clientReady(root); + let wasmHandler; + const durableHandler = async (...wire) => { + let command; + let count; + try { + ({ command, count } = await appendReorder(await databasePromise, wire)); + } catch (error) { + report(root, "persist", error); + throw error; + } + root.setAttribute("data-kanban-command-count", String(count)); + root.dispatchEvent(new CustomEvent("kanban:command-persisted", { + detail: { + id: command.id, + schemaVersion: command.schemaVersion, + actor: command.actor, + session: command.session, + causal: command.causal, + }, + })); + try { + return await project(root, wasmHandler, command); + } catch (error) { + report(root, "project", error); + throw error; + } + }; + + 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; + try { + const commands = await storedCommands(database); + 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", ""); + } catch (error) { + report(root, "restore", error); + throw error; + } +} + +start().catch((error) => { + const root = document.querySelector(ROOT); + if (root && !root.hasAttribute("data-kanban-command-error")) report(root, "open", error); + console.error("kanban durable command log failed", error); +}); diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index 930a154..94977ff 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -22,7 +22,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul .to_owned(); let (package, bootstrap, rendered) = build_browser_artifact(&workspace); let runtime = workspace.join("hemx-js/runtime/hemx.js"); - let server = StaticServer::start(package, runtime, bootstrap, rendered, "client_local"); + let server = StaticServer::start(package, runtime, bootstrap, rendered, "client_local", None); let webdriver_port = available_port(); let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); @@ -191,6 +191,148 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul result.and(quit) } +#[tokio::test] +async fn kanban_command_persists_before_projection_and_restores_after_reload() -> WebDriverResult<()> +{ + // test req: local/001 req: local/002 req: local/003 req: local/004 + 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(workspace.join("examples/kanban/static/command-log.js")), + ); + + 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, + "return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')", + ) + .await?; + driver + .execute( + r#" + const root = document.querySelector('[data-hemx-root]'); + window.__persistedCommand = null; + root.addEventListener('kanban:command-persisted', (event) => { + window.__persistedCommand = { + detail: event.detail, + order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), + }; + }, { once: true }); + return true; + "#, + Vec::new(), + ) + .await?; + driver.find(By::Css("[data-card-id='1']")).await?.click().await?; + wait_until( + &driver, + "return window.__persistedCommand && [...document.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|') === '2|1'", + ) + .await?; + let persisted = driver + .execute( + "return { persisted: window.__persistedCommand, count: document.querySelector('[data-hemx-root]').getAttribute('data-kanban-command-count') }", + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(persisted["persisted"]["order"], "1|2"); + assert_eq!(persisted["persisted"]["detail"]["schemaVersion"], 1); + assert_eq!(persisted["persisted"]["detail"]["causal"], 1); + let actor = persisted["persisted"]["detail"]["actor"] + .as_str() + .expect("persisted actor"); + let session = persisted["persisted"]["detail"]["session"] + .as_str() + .expect("persisted session"); + assert!(!actor.is_empty()); + assert!(!session.is_empty()); + assert_eq!(persisted["persisted"]["detail"]["id"], format!("{actor}:1")); + assert_eq!(persisted["count"], "1"); + + driver.refresh().await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')", + ) + .await?; + let restored = driver + .execute( + "const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.getAttribute('data-key')).join('|'), count: root.getAttribute('data-kanban-command-count'), error: root.getAttribute('data-kanban-command-error'), notice: root.querySelector('[data-sid]').textContent }", + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!(restored["order"], "2|1"); + assert_eq!(restored["count"], "1"); + assert!(restored["error"].is_null()); + assert_eq!(restored["notice"], "Moved 1 with click"); + + driver + .execute( + r#" + window.__futureCommandStored = false; + const request = indexedDB.open('hemx-kanban-v1', 1); + request.onsuccess = () => { + const tx = request.result.transaction('commands', 'readwrite'); + tx.objectStore('commands').add({ + id: 'future:2', schemaVersion: 2, actor: 'future', session: 'future', + causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null, + }); + tx.oncomplete = () => { window.__futureCommandStored = true; }; + }; + return true; + "#, + Vec::new(), + ) + .await?; + wait_until(&driver, "return window.__futureCommandStored === true").await?; + driver.refresh().await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')", + ) + .await?; + let rejected = driver + .execute( + "const root = document.querySelector('[data-hemx-root]'); return { error: root.getAttribute('data-kanban-command-error'), ready: root.hasAttribute('data-kanban-command-ready') }", + Vec::new(), + ) + .await? + .json() + .clone(); + assert_eq!( + rejected["error"], + "restore: unsupported durable command future:2" + ); + assert_eq!(rejected["ready"], false); + 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<()> { @@ -202,7 +344,14 @@ async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() - .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"); + let server = StaticServer::start( + package, + runtime, + bootstrap, + rendered, + "kanban_client", + Some(workspace.join("examples/kanban/static/command-log.js")), + ); let webdriver_port = available_port(); let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); @@ -455,6 +604,7 @@ impl StaticServer { bootstrap: PathBuf, rendered: String, asset_stem: &'static str, + app_module: Option, ) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture"); listener.set_nonblocking(true).expect("nonblocking fixture"); @@ -465,7 +615,13 @@ impl StaticServer { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { Ok((stream, _)) => serve( - stream, &package, &runtime, &bootstrap, &rendered, asset_stem, + stream, + &package, + &runtime, + &bootstrap, + &rendered, + asset_stem, + app_module.as_deref(), ), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)) @@ -495,6 +651,7 @@ fn serve( bootstrap: &Path, rendered: &str, asset_stem: &str, + app_module: Option<&Path>, ) { let mut request = [0_u8; 2048]; let length = stream.read(&mut request).unwrap_or(0); @@ -503,7 +660,7 @@ fn serve( let (content_type, body) = match path { "/" => ( "text/html; charset=utf-8", - fixture_html(rendered).into_bytes(), + fixture_html(rendered, app_module.is_some()).into_bytes(), ), "/hemx.js" => ( "text/javascript; charset=utf-8", @@ -521,11 +678,16 @@ fn serve( "text/javascript; charset=utf-8", fs::read(bootstrap).expect("read generated client bootstrap"), ), + "/app.js" if app_module.is_some() => ( + "text/javascript; charset=utf-8", + fs::read(app_module.expect("checked app module")).expect("read app module"), + ), _ => ("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.starts_with(&format!("/{asset_stem}")) { "200 OK" @@ -536,9 +698,14 @@ fn serve( stream.write_all(&body).expect("write fixture body"); } -fn fixture_html(rendered: &str) -> String { +fn fixture_html(rendered: &str, has_app_module: bool) -> String { + let app_module = if has_app_module { + "" + } else { + "" + }; format!( - "{rendered}" + "{rendered}{app_module}" ) }