Files
hemx/examples/kanban/static/command-log.js
T
slhx agent d946975f2d feat(kanban): add local recovery controls
req: local/003\nreq: security/007
2026-07-13 14:59:05 +02:00

268 lines
9.8 KiB
JavaScript

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) {
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() {
let session = sessionStorage.getItem(SESSION);
if (!session) {
session = crypto.randomUUID();
sessionStorage.setItem(SESSION, 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 } }));
}
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;
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;
installRecoveryControls(root, database);
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);
});