const DATABASE = "hemx-kanban-v1"; const DATABASE_VERSION = 3; const COMMANDS = "commands"; const ACCOUNT_INDEX = "byAccountPartition"; const COMMAND_SCHEMA = 2; const LEGACY_COMMAND_SCHEMA = 1; const MIGRATION_KEY = "commandSchemaMigration"; const MAX_ATTEMPTS = 3; const BACKOFF_MS = [25, 50]; const root = document.querySelector("[data-kanban-sync]"); const TAB_ID = sessionStorage.getItem("hemx-kanban-sync-tab-id") || crypto.randomUUID(); const LEASE_MS = 5000; const LEASE_POLL_MS = 100; let database; let accountPartition; let uploadLimit; let retryTimer; let leaseTimer; let synchronizing = false; let uploadsThisRun = 0; let uploadedTotal = 0; let inFlightUploads = 0; let maxObservedInFlight = 0; let stopped = false; class UploadError extends Error { constructor(status, retryable, kind, reason) { super(`sync upload failed with ${status}`); this.name = "UploadError"; this.status = status; this.retryable = retryable; this.kind = kind; this.reason = reason; } } 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 }); }); } 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", }); } meta.put({ from: oldVersion, to: DATABASE_VERSION, migrated: legacy.length }, MIGRATION_KEY); }, { once: true }); } async function openLog() { const request = indexedDB.open(DATABASE, DATABASE_VERSION); request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion)); return requestResult(request); } async function pendingCommands(database) { const transaction = database.transaction(COMMANDS, "readonly"); const done = transactionDone(transaction); const commands = await requestResult(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition)); 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 decideRebase(snapshot, command) { const canonical = snapshot.cards.find((card) => String(card.id) === command.cardId); if (!canonical) return { kind: "conflicted", reason: "card-missing", canonicalColumn: "missing" }; if (command.kind === "reorder_card" && canonical.column === "done") { return { kind: "converged", reason: "intent-already-canonical", canonicalColumn: canonical.column }; } return { kind: "conflicted", reason: "canonical-state-diverged", canonicalColumn: canonical.column }; } async function claimUploaderLease(database) { const transaction = database.transaction("meta", "readwrite"); const done = transactionDone(transaction); const meta = transaction.objectStore("meta"); const now = Date.now(); const leaseKey = `uploaderLease:${accountPartition}`; const current = await requestResult(meta.get(leaseKey)); if (current && current.owner !== TAB_ID && current.expiresAt > now) { await done; return { leader: false, owner: current.owner, expiresAt: current.expiresAt }; } const lease = { owner: TAB_ID, expiresAt: now + LEASE_MS }; meta.put(lease, leaseKey); await done; return { leader: true, ...lease }; } async function releaseUploaderLease(database) { const transaction = database.transaction("meta", "readwrite"); const done = transactionDone(transaction); const meta = transaction.objectStore("meta"); const leaseKey = `uploaderLease:${accountPartition}`; const current = await requestResult(meta.get(leaseKey)); if (current?.owner === TAB_ID) meta.delete(leaseKey); await done; } function publishLease(lease) { root.setAttribute("data-sync-tab-id", TAB_ID); root.setAttribute("data-sync-leader", String(lease.leader)); root.setAttribute("data-sync-lease-owner", lease.owner || TAB_ID); root.setAttribute("data-sync-lease-expires", String(lease.expiresAt)); } async function commitConvergedRebase(database, snapshot, command) { const transaction = database.transaction([COMMANDS, "meta"], "readwrite"); const done = transactionDone(transaction); transaction.objectStore("meta").put(snapshot, "canonicalSnapshot"); transaction.objectStore("meta").put(snapshot.serverSequence, "acknowledgementCursor"); transaction.objectStore(COMMANDS).delete(command.id); 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 !== COMMAND_SCHEMA || command.accountPartition !== accountPartition || command.kind !== "reorder_card" || typeof command.id !== "string" || !command.id || typeof command.cardId !== "string" || !command.cardId || command.targetColumn !== "done") { throw new Error("invalid pending command"); } return command; } function setOnline(online) { root.setAttribute("data-sync-connection", online ? "online" : "offline"); } function setManualRetryAvailable(available) { const retry = root.querySelector("[data-sync-retry]"); retry.disabled = !available; if (available) root.setAttribute("data-sync-manual-retry", "available"); else root.removeAttribute("data-sync-manual-retry"); } function setExportAvailable(available) { root.querySelector("[data-sync-export]").disabled = !available; } async function exportPendingWork() { const commands = await pendingCommands(database); if (commands.length === 0) return; const payload = JSON.stringify({ accountPartition, commands }, null, 2); const url = URL.createObjectURL(new Blob([payload], { type: "application/json" })); const link = document.createElement("a"); link.href = url; link.download = "hemx-kanban-queue.json"; link.click(); URL.revokeObjectURL(url); root.setAttribute("data-sync-exported-count", String(commands.length)); } function scheduleManualRetry(command, error) { clearTimeout(retryTimer); root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error)); setManualRetryAvailable(true); 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, column: command.targetColumn }); 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) { const problem = await response.json().catch(() => ({})); const kind = typeof problem.kind === "string" ? problem.kind : "unclassified-rejection"; const reason = typeof problem.error === "string" ? problem.error : "unclassified rejection"; throw new UploadError(response.status, response.status >= 500, kind, reason); } 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"); } function beginUpload() { inFlightUploads += 1; maxObservedInFlight = Math.max(maxObservedInFlight, inFlightUploads); root.setAttribute("data-sync-in-flight", String(inFlightUploads)); root.setAttribute("data-sync-max-observed-in-flight", String(maxObservedInFlight)); } function finishUpload() { inFlightUploads -= 1; root.setAttribute("data-sync-in-flight", String(inFlightUploads)); } async function continuePendingWork() { const commands = await pendingCommands(database); root.setAttribute("data-sync-pending-count", String(commands.length)); setExportAvailable(commands.length > 0); if (commands.length === 0) return; if (uploadsThisRun >= uploadLimit) { setManualRetryAvailable(true); setPhase("backpressured", `Upload limit ${uploadLimit} reached; ${commands.length} durable command${commands.length === 1 ? " remains" : "s remain"} queued. Retry now to continue.`); return; } setTimeout(() => synchronize(validatePending(commands[0])).catch(failPermanently), 0); } async function synchronize(command) { if (synchronizing) return; synchronizing = true; const lease = await claimUploaderLease(database); publishLease(lease); if (!lease.leader) { synchronizing = false; setPhase("standby", "Another tab owns sync; waiting for lease takeover."); return; } clearTimeout(leaseTimer); leaseTimer = setTimeout(() => { if (!stopped && root.getAttribute("data-sync-phase") !== "acknowledged") { synchronize(command).catch(failPermanently); } }, LEASE_MS / 2); root.removeAttribute("data-sync-error"); root.removeAttribute("data-sync-manual-retry"); setOnline(navigator.onLine); try { beginUpload(); let acknowledgement; try { acknowledgement = await upload(command); } finally { finishUpload(); } 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; source.close(); root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length)); await removeAcknowledged(database, command.id); uploadsThisRun += 1; uploadedTotal += 1; root.setAttribute("data-sync-uploaded-this-run", String(uploadsThisRun)); root.setAttribute("data-sync-uploaded-total", String(uploadedTotal)); const pendingAfterAck = (await pendingCommands(database)).length; root.setAttribute("data-sync-pending-count", String(pendingAfterAck)); setExportAvailable(pendingAfterAck > 0); root.setAttribute("data-sync-ack-sequence", String(canonical.serverSequence)); root.setAttribute("data-sync-ack-command-id", canonical.commandId); 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 })); synchronizing = false; clearTimeout(leaseTimer); await releaseUploaderLease(database); root.setAttribute("data-sync-leader", "false"); await continuePendingWork(); }); source.addEventListener("snapshot-required", async (event) => { const missing = JSON.parse(event.data); const response = await fetch(missing.snapshotUrl); if (!response.ok) throw new Error(`snapshot failed with ${response.status}`); const snapshot = await response.json(); const queued = await pendingCommands(database); const decision = decideRebase(snapshot, command); root.setAttribute("data-sync-snapshot-sequence", String(snapshot.serverSequence)); root.setAttribute("data-sync-snapshot-schema", String(snapshot.schemaVersion)); root.setAttribute("data-sync-snapshot-card-count", String(snapshot.cards.length)); root.setAttribute("data-sync-rebase-pending-count", String(queued.length)); root.setAttribute("data-sync-rebase-decision", decision.kind); root.setAttribute("data-sync-rebase-reason", decision.reason); root.setAttribute("data-sync-canonical-column", decision.canonicalColumn); if (decision.kind === "converged") { await commitConvergedRebase(database, snapshot, command); root.setAttribute("data-sync-pending-count", String((await pendingCommands(database)).length)); root.setAttribute("data-sync-ack-sequence", String(snapshot.serverSequence)); setPhase("rebased", `Canonical snapshot ${snapshot.serverSequence} already satisfies ${command.id}; committed and removed the pending command.`); root.dispatchEvent(new CustomEvent("kanban:sync-rebased", { detail: { snapshot, command, decision } })); } else { setPhase("conflicted", `Canonical snapshot ${snapshot.serverSequence} conflicts with ${command.id} (${decision.reason}); the pending command remains queued.`); root.dispatchEvent(new CustomEvent("kanban:sync-conflicted", { detail: { snapshot, command, decision } })); } synchronizing = false; source.close(); }); } catch (error) { synchronizing = false; if (error instanceof UploadError && !error.retryable) { setOnline(true); clearTimeout(leaseTimer); root.setAttribute("data-sync-error", error.message); root.setAttribute("data-sync-error-status", String(error.status)); const remaining = await pendingCommands(database); setManualRetryAvailable(false); if (error.kind === "authorization-denial") { root.setAttribute("data-sync-error-kind", "authorization-denial"); root.setAttribute("data-sync-pending-count", "redacted"); root.setAttribute("data-sync-redacted-pending", "true"); root.removeAttribute("data-sync-error-reason"); root.removeAttribute("data-sync-rejected-command-id"); setPhase("authorization-denied", "Current session cannot access local queued work. Sign back into the owning account to continue."); } else { root.setAttribute("data-sync-error-kind", "permanent-rejection"); root.setAttribute("data-sync-error-reason", error.reason); root.setAttribute("data-sync-rejected-command-id", command.id); root.setAttribute("data-sync-pending-count", String(remaining.length)); setPhase("rejected", `Command ${command.id} was permanently rejected (${error.status}: ${error.reason}); ${remaining.length} durable command${remaining.length === 1 ? " remains" : "s remain"} queued for review.`); } await releaseUploaderLease(database); root.setAttribute("data-sync-leader", "false"); return; } setOnline(false); scheduleManualRetry(command, error); } } async function runLeaseLoop(command) { if (stopped) return; const phase = root.getAttribute("data-sync-phase"); if (phase === "acknowledged" || phase === "rebased" || phase === "conflicted" || phase === "failed") return; if (root.getAttribute("data-sync-leader") === "true") { await synchronize(command); return; } const lease = await claimUploaderLease(database); publishLease(lease); if (lease.leader) { await synchronize(command); return; } setPhase("standby", "Another tab owns sync; waiting for lease takeover."); leaseTimer = setTimeout(() => runLeaseLoop(command).catch(failPermanently), LEASE_POLL_MS); } async function start() { if (!root) return; const contextResponse = await fetch("/sync/context", { credentials: "same-origin", cache: "no-store" }); if (!contextResponse.ok) throw new Error(`account context failed with ${contextResponse.status}`); const context = await contextResponse.json(); if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) { throw new Error("account context omitted accountPartition"); } accountPartition = context.accountPartition; root.setAttribute("data-sync-account-partition", accountPartition); uploadLimit = Number.parseInt(root.getAttribute("data-sync-upload-limit"), 10); if (!Number.isSafeInteger(uploadLimit) || uploadLimit < 1) throw new Error("data-sync-upload-limit must be a positive integer"); database = await openLog(); const migration = await requestResult(database.transaction("meta", "readonly").objectStore("meta").get(MIGRATION_KEY)); root.setAttribute("data-sync-database-version", String(database.version)); root.setAttribute("data-sync-command-schema", String(COMMAND_SCHEMA)); if (migration) { root.setAttribute("data-sync-migration-from", String(migration.from)); root.setAttribute("data-sync-migration-to", String(migration.to)); root.setAttribute("data-sync-migrated-count", String(migration.migrated)); } const commands = await pendingCommands(database); root.setAttribute("data-sync-uploaded-this-run", "0"); root.setAttribute("data-sync-uploaded-total", "0"); root.setAttribute("data-sync-in-flight", "0"); root.setAttribute("data-sync-max-observed-in-flight", "0"); root.setAttribute("data-sync-pending-count", String(commands.length)); setExportAvailable(commands.length > 0); setManualRetryAvailable(false); if (commands.length === 0) { setPhase("idle", "No pending commands."); return; } const command = validatePending(commands[0]); root.addEventListener("click", async (event) => { if (event.target.closest("[data-sync-export]")) { await exportPendingWork(); return; } if (!event.target.closest("[data-sync-retry]")) return; uploadsThisRun = 0; root.setAttribute("data-sync-uploaded-this-run", "0"); setManualRetryAvailable(false); const [next] = await pendingCommands(database); if (next) synchronize(validatePending(next)).catch(failPermanently); }); window.addEventListener("online", async () => { if (root.getAttribute("data-sync-phase") !== "offline") return; setManualRetryAvailable(false); const [next] = await pendingCommands(database); if (next) synchronize(validatePending(next)).catch(failPermanently); }); await runLeaseLoop(command); } window.addEventListener("pagehide", () => { stopped = true; clearTimeout(leaseTimer); if (database) releaseUploaderLease(database).catch(() => {}); }); function failPermanently(error) { synchronizing = false; 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); });