f1dc6edb7a
req: operations/001 req: security/002 req: sync/016
404 lines
16 KiB
JavaScript
404 lines
16 KiB
JavaScript
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 = 100;
|
|
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();
|
|
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), 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);
|
|
});
|