feat(kanban): migrate queued commands transactionally
req: sync/014
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
const DATABASE = "hemx-kanban-v1";
|
||||
const DATABASE_VERSION = 2;
|
||||
const COMMANDS = "commands";
|
||||
const META = "meta";
|
||||
const COMMAND_SCHEMA = 1;
|
||||
const COMMAND_SCHEMA = 2;
|
||||
const LEGACY_COMMAND_SCHEMA = 1;
|
||||
const MIGRATION_KEY = "commandSchemaMigration";
|
||||
const EXPORT_SCHEMA = 1;
|
||||
const MAX_REPLAY_COMMANDS = 64;
|
||||
const REPLAY_BUDGET_MS = 100;
|
||||
@@ -23,13 +26,31 @@ function completed(transaction) {
|
||||
});
|
||||
}
|
||||
|
||||
function migrateCommandLog(request, oldVersion) {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(COMMANDS)) database.createObjectStore(COMMANDS, { keyPath: "id" });
|
||||
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)) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
for (const command of legacy) {
|
||||
commands.put({ ...command, schemaVersion: COMMAND_SCHEMA, targetColumn: "done" });
|
||||
}
|
||||
meta.put({ from: LEGACY_COMMAND_SCHEMA, to: COMMAND_SCHEMA, migrated: legacy.length }, MIGRATION_KEY);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function openCommandLog() {
|
||||
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);
|
||||
});
|
||||
const request = indexedDB.open(DATABASE, DATABASE_VERSION);
|
||||
request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion));
|
||||
return result(request);
|
||||
}
|
||||
|
||||
@@ -86,6 +107,7 @@ async function appendReorder(database, wire) {
|
||||
causal,
|
||||
kind: "reorder_card",
|
||||
cardId: String(wire[2] || "1"),
|
||||
targetColumn: "done",
|
||||
eventKind: String(wire[1] || "click"),
|
||||
key: wire[4] ? String(wire[4]) : null,
|
||||
};
|
||||
@@ -145,6 +167,7 @@ function validate(command) {
|
||||
if (command.id !== `${command.actor}:${command.causal}`) invalidCommand(command, "id");
|
||||
if (command.kind !== "reorder_card") invalidCommand(command, "kind");
|
||||
if (typeof command.cardId !== "string" || !command.cardId) invalidCommand(command, "cardId");
|
||||
if (command.targetColumn !== "done") invalidCommand(command, "targetColumn");
|
||||
if (typeof command.eventKind !== "string" || !command.eventKind) invalidCommand(command, "eventKind");
|
||||
if (command.key !== null && typeof command.key !== "string") invalidCommand(command, "key");
|
||||
return command;
|
||||
@@ -291,6 +314,7 @@ async function start() {
|
||||
actor: command.actor,
|
||||
session: command.session,
|
||||
causal: command.causal,
|
||||
targetColumn: command.targetColumn,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const DATABASE = "hemx-kanban-v1";
|
||||
const DATABASE_VERSION = 2;
|
||||
const COMMANDS = "commands";
|
||||
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]");
|
||||
@@ -43,13 +47,31 @@ function transactionDone(transaction) {
|
||||
});
|
||||
}
|
||||
|
||||
function migrateCommandLog(request, oldVersion) {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(COMMANDS)) database.createObjectStore(COMMANDS, { keyPath: "id" });
|
||||
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)) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
for (const command of legacy) {
|
||||
commands.put({ ...command, schemaVersion: COMMAND_SCHEMA, targetColumn: "done" });
|
||||
}
|
||||
meta.put({ from: LEGACY_COMMAND_SCHEMA, to: COMMAND_SCHEMA, migrated: legacy.length }, MIGRATION_KEY);
|
||||
}, { 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");
|
||||
});
|
||||
const request = indexedDB.open(DATABASE, DATABASE_VERSION);
|
||||
request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion));
|
||||
return requestResult(request);
|
||||
}
|
||||
|
||||
@@ -124,7 +146,7 @@ function setPhase(phase, 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) {
|
||||
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") {
|
||||
throw new Error("invalid pending command");
|
||||
}
|
||||
return command;
|
||||
@@ -155,7 +177,7 @@ async function upload(command) {
|
||||
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 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];
|
||||
@@ -342,6 +364,14 @@ async function start() {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user