feat(kanban): add local recovery controls
req: local/003\nreq: security/007
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user