3ace17f9f1
req: sync/009 req: sync/010
390 lines
17 KiB
JavaScript
390 lines
17 KiB
JavaScript
const DATABASE = "hemx-kanban-v1";
|
|
const COMMANDS = "commands";
|
|
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;
|
|
const LEASE_KEY = "uploaderLease";
|
|
let database;
|
|
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, reason) {
|
|
super(`sync upload failed with ${status}`);
|
|
this.name = "UploadError";
|
|
this.status = status;
|
|
this.retryable = retryable;
|
|
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 });
|
|
});
|
|
}
|
|
|
|
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 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 current = await requestResult(meta.get(LEASE_KEY));
|
|
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, LEASE_KEY);
|
|
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 current = await requestResult(meta.get(LEASE_KEY));
|
|
if (current?.owner === TAB_ID) meta.delete(LEASE_KEY);
|
|
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 !== 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 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 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 });
|
|
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 reason = typeof problem.error === "string" ? problem.error : "unclassified rejection";
|
|
throw new UploadError(response.status, response.status >= 500, 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));
|
|
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));
|
|
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 }));
|
|
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-kind", "permanent-rejection");
|
|
root.setAttribute("data-sync-error-status", String(error.status));
|
|
root.setAttribute("data-sync-error-reason", error.reason);
|
|
root.setAttribute("data-sync-rejected-command-id", command.id);
|
|
const remaining = await pendingCommands(database);
|
|
root.setAttribute("data-sync-pending-count", String(remaining.length));
|
|
setManualRetryAvailable(false);
|
|
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;
|
|
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 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));
|
|
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-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);
|
|
});
|