const DATABASE = "hemx-kanban-v1"; const COMMANDS = "commands"; const MAX_ATTEMPTS = 3; const BACKOFF_MS = [25, 50]; const root = document.querySelector("[data-kanban-sync]"); let database; let retryTimer; 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; } function setOnline(online) { root.setAttribute("data-sync-connection", online ? "online" : "offline"); } function scheduleManualRetry(command, error) { clearTimeout(retryTimer); root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error)); root.setAttribute("data-sync-manual-retry", "available"); setPhase("offline", "Sync is offline after bounded retries; the durable command remains queued. Retry now when ready."); root.dispatchEvent(new CustomEvent("kanban:sync-exhausted", { detail: { commandId: command.id, attempts: MAX_ATTEMPTS } })); } 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 synchronize(command) { root.removeAttribute("data-sync-error"); root.removeAttribute("data-sync-manual-retry"); setOnline(navigator.onLine); try { const acknowledgement = await upload(command); setOnline(true); 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(); }); } catch (error) { setOnline(false); if (error instanceof UploadError && !error.retryable) throw error; scheduleManualRetry(command, error); } } async function start() { if (!root) return; 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]); root.addEventListener("click", (event) => { if (!event.target.closest("[data-sync-retry]")) return; synchronize(command).catch(failPermanently); }); window.addEventListener("online", () => { if (root.getAttribute("data-sync-phase") === "offline") synchronize(command).catch(failPermanently); }); await synchronize(command); } function failPermanently(error) { root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error)); setPhase("failed", "Sync failed; the durable command remains queued."); } start().catch((error) => { if (!root) return; failPermanently(error); });