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
|
## 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. 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.
|
- **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. 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
|
## Slice 4 — authoritative reconnect and convergence
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const DATABASE = "hemx-kanban-v1";
|
|||||||
const COMMANDS = "commands";
|
const COMMANDS = "commands";
|
||||||
const META = "meta";
|
const META = "meta";
|
||||||
const COMMAND_SCHEMA = 1;
|
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"]';
|
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]';
|
||||||
|
|
||||||
function result(request) {
|
function result(request) {
|
||||||
@@ -52,11 +54,10 @@ async function prepareOfflineShell(root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stableSession() {
|
function stableSession() {
|
||||||
const key = "hemx-kanban-session-v1";
|
let session = sessionStorage.getItem(SESSION);
|
||||||
let session = sessionStorage.getItem(key);
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
session = crypto.randomUUID();
|
session = crypto.randomUUID();
|
||||||
sessionStorage.setItem(key, session);
|
sessionStorage.setItem(SESSION, session);
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -126,6 +127,89 @@ function report(root, stage, error) {
|
|||||||
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } }));
|
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() {
|
async function start() {
|
||||||
const root = document.querySelector(ROOT);
|
const root = document.querySelector(ROOT);
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -163,6 +247,7 @@ async function start() {
|
|||||||
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
|
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
|
||||||
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
|
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
|
||||||
const database = await databasePromise;
|
const database = await databasePromise;
|
||||||
|
installRecoveryControls(root, database);
|
||||||
try {
|
try {
|
||||||
const commands = await storedCommands(database);
|
const commands = await storedCommands(database);
|
||||||
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
||||||
|
|||||||
@@ -5,5 +5,11 @@
|
|||||||
{+ card +}
|
{+ card +}
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</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>
|
<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>
|
</section>
|
||||||
|
|||||||
@@ -350,6 +350,184 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
|
|||||||
result.and(quit)
|
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]
|
#[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<()>
|
||||||
{
|
{
|
||||||
@@ -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<()> {
|
async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> {
|
||||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
Reference in New Issue
Block a user