feat(kanban): partition local queues by account
req: sync/020 req: auth/005 req: security/004 req: operations/002
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const DATABASE = "hemx-kanban-v1";
|
||||
const DATABASE_VERSION = 2;
|
||||
const DATABASE_VERSION = 3;
|
||||
const COMMANDS = "commands";
|
||||
const ACCOUNT_INDEX = "byAccountPartition";
|
||||
const COMMAND_SCHEMA = 2;
|
||||
const LEGACY_COMMAND_SCHEMA = 1;
|
||||
const MIGRATION_KEY = "commandSchemaMigration";
|
||||
@@ -10,8 +11,8 @@ 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 accountPartition;
|
||||
let uploadLimit;
|
||||
let retryTimer;
|
||||
let leaseTimer;
|
||||
@@ -50,23 +51,30 @@ function transactionDone(transaction) {
|
||||
|
||||
function migrateCommandLog(request, oldVersion) {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(COMMANDS)) database.createObjectStore(COMMANDS, { keyPath: "id" });
|
||||
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 commands = transaction.objectStore(COMMANDS);
|
||||
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)) {
|
||||
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: "done" });
|
||||
commands.put({
|
||||
...command,
|
||||
schemaVersion: COMMAND_SCHEMA,
|
||||
targetColumn: command.targetColumn || "done",
|
||||
accountPartition: command.accountPartition || "demo:demo",
|
||||
});
|
||||
}
|
||||
meta.put({ from: LEGACY_COMMAND_SCHEMA, to: COMMAND_SCHEMA, migrated: legacy.length }, MIGRATION_KEY);
|
||||
meta.put({ from: oldVersion, to: DATABASE_VERSION, migrated: legacy.length }, MIGRATION_KEY);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
@@ -79,7 +87,7 @@ async function openLog() {
|
||||
async function pendingCommands(database) {
|
||||
const transaction = database.transaction(COMMANDS, "readonly");
|
||||
const done = transactionDone(transaction);
|
||||
const commands = await requestResult(transaction.objectStore(COMMANDS).getAll());
|
||||
const commands = await requestResult(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition));
|
||||
await done;
|
||||
return commands.sort((left, right) => left.causal - right.causal);
|
||||
}
|
||||
@@ -105,13 +113,14 @@ async function claimUploaderLease(database) {
|
||||
const done = transactionDone(transaction);
|
||||
const meta = transaction.objectStore("meta");
|
||||
const now = Date.now();
|
||||
const current = await requestResult(meta.get(LEASE_KEY));
|
||||
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, LEASE_KEY);
|
||||
meta.put(lease, leaseKey);
|
||||
await done;
|
||||
return { leader: true, ...lease };
|
||||
}
|
||||
@@ -120,8 +129,9 @@ 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);
|
||||
const leaseKey = `uploaderLease:${accountPartition}`;
|
||||
const current = await requestResult(meta.get(leaseKey));
|
||||
if (current?.owner === TAB_ID) meta.delete(leaseKey);
|
||||
await done;
|
||||
}
|
||||
|
||||
@@ -147,7 +157,7 @@ function setPhase(phase, message) {
|
||||
}
|
||||
|
||||
function validatePending(command) {
|
||||
if (!command || command.schemaVersion !== COMMAND_SCHEMA || command.kind !== "reorder_card" || typeof command.id !== "string" || !command.id || typeof command.cardId !== "string" || !command.cardId || command.targetColumn !== "done") {
|
||||
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;
|
||||
@@ -164,6 +174,23 @@ function setManualRetryAvailable(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));
|
||||
@@ -225,6 +252,7 @@ function finishUpload() {
|
||||
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);
|
||||
@@ -282,7 +310,9 @@ async function synchronize(command) {
|
||||
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));
|
||||
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);
|
||||
@@ -373,6 +403,14 @@ async function runLeaseLoop(command) {
|
||||
|
||||
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();
|
||||
@@ -390,6 +428,7 @@ async function start() {
|
||||
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.");
|
||||
@@ -397,6 +436,10 @@ async function start() {
|
||||
}
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user