feat(kanban): partition local queues by account
req: sync/020 req: auth/005 req: security/004 req: operations/002
This commit is contained in:
@@ -428,6 +428,7 @@ async fn main() {
|
||||
.route("/events", get(events))
|
||||
.route("/sync-demo", get(sync_demo))
|
||||
.route("/sync.js", get(sync_js))
|
||||
.route("/sync/context", get(sync_context))
|
||||
.route("/sync/commands", post(sync_command))
|
||||
.route("/sync/acknowledgements", get(sync_acknowledgements))
|
||||
.route("/sync/snapshot", get(sync_snapshot))
|
||||
@@ -585,6 +586,23 @@ fn current_sync_principal(
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncContext {
|
||||
account_partition: String,
|
||||
}
|
||||
|
||||
// req: sync/020 req: auth/005
|
||||
async fn sync_context(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<SyncContext>, SyncRejection> {
|
||||
let principal = current_sync_principal(&headers, &state.sync_sessions)?;
|
||||
Ok(Json(SyncContext {
|
||||
account_partition: format!("{}:{}", principal.tenant, principal.principal),
|
||||
}))
|
||||
}
|
||||
|
||||
fn authorize_sync_replay(
|
||||
principal: CurrentSyncPrincipal,
|
||||
card_id: u64,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<h2 id="sync-title">Sync status</h2>
|
||||
<p role="status" aria-live="polite">Waiting for pending commands.</p>
|
||||
<button type="button" data-sync-retry>Retry sync now</button>
|
||||
<button type="button" data-sync-export disabled>Export this account's queue</button>
|
||||
</section>
|
||||
<script +src="self.runtime_src" defer></script>
|
||||
<script src="/sync.js" defer></script>
|
||||
|
||||
@@ -242,8 +242,8 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'sync-actor:1', schemaVersion: 1, actor: 'sync-actor', session: 'sync-session',
|
||||
causal: 1, kind: 'reorder_card', cardId: '1', eventKind: 'click', key: null,
|
||||
id: 'sync-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'sync-actor', session: 'sync-session',
|
||||
causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
@@ -319,7 +319,7 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'sync-actor:1', schemaVersion: 2, actor: 'sync-actor', session: 'sync-session',
|
||||
id: 'sync-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'sync-actor', session: 'sync-session',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
@@ -373,9 +373,8 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_work(
|
||||
) -> WebDriverResult<()> {
|
||||
// test req: sync/019 req: security/004 req: auth/005 req: operations/002
|
||||
async fn account_partition_hides_replay_and_export_until_owner_returns() -> WebDriverResult<()> {
|
||||
// test req: sync/020 req: security/004 req: auth/005 req: operations/002
|
||||
let app_port = available_port();
|
||||
let app_addr = format!("127.0.0.1:{app_port}");
|
||||
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
|
||||
@@ -392,7 +391,8 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
|
||||
.env(
|
||||
"HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR",
|
||||
"test-token-carol-beta-editor",
|
||||
);
|
||||
)
|
||||
.env("HEMX_KANBAN_SYNC_FAILURES", "3");
|
||||
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
|
||||
.expect("start ready hemx-kanban");
|
||||
|
||||
@@ -422,7 +422,7 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'auth:1', schemaVersion: 2, actor: 'alice-device', session: 'enqueue-session',
|
||||
id: 'auth:1', schemaVersion: 2, accountPartition: 'alpha:alice', actor: 'alice-device', session: 'enqueue-session',
|
||||
causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done',
|
||||
eventKind: 'click', key: null, enqueuedPrincipal: 'alice', enqueuedTenant: 'alpha',
|
||||
});
|
||||
@@ -440,30 +440,23 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
|
||||
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'",
|
||||
)
|
||||
.await?;
|
||||
let cross_tenant = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-kanban-sync]'); const retry = root.querySelector('[data-sync-retry]'); return { phase: root.getAttribute('data-sync-phase'), kind: root.getAttribute('data-sync-error-kind'), errorStatus: root.getAttribute('data-sync-error-status'), pending: root.getAttribute('data-sync-pending-count'), redacted: root.getAttribute('data-sync-redacted-pending'), reason: root.getAttribute('data-sync-error-reason'), rejectedId: root.getAttribute('data-sync-rejected-command-id'), retryDisabled: retry.disabled, leakedId: document.body.textContent.includes('auth:1'), status: root.querySelector('[role=status]').textContent }",
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return { phase: root.getAttribute('data-sync-phase'), account: root.getAttribute('data-sync-account-partition'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), leakedId: document.body.textContent.includes('auth:1'), status: root.querySelector('[role=status]').textContent }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(cross_tenant["phase"], "authorization-denied");
|
||||
assert_eq!(cross_tenant["kind"], "authorization-denial");
|
||||
assert_eq!(cross_tenant["errorStatus"], "403");
|
||||
assert_eq!(cross_tenant["pending"], "redacted");
|
||||
assert_eq!(cross_tenant["redacted"], "true");
|
||||
assert!(cross_tenant["reason"].is_null());
|
||||
assert!(cross_tenant["rejectedId"].is_null());
|
||||
assert_eq!(cross_tenant["retryDisabled"], true);
|
||||
assert_eq!(cross_tenant["phase"], "idle");
|
||||
assert_eq!(cross_tenant["account"], "beta:carol");
|
||||
assert_eq!(cross_tenant["pending"], "0");
|
||||
assert!(cross_tenant["attempts"].is_null());
|
||||
assert_eq!(cross_tenant["leakedId"], false);
|
||||
assert_eq!(
|
||||
cross_tenant["status"],
|
||||
"Current session cannot access local queued work. Sign back into the owning account to continue."
|
||||
);
|
||||
assert_eq!(cross_tenant["status"], "No pending commands.");
|
||||
assert_eq!(command_count(&driver).await?, 1);
|
||||
|
||||
driver
|
||||
@@ -475,20 +468,20 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
|
||||
driver.refresh().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'failed'",
|
||||
)
|
||||
.await?;
|
||||
let signed_out = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return { kind: root.getAttribute('data-sync-error-kind'), status: root.getAttribute('data-sync-error-status'), pending: root.getAttribute('data-sync-pending-count') }",
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return { error: root.getAttribute('data-sync-error'), pending: root.getAttribute('data-sync-pending-count'), account: root.getAttribute('data-sync-account-partition') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(signed_out["kind"], "authorization-denial");
|
||||
assert_eq!(signed_out["status"], "401");
|
||||
assert_eq!(signed_out["pending"], "redacted");
|
||||
assert_eq!(signed_out["error"], "account context failed with 401");
|
||||
assert!(signed_out["pending"].is_null());
|
||||
assert!(signed_out["account"].is_null());
|
||||
assert_eq!(command_count(&driver).await?, 1);
|
||||
|
||||
let before_authorized = driver
|
||||
@@ -516,30 +509,70 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
|
||||
driver.refresh().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'authorization-denied'",
|
||||
"return document.querySelector('[data-kanban-sync]')?.getAttribute('data-sync-phase') === 'idle'",
|
||||
)
|
||||
.await?;
|
||||
let stale_permission = driver
|
||||
let switched_user = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return { kind: root.getAttribute('data-sync-error-kind'), status: root.getAttribute('data-sync-error-status'), pending: root.getAttribute('data-sync-pending-count'), rejectedId: root.getAttribute('data-sync-rejected-command-id') }",
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return { account: root.getAttribute('data-sync-account-partition'), pending: root.getAttribute('data-sync-pending-count'), attempts: root.getAttribute('data-sync-attempts'), leakedId: document.body.textContent.includes('auth:1') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(stale_permission["kind"], "authorization-denial");
|
||||
assert_eq!(stale_permission["status"], "403");
|
||||
assert_eq!(stale_permission["pending"], "redacted");
|
||||
assert!(stale_permission["rejectedId"].is_null());
|
||||
assert_eq!(switched_user["account"], "alpha:bob");
|
||||
assert_eq!(switched_user["pending"], "0");
|
||||
assert!(switched_user["attempts"].is_null());
|
||||
assert_eq!(switched_user["leakedId"], false);
|
||||
assert_eq!(command_count(&driver).await?, 1);
|
||||
|
||||
let export_boundary = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-kanban-sync]'); const button = root.querySelector('[data-sync-export]'); button.click(); return { exportDisabled: button.disabled, exported: root.getAttribute('data-sync-exported-count'), leakedId: document.body.textContent.includes('auth:1') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(export_boundary["exportDisabled"], true);
|
||||
assert!(export_boundary["exported"].is_null());
|
||||
assert_eq!(export_boundary["leakedId"], false);
|
||||
|
||||
driver
|
||||
.execute(
|
||||
"document.cookie = 'hemx_kanban_session=test-token-alice-alpha-editor; Path=/; SameSite=Strict'; return true;",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
driver.refresh().await?;
|
||||
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'offline' && root?.getAttribute('data-sync-pending-count') === '1'",
|
||||
)
|
||||
.await?;
|
||||
driver
|
||||
.execute(
|
||||
"window.__exportPayload = null; const create = URL.createObjectURL; URL.createObjectURL = (blob) => { blob.text().then((text) => { window.__exportPayload = JSON.parse(text); }); return create(blob); }; return true;",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
driver.find(By::Css("[data-sync-export]")).await?.click().await?;
|
||||
wait_until(&driver, "return window.__exportPayload !== null").await?;
|
||||
let owner_export = driver
|
||||
.execute(
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return { payload: window.__exportPayload, exported: root.getAttribute('data-sync-exported-count') }",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(owner_export["exported"], "1");
|
||||
assert_eq!(owner_export["payload"]["accountPartition"], "alpha:alice");
|
||||
assert_eq!(owner_export["payload"]["commands"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(owner_export["payload"]["commands"][0]["id"], "auth:1");
|
||||
assert_eq!(owner_export["payload"]["commands"][0]["accountPartition"], "alpha:alice");
|
||||
|
||||
driver.find(By::Css("[data-sync-retry]")).await?.click().await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'",
|
||||
@@ -655,15 +688,15 @@ async fn schema_upgrade_preserves_queued_order_and_local_intent() -> WebDriverRe
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert_eq!(migrated["databaseVersion"], 2);
|
||||
assert_eq!(migrated["databaseVersion"], 3);
|
||||
assert_eq!(migrated["commandSchema"], "2");
|
||||
assert_eq!(migrated["migrationFrom"], "1");
|
||||
assert_eq!(migrated["migrationTo"], "2");
|
||||
assert_eq!(migrated["migrationTo"], "3");
|
||||
assert_eq!(migrated["migratedCount"], "3");
|
||||
assert_eq!(migrated["pending"], "3");
|
||||
assert_eq!(
|
||||
migrated["receipt"],
|
||||
serde_json::json!({ "from": 1, "to": 2, "migrated": 3 })
|
||||
serde_json::json!({ "from": 1, "to": 3, "migrated": 3 })
|
||||
);
|
||||
assert_eq!(
|
||||
migrated["commands"],
|
||||
@@ -760,8 +793,8 @@ async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebD
|
||||
const commands = tx.objectStore('commands');
|
||||
for (const [causal, cardId] of [[1, '1'], [2, '999'], [3, '2']]) {
|
||||
commands.add({
|
||||
id: `mixed:${causal}`, schemaVersion: 1, actor: 'mixed', session: 'mixed-session',
|
||||
causal, kind: 'reorder_card', cardId, eventKind: 'click', key: null,
|
||||
id: `mixed:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'mixed', session: 'mixed-session',
|
||||
causal, kind: 'reorder_card', cardId, targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
}
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
@@ -888,8 +921,8 @@ async fn upload_backpressure_keeps_pending_work_visible_and_recoverable() -> Web
|
||||
const commands = tx.objectStore('commands');
|
||||
for (let causal = 1; causal <= 3; causal += 1) {
|
||||
commands.add({
|
||||
id: `pressure:${causal}`, schemaVersion: 1, actor: 'pressure', session: 'pressure-session',
|
||||
causal, kind: 'reorder_card', cardId: String(causal), eventKind: 'click', key: null,
|
||||
id: `pressure:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'pressure', session: 'pressure-session',
|
||||
causal, kind: 'reorder_card', cardId: String(causal), targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
}
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
@@ -997,8 +1030,8 @@ async fn two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_appl
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'tabs:1', schemaVersion: 1, actor: 'tabs', session: 'tabs-session',
|
||||
causal: 1, kind: 'reorder_card', cardId: '1', eventKind: 'click', key: null,
|
||||
id: 'tabs:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'tabs', session: 'tabs-session',
|
||||
causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
@@ -1168,8 +1201,8 @@ async fn exhausted_offline_retries_keep_command_until_later_reconnect() -> WebDr
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'offline-actor:1', schemaVersion: 1, actor: 'offline-actor', session: 'offline-session',
|
||||
causal: 1, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
|
||||
id: 'offline-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'offline-actor', session: 'offline-session',
|
||||
causal: 1, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
@@ -1309,8 +1342,8 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'history:2', schemaVersion: 1, actor: 'history', session: 'history-session',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null,
|
||||
id: 'history:2', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session',
|
||||
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
tx.onabort = () => done({ error: tx.error && tx.error.name });
|
||||
@@ -1421,7 +1454,7 @@ async fn missing_history_rebase_converges_without_losing_local_intent() -> WebDr
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction('commands', 'readwrite');
|
||||
tx.objectStore('commands').add({
|
||||
id: 'history:3', schemaVersion: 2, actor: 'history', session: 'history-session',
|
||||
id: 'history:3', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session',
|
||||
causal: 3, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
|
||||
});
|
||||
tx.oncomplete = () => done({ seeded: true });
|
||||
|
||||
Reference in New Issue
Block a user