const DATABASE = "hemx-kanban-v1"; const COMMANDS = "commands"; const MAX_ATTEMPTS = 3; const BACKOFF_MS = [25, 50]; const root = document.querySelector("[data-kanban-sync]"); class UploadError extends Error { constructor(status, retryable) { super(`sync upload failed with ${status}`); this.name = "UploadError"; this.retryable = retryable; } } function requestResult(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 transactionDone(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 }); }); } async function openLog() { 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 requestResult(request); } async function pendingCommands(database) { const transaction = database.transaction(COMMANDS, "readonly"); const done = transactionDone(transaction); const commands = await requestResult(transaction.objectStore(COMMANDS).getAll()); await done; return commands.sort((left, right) => left.causal - right.causal); } async function removeAcknowledged(database, commandId) { const transaction = database.transaction(COMMANDS, "readwrite"); const done = transactionDone(transaction); transaction.objectStore(COMMANDS).delete(commandId); await done; } function setPhase(phase, message) { root.setAttribute("data-sync-phase", phase); root.querySelector('[role="status"]').textContent = message; } function validatePending(command) { if (!command || command.schemaVersion !== 1 || command.kind !== "reorder_card" || typeof command.id !== "string" || !command.id || typeof command.cardId !== "string" || !command.cardId) { throw new Error("invalid pending command"); } return command; } async function upload(command) { root.setAttribute("data-sync-max-attempts", String(MAX_ATTEMPTS)); for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { root.setAttribute("data-sync-attempts", String(attempt)); setPhase(attempt === 1 ? "uploading" : "retrying", `Uploading ${command.id} (attempt ${attempt} of ${MAX_ATTEMPTS}).`); try { const query = new URLSearchParams({ command_id: command.id, card_id: command.cardId }); const response = await fetch(`/sync/commands?${query}`, { method: "POST" }); if (response.status === 503 && attempt < MAX_ATTEMPTS) { const base = BACKOFF_MS[attempt - 1]; const delay = base + Math.floor(Math.random() * base); root.setAttribute("data-sync-last-backoff-base-ms", String(base)); root.setAttribute("data-sync-last-backoff-ms", String(delay)); root.dispatchEvent(new CustomEvent("kanban:sync-retry", { detail: { attempt, base, delay } })); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } if (!response.ok) throw new UploadError(response.status, response.status >= 500); return response.json(); } catch (error) { if (error instanceof UploadError && !error.retryable) throw error; if (attempt === MAX_ATTEMPTS) throw error; const base = BACKOFF_MS[attempt - 1]; const delay = base + Math.floor(Math.random() * base); root.setAttribute("data-sync-last-backoff-base-ms", String(base)); root.setAttribute("data-sync-last-backoff-ms", String(delay)); root.dispatchEvent(new CustomEvent("kanban:sync-retry", { detail: { attempt, base, delay } })); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw new Error("sync retry limit exhausted"); } async function start() { if (!root) return; const database = await openLog(); const commands = await pendingCommands(database); root.setAttribute("data-sync-pending-count", String(commands.length)); if (commands.length === 0) { setPhase("idle", "No pending commands."); return; } const command = validatePending(commands[0]); const acknowledgement = await upload(command); root.setAttribute("data-sync-upload-sequence", String(acknowledgement.serverSequence)); setPhase("awaiting-ack", `Command ${command.id} uploaded; awaiting canonical acknowledgement.`); const reconnect = command.session || command.actor || "kanban"; const source = new EventSource(`/sync/acknowledgements?after=0&reconnect=${encodeURIComponent(reconnect)}`); let opens = 0; source.addEventListener("open", () => { opens += 1; root.setAttribute("data-sync-transport-opens", String(opens)); }); source.addEventListener("acknowledgement", async (event) => { const canonical = JSON.parse(event.data); if (canonical.commandId !== command.id) return; root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length)); await removeAcknowledged(database, command.id); root.setAttribute("data-sync-pending-count", String((await pendingCommands(database)).length)); root.setAttribute("data-sync-ack-sequence", String(canonical.serverSequence)); root.setAttribute("data-sync-canonical-column", canonical.canonicalColumn); setPhase("acknowledged", `Command ${command.id} acknowledged in ${canonical.canonicalColumn}.`); root.dispatchEvent(new CustomEvent("kanban:sync-acknowledged", { detail: canonical })); source.close(); }); } start().catch((error) => { if (!root) return; root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error)); setPhase("failed", "Sync failed; the durable command remains queued."); });