const DATABASE = "hemx-kanban-v1"; const COMMANDS = "commands"; const META = "meta"; const COMMAND_SCHEMA = 1; 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 openCommandLog() { const request = indexedDB.open(DATABASE, 1); request.addEventListener("upgradeneeded", () => { const database = request.result; if (!database.objectStoreNames.contains(COMMANDS)) database.createObjectStore(COMMANDS, { keyPath: "id" }); if (!database.objectStoreNames.contains(META)) database.createObjectStore(META); }); return result(request); } 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() { const key = "hemx-kanban-session-v1"; let session = sessionStorage.getItem(key); if (!session) { session = crypto.randomUUID(); sessionStorage.setItem(key, session); } return session; } async function appendReorder(database, wire) { const transaction = database.transaction([COMMANDS, META], "readwrite"); const done = completed(transaction); const meta = transaction.objectStore(META); const commands = transaction.objectStore(COMMANDS); const actorRequest = result(meta.get("actor")); const causalRequest = result(meta.get("causal")); 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, actor, session: stableSession(), causal, kind: "reorder_card", cardId: String(wire[2] || "1"), eventKind: String(wire[1] || "click"), key: wire[4] ? String(wire[4]) : null, }; meta.put(actor, "actor"); meta.put(causal, "causal"); commands.add(command); const count = await result(commands.count()); await done; return { command, count }; } async function storedCommands(database) { const transaction = database.transaction(COMMANDS, "readonly"); const done = completed(transaction); const commands = await result(transaction.objectStore(COMMANDS).getAll()); await done; return commands.sort((left, right) => left.causal - right.causal); } function validate(command) { if (command.schemaVersion !== COMMAND_SCHEMA || command.kind !== "reorder_card" || !command.id || !command.actor || !command.session || !command.cardId || !Number.isSafeInteger(command.causal)) { throw new Error(`unsupported durable command ${command && command.id ? command.id : "record"}`); } 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 message = error instanceof Error ? error.message : String(error); root.setAttribute("data-kanban-command-error", `${stage}: ${message}`); root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } })); } async function start() { const root = document.querySelector(ROOT); if (!root) return; const databasePromise = openCommandLog(); const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error)); await clientReady(root); let wasmHandler; const durableHandler = async (...wire) => { let command; let count; try { ({ command, count } = await appendReorder(await databasePromise, wire)); } catch (error) { report(root, "persist", error); throw error; } 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, }, })); 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; try { const commands = await storedCommands(database); for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root); 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); });