feat(kanban): upload pending command with retry
req: sync/004\nreq: sync/009\nreq: sync/010\nreq: sync/016\nreq: sync/017
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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.");
|
||||
});
|
||||
Reference in New Issue
Block a user