const DATABASE = "hemx-kanban-v1"; const DATABASE_VERSION = 3; const COMMANDS = "commands"; const META = "meta"; const ACCOUNT_INDEX = "byAccountPartition"; const COMMAND_SCHEMA = 2; const LEGACY_COMMAND_SCHEMA = 1; const MIGRATION_KEY = "commandSchemaMigration"; const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1"; const EXPORT_SCHEMA = 1; const MAX_REPLAY_COMMANDS = 64; const REPLAY_BUDGET_MS = 250; // req: performance/007 const SESSION = "hemx-kanban-session-v1"; 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 migrateCommandLog(request, oldVersion) { const database = request.result; const commands = database.objectStoreNames.contains(COMMANDS) ? request.transaction.objectStore(COMMANDS) : database.createObjectStore(COMMANDS, { keyPath: "id" }); if (!commands.indexNames.contains(ACCOUNT_INDEX)) commands.createIndex(ACCOUNT_INDEX, "accountPartition"); if (!database.objectStoreNames.contains(META)) database.createObjectStore(META); if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return; const transaction = request.transaction; const meta = transaction.objectStore(META); const all = commands.getAll(); all.addEventListener("success", () => { const legacy = all.result; if (legacy.some((command) => command.schemaVersion !== LEGACY_COMMAND_SCHEMA && command.schemaVersion !== COMMAND_SCHEMA)) { transaction.abort(); return; } for (const command of legacy) { commands.put({ ...command, schemaVersion: COMMAND_SCHEMA, targetColumn: command.targetColumn || "done", accountPartition: command.accountPartition || "demo:demo", queuedAt: Number.isSafeInteger(command.queuedAt) ? command.queuedAt : Date.now(), }); } meta.put({ from: oldVersion, to: DATABASE_VERSION, migrated: legacy.length }, MIGRATION_KEY); }, { once: true }); } function openCommandLog() { const request = indexedDB.open(DATABASE, DATABASE_VERSION); request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion)); return result(request); } async function currentAccountPartition() { let response; try { response = await fetch("/sync/context", { credentials: "same-origin", cache: "no-store" }); } catch (error) { const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION); if (cached) return cached; throw error; } if (!response.ok) { const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION); if (response.status === 404 && cached) return cached; throw new Error(`account context failed with ${response.status}`); } const context = await response.json(); if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) { throw new Error("account context omitted accountPartition"); } sessionStorage.setItem(ACCOUNT_PARTITION_SESSION, context.accountPartition); return context.accountPartition; } 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"] }); }); } async function prepareOfflineShell(root) { if (!("serviceWorker" in navigator)) throw new Error("service workers are unavailable"); await navigator.serviceWorker.register("/offline.js", { scope: "/" }); await navigator.serviceWorker.ready; if (!navigator.serviceWorker.controller) { await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })); } root.setAttribute("data-kanban-offline-ready", ""); } function stableSession() { let session = sessionStorage.getItem(SESSION); if (!session) { session = crypto.randomUUID(); sessionStorage.setItem(SESSION, session); } return session; } async function appendReorder(database, accountPartition, wire) { const transaction = database.transaction([COMMANDS, META], "readwrite"); const done = completed(transaction); const completion = done.then( () => null, (error) => error, ); const meta = transaction.objectStore(META); const commands = transaction.objectStore(COMMANDS); const actorKey = `actor:${accountPartition}`; const causalKey = `causal:${accountPartition}`; const actorRequest = result(meta.get(actorKey)); const causalRequest = result(meta.get(causalKey)); 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, accountPartition, actor, session: stableSession(), causal, queuedAt: Date.now(), kind: "reorder_card", cardId: String(wire[2] || "1"), targetColumn: "done", eventKind: String(wire[1] || "click"), key: wire[4] ? String(wire[4]) : null, }; let append; let counted; try { meta.put(actor, actorKey); meta.put(causal, causalKey); append = result(commands.add(command)); counted = result(commands.index(ACCOUNT_INDEX).count(accountPartition)); } catch (error) { transaction.abort(); await completion; throw 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, accountPartition) { const transaction = database.transaction(COMMANDS, "readonly"); const done = completed(transaction); const commands = await result(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition)); await done; return commands.sort((left, right) => left.causal - right.causal); } class ReplayLimitError extends Error { constructor(actual) { super(`durable replay limit exceeded: ${actual} > ${MAX_REPLAY_COMMANDS}`); this.name = "ReplayLimitError"; } } function invalidCommand(command, field) { const id = command && typeof command.id === "string" && command.id ? command.id : "record"; throw new Error(`invalid durable command ${id}: ${field}`); } function validate(command) { if (!command || typeof command !== "object") invalidCommand(command, "record"); if (!Number.isSafeInteger(command.schemaVersion)) invalidCommand(command, "schemaVersion"); if (command.schemaVersion !== COMMAND_SCHEMA) { throw new Error(`unsupported durable command ${command.id || "record"}`); } if (typeof command.id !== "string" || !command.id) invalidCommand(command, "id"); if (typeof command.accountPartition !== "string" || !command.accountPartition) invalidCommand(command, "accountPartition"); if (typeof command.actor !== "string" || !command.actor) invalidCommand(command, "actor"); if (typeof command.session !== "string" || !command.session) invalidCommand(command, "session"); if (!Number.isSafeInteger(command.causal) || command.causal < 1) invalidCommand(command, "causal"); if (!Number.isSafeInteger(command.queuedAt) || command.queuedAt < 0) invalidCommand(command, "queuedAt"); if (command.id !== `${command.actor}:${command.causal}`) invalidCommand(command, "id"); if (command.kind !== "reorder_card") invalidCommand(command, "kind"); if (typeof command.cardId !== "string" || !command.cardId) invalidCommand(command, "cardId"); if (command.targetColumn !== "done") invalidCommand(command, "targetColumn"); if (typeof command.eventKind !== "string" || !command.eventKind) invalidCommand(command, "eventKind"); if (command.key !== null && typeof command.key !== "string") invalidCommand(command, "key"); 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 code = error && typeof error.name === "string" ? error.name : "Error"; const message = error instanceof Error ? error.message : String(error); root.setAttribute("data-kanban-command-phase", "failed"); root.removeAttribute("aria-busy"); root.setAttribute("data-kanban-command-error", `${stage}: ${message}`); root.setAttribute("data-kanban-command-error-stage", stage); root.setAttribute("data-kanban-command-error-code", code); announce(root, `Local command ${stage} failed (${code}). Recovery controls remain available.`); root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, code, 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, accountPartition) { const transaction = database.transaction(COMMANDS, "readwrite"); const done = completed(transaction); const commands = transaction.objectStore(COMMANDS); const cursor = commands.index(ACCOUNT_INDEX).openKeyCursor(IDBKeyRange.only(accountPartition)); cursor.addEventListener("success", () => { if (!cursor.result) return; commands.delete(cursor.result.primaryKey); cursor.result.continue(); }); 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, accountPartition) { 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, accountPartition)); controls.forEach((item) => { item.disabled = false; }); return; } if (action === "delete") { await clearCommands(database, accountPartition); 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; root.setAttribute("data-kanban-load-id", crypto.randomUUID()); const accountPartition = await currentAccountPartition(); root.setAttribute("data-kanban-account-partition", accountPartition); const databasePromise = openCommandLog(); const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error)); await clientReady(root); let wasmHandler; const durableHandler = async (...wire) => { const queuedCard = String(wire[2] || "1"); root.setAttribute("data-kanban-command-phase", "queued"); root.setAttribute("aria-busy", "true"); announce(root, `Queued card ${queuedCard}; saving for offline use.`); root.dispatchEvent(new CustomEvent("kanban:command-queued", { detail: { cardId: queuedCard } })); let command; let count; try { ({ command, count } = await appendReorder(await databasePromise, accountPartition, wire)); } catch (error) { report(root, "persist", error); throw error; } root.setAttribute("data-kanban-command-phase", "durable"); root.removeAttribute("aria-busy"); 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, targetColumn: command.targetColumn, }, })); 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; installRecoveryControls(root, database, accountPartition); root.setAttribute("data-kanban-replay-limit", String(MAX_REPLAY_COMMANDS)); try { const commands = await storedCommands(database, accountPartition); if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length); commands.forEach(validate); const replayStarted = performance.now(); const batches = await Promise.all(commands.map((command) => project(root, wasmHandler, command))); for (const batch of batches) window.hemx.applyBatch(batch, root); const replayMs = performance.now() - replayStarted; root.setAttribute("data-kanban-replay-ms", replayMs.toFixed(3)); root.setAttribute("data-kanban-replay-budget-ms", String(REPLAY_BUDGET_MS)); root.toggleAttribute("data-kanban-replay-over-budget", replayMs > REPLAY_BUDGET_MS); root.setAttribute("data-kanban-command-count", String(commands.length)); root.setAttribute("data-kanban-command-ready", ""); await offlineReady; } 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); });