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,10 +1,12 @@
|
||||
const DATABASE = "hemx-kanban-v1";
|
||||
const DATABASE_VERSION = 2;
|
||||
const DATABASE_VERSION = 3;
|
||||
const COMMANDS = "commands";
|
||||
const META = "meta";
|
||||
const ACCOUNT_INDEX = "byAccountPartition";
|
||||
const COMMAND_SCHEMA = 2;
|
||||
const LEGACY_COMMAND_SCHEMA = 1;
|
||||
const MIGRATION_KEY = "commandSchemaMigration";
|
||||
const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1";
|
||||
const EXPORT_SCHEMA = 1;
|
||||
const MAX_REPLAY_COMMANDS = 64;
|
||||
const REPLAY_BUDGET_MS = 100;
|
||||
@@ -28,23 +30,30 @@ function completed(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 });
|
||||
}
|
||||
|
||||
@@ -54,6 +63,28 @@ function openCommandLog() {
|
||||
return result(request);
|
||||
}
|
||||
|
||||
async function currentAccountPartition() {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch("/sync/context", { credentials: "same-origin", cache: "no-store" });
|
||||
} catch (error) {
|
||||
const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION);
|
||||
if (response.status === 404 && cached) return cached;
|
||||
throw new Error(`account context failed with ${response.status}`);
|
||||
}
|
||||
const context = await response.json();
|
||||
if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) {
|
||||
throw new Error("account context omitted accountPartition");
|
||||
}
|
||||
sessionStorage.setItem(ACCOUNT_PARTITION_SESSION, context.accountPartition);
|
||||
return context.accountPartition;
|
||||
}
|
||||
|
||||
function clientReady(root) {
|
||||
if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
@@ -85,7 +116,7 @@ function stableSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function appendReorder(database, wire) {
|
||||
async function appendReorder(database, accountPartition, wire) {
|
||||
const transaction = database.transaction([COMMANDS, META], "readwrite");
|
||||
const done = completed(transaction);
|
||||
const completion = done.then(
|
||||
@@ -94,14 +125,17 @@ async function appendReorder(database, wire) {
|
||||
);
|
||||
const meta = transaction.objectStore(META);
|
||||
const commands = transaction.objectStore(COMMANDS);
|
||||
const actorRequest = result(meta.get("actor"));
|
||||
const causalRequest = result(meta.get("causal"));
|
||||
const actorKey = `actor:${accountPartition}`;
|
||||
const causalKey = `causal:${accountPartition}`;
|
||||
const actorRequest = result(meta.get(actorKey));
|
||||
const causalRequest = result(meta.get(causalKey));
|
||||
const [storedActor, storedCausal] = await Promise.all([actorRequest, causalRequest]);
|
||||
const actor = storedActor || crypto.randomUUID();
|
||||
const causal = (storedCausal || 0) + 1;
|
||||
const command = {
|
||||
id: `${actor}:${causal}`,
|
||||
schemaVersion: COMMAND_SCHEMA,
|
||||
accountPartition,
|
||||
actor,
|
||||
session: stableSession(),
|
||||
causal,
|
||||
@@ -114,10 +148,10 @@ async function appendReorder(database, wire) {
|
||||
let append;
|
||||
let counted;
|
||||
try {
|
||||
meta.put(actor, "actor");
|
||||
meta.put(causal, "causal");
|
||||
meta.put(actor, actorKey);
|
||||
meta.put(causal, causalKey);
|
||||
append = result(commands.add(command));
|
||||
counted = result(commands.count());
|
||||
counted = result(commands.index(ACCOUNT_INDEX).count(accountPartition));
|
||||
} catch (error) {
|
||||
transaction.abort();
|
||||
await completion;
|
||||
@@ -134,10 +168,10 @@ async function appendReorder(database, wire) {
|
||||
}
|
||||
}
|
||||
|
||||
async function storedCommands(database) {
|
||||
async function storedCommands(database, accountPartition) {
|
||||
const transaction = database.transaction(COMMANDS, "readonly");
|
||||
const done = completed(transaction);
|
||||
const commands = await result(transaction.objectStore(COMMANDS).getAll());
|
||||
const commands = await result(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition));
|
||||
await done;
|
||||
return commands.sort((left, right) => left.causal - right.causal);
|
||||
}
|
||||
@@ -161,6 +195,7 @@ function validate(command) {
|
||||
throw new Error(`unsupported durable command ${command.id || "record"}`);
|
||||
}
|
||||
if (typeof command.id !== "string" || !command.id) invalidCommand(command, "id");
|
||||
if (typeof command.accountPartition !== "string" || !command.accountPartition) invalidCommand(command, "accountPartition");
|
||||
if (typeof command.actor !== "string" || !command.actor) invalidCommand(command, "actor");
|
||||
if (typeof command.session !== "string" || !command.session) invalidCommand(command, "session");
|
||||
if (!Number.isSafeInteger(command.causal) || command.causal < 1) invalidCommand(command, "causal");
|
||||
@@ -221,10 +256,16 @@ function exportCommands(root, commands) {
|
||||
root.dispatchEvent(new CustomEvent("kanban:commands-exported", { detail: payload }));
|
||||
}
|
||||
|
||||
async function clearCommands(database) {
|
||||
async function clearCommands(database, accountPartition) {
|
||||
const transaction = database.transaction(COMMANDS, "readwrite");
|
||||
const done = completed(transaction);
|
||||
transaction.objectStore(COMMANDS).clear();
|
||||
const commands = transaction.objectStore(COMMANDS);
|
||||
const cursor = commands.index(ACCOUNT_INDEX).openKeyCursor(IDBKeyRange.only(accountPartition));
|
||||
cursor.addEventListener("success", () => {
|
||||
if (!cursor.result) return;
|
||||
commands.delete(cursor.result.primaryKey);
|
||||
cursor.result.continue();
|
||||
});
|
||||
await done;
|
||||
}
|
||||
|
||||
@@ -244,7 +285,7 @@ function disarmRecoveryControls(controls) {
|
||||
}
|
||||
}
|
||||
|
||||
function installRecoveryControls(root, database) {
|
||||
function installRecoveryControls(root, database, accountPartition) {
|
||||
const controls = [...root.querySelectorAll("[data-kanban-command-action]")];
|
||||
for (const control of controls) {
|
||||
control.addEventListener("click", async () => {
|
||||
@@ -260,12 +301,12 @@ function installRecoveryControls(root, database) {
|
||||
controls.forEach((item) => { item.disabled = true; });
|
||||
try {
|
||||
if (action === "export") {
|
||||
exportCommands(root, await storedCommands(database));
|
||||
exportCommands(root, await storedCommands(database, accountPartition));
|
||||
controls.forEach((item) => { item.disabled = false; });
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
await clearCommands(database);
|
||||
await clearCommands(database, accountPartition);
|
||||
root.dispatchEvent(new CustomEvent("kanban:commands-deleted"));
|
||||
} else if (action === "reset") {
|
||||
await resetLocalData(database);
|
||||
@@ -286,6 +327,9 @@ function installRecoveryControls(root, database) {
|
||||
async function start() {
|
||||
const root = document.querySelector(ROOT);
|
||||
if (!root) return;
|
||||
root.setAttribute("data-kanban-load-id", crypto.randomUUID());
|
||||
const accountPartition = await currentAccountPartition();
|
||||
root.setAttribute("data-kanban-account-partition", accountPartition);
|
||||
const databasePromise = openCommandLog();
|
||||
const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error));
|
||||
await clientReady(root);
|
||||
@@ -299,7 +343,7 @@ async function start() {
|
||||
let command;
|
||||
let count;
|
||||
try {
|
||||
({ command, count } = await appendReorder(await databasePromise, wire));
|
||||
({ command, count } = await appendReorder(await databasePromise, accountPartition, wire));
|
||||
} catch (error) {
|
||||
report(root, "persist", error);
|
||||
throw error;
|
||||
@@ -328,10 +372,10 @@ async function start() {
|
||||
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
|
||||
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
|
||||
const database = await databasePromise;
|
||||
installRecoveryControls(root, database);
|
||||
installRecoveryControls(root, database, accountPartition);
|
||||
root.setAttribute("data-kanban-replay-limit", String(MAX_REPLAY_COMMANDS));
|
||||
try {
|
||||
const commands = await storedCommands(database);
|
||||
const commands = await storedCommands(database, accountPartition);
|
||||
if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length);
|
||||
commands.forEach(validate);
|
||||
const replayStarted = performance.now();
|
||||
|
||||
Reference in New Issue
Block a user