feat(kanban): add local recovery controls

req: local/003\nreq: security/007
This commit is contained in:
slhx agent
2026-07-13 14:59:05 +02:00
parent 9a3c0335eb
commit d946975f2d
4 changed files with 290 additions and 5 deletions
+2 -2
View File
@@ -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
+88 -3
View File
@@ -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);
@@ -5,5 +5,11 @@
{+ card +}
</template>
</ul>
<fieldset>
<legend>Offline commands</legend>
<button type="button" data-kanban-command-action="export">Export commands</button>
<button type="button" data-kanban-command-action="delete">Delete commands</button>
<button type="button" data-kanban-command-action="reset">Reset local data</button>
</fieldset>
<div data-hemx-handle="reorder_card" data-hemx-on="drop" data-hemx-client="reorder_card" data-hemx-client-event="drop" data-hemx-client-policy="latest">Drop card</div>
</section>
+194
View File
@@ -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::<Vec<_>>();
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<ScriptRet> {
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 {