fix(kanban): fail closed on persistence errors

req: sync/015
This commit is contained in:
slhx agent
2026-07-13 15:11:08 +02:00
parent d946975f2d
commit 47715e3ba3
3 changed files with 184 additions and 9 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; 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. - **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. An injected transactional conflict proves failed persistence neither projects nor emits a durability claim, exposes stage/code without command payload, and remains recoverable through deletion. Quota/corruption-specific 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, 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. - **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. `cargo test -p hemx-wasm --test browser kanban_persistence_failure_does_not_project_and_recovers -- --exact` proves transactional failure does not project or emit `kanban:command-persisted`, reports non-payload stage/code diagnostics, and recovers through the ordinary deletion path. The completed slice proof must additionally cover quota and corruption specifically, bound replay, and satisfy the performance budget.
## Slice 4 — authoritative reconnect and convergence ## Slice 4 — authoritative reconnect and convergence
+19 -5
View File
@@ -85,10 +85,21 @@ async function appendReorder(database, wire) {
}; };
meta.put(actor, "actor"); meta.put(actor, "actor");
meta.put(causal, "causal"); meta.put(causal, "causal");
commands.add(command); const append = result(commands.add(command));
const count = await result(commands.count()); const counted = result(commands.count());
await done; const completion = done.then(
return { command, count }; () => null,
(error) => error,
);
try {
const [, count] = await Promise.all([append, counted]);
const transactionError = await completion;
if (transactionError) throw transactionError;
return { command, count };
} catch (error) {
await completion;
throw error;
}
} }
async function storedCommands(database) { async function storedCommands(database) {
@@ -122,9 +133,12 @@ async function project(root, wasmHandler, command) {
} }
function report(root, stage, error) { function report(root, stage, error) {
const code = error && typeof error.name === "string" ? error.name : "Error";
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
root.setAttribute("data-kanban-command-error", `${stage}: ${message}`); root.setAttribute("data-kanban-command-error", `${stage}: ${message}`);
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } })); root.setAttribute("data-kanban-command-error-stage", stage);
root.setAttribute("data-kanban-command-error-code", code);
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, code, message } }));
} }
function announce(root, message) { function announce(root, message) {
+163 -2
View File
@@ -528,6 +528,111 @@ async fn kanban_command_export_delete_and_reset_are_recoverable() -> WebDriverRe
result.and(quit) result.and(quit)
} }
#[tokio::test]
async fn kanban_persistence_failure_does_not_project_and_recovers() -> WebDriverResult<()> {
// test req: sync/015
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?;
driver.find(By::Css("[data-card-id='1']")).await?.click().await?;
wait_until(
&driver,
"return [...document.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|') === '2|1'",
)
.await?;
occupy_next_command_id(&driver).await?;
driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); window.__persistedAfterFault = false; window.__commandFailure = null; root.addEventListener('kanban:command-persisted', () => { window.__persistedAfterFault = true; }, { once: true }); root.addEventListener('kanban:command-error', (event) => { window.__commandFailure = event.detail; }, { once: true }); return true;",
Vec::new(),
)
.await?;
driver.find(By::Css("[data-card-id='2']")).await?.click().await?;
wait_until(&driver, "return window.__commandFailure !== null").await?;
let failed = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), notice: root.querySelector('[role=status]').textContent, count: root.getAttribute('data-kanban-command-count'), stage: root.getAttribute('data-kanban-command-error-stage'), code: root.getAttribute('data-kanban-command-error-code'), persisted: window.__persistedAfterFault, detail: window.__commandFailure }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(failed["order"], "2|1");
assert_eq!(failed["notice"], "Moved 1 with click");
assert_eq!(failed["count"], "1");
assert_eq!(failed["stage"], "persist");
assert_eq!(failed["code"], "ConstraintError");
assert_eq!(failed["detail"]["stage"], "persist");
assert_eq!(failed["detail"]["code"], "ConstraintError");
assert_eq!(failed["persisted"], false);
driver
.execute("window.__reloadPending = true", Vec::new())
.await?;
let delete = driver
.find(By::Css("[data-kanban-command-action='delete']"))
.await?;
delete.click().await?;
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 recovered = driver
.execute(
"const root = document.querySelector('[data-hemx-root]'); return { order: [...root.querySelectorAll('[data-key]')].map((node) => node.dataset.key).join('|'), error: root.getAttribute('data-kanban-command-error') }",
Vec::new(),
)
.await?
.json()
.clone();
assert_eq!(recovered["order"], "1|2");
assert!(recovered["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_recovery = export_commands(&driver).await?.json().clone();
assert_eq!(after_recovery["commands"].as_array().map(Vec::len), Some(1));
assert_eq!(after_recovery["commands"][0]["causal"], 2);
Ok(())
}
.await;
let quit = driver.quit().await;
result.and(quit)
}
#[tokio::test] #[tokio::test]
async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()> async fn kanban_reorder_has_pointer_keyboard_focus_and_reduced_motion_parity() -> WebDriverResult<()>
{ {
@@ -922,8 +1027,10 @@ fn serve(
} else { } else {
"404 Not Found" "404 Not Found"
}; };
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).expect("write fixture headers"); if write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).is_err() {
stream.write_all(&body).expect("write fixture body"); return;
}
let _ = stream.write_all(&body);
} }
fn fixture_html(rendered: &str, has_app_module: bool) -> String { fn fixture_html(rendered: &str, has_app_module: bool) -> String {
@@ -937,6 +1044,60 @@ fn fixture_html(rendered: &str, has_app_module: bool) -> String {
) )
} }
async fn occupy_next_command_id(driver: &WebDriver) -> WebDriverResult<()> {
let occupied = driver
.execute_async(
r#"
const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 1);
open.onerror = () => done({ error: open.error && open.error.name });
open.onsuccess = () => {
const tx = open.result.transaction(['commands', 'meta'], 'readwrite');
const commands = tx.objectStore('commands');
const meta = tx.objectStore('meta');
let actor;
let causal;
let pending = 2;
const addCollision = () => {
pending -= 1;
if (pending !== 0) return;
const next = causal + 1;
commands.add({
id: `${actor}:${next}`,
schemaVersion: 1,
actor,
session: 'fault-injection',
causal: next,
kind: 'reorder_card',
cardId: 'fault-injection',
eventKind: 'click',
key: null,
});
};
const actorRequest = meta.get('actor');
actorRequest.onsuccess = () => { actor = actorRequest.result; addCollision(); };
const causalRequest = meta.get('causal');
causalRequest.onsuccess = () => { causal = causalRequest.result; addCollision(); };
tx.oncomplete = () => done({ id: `${actor}:${causal + 1}` });
tx.onabort = () => done({ error: tx.error && tx.error.name });
};
"#,
Vec::new(),
)
.await?
.json()
.clone();
assert!(
occupied["error"].is_null(),
"failed to occupy command id: {occupied}"
);
assert!(
occupied["id"].as_str().is_some(),
"missing occupied id: {occupied}"
);
Ok(())
}
async fn export_commands(driver: &WebDriver) -> WebDriverResult<ScriptRet> { async fn export_commands(driver: &WebDriver) -> WebDriverResult<ScriptRet> {
driver driver
.execute( .execute(