feat(kanban): partition local queues by account

req: sync/020

req: auth/005

req: security/004

req: operations/002
This commit is contained in:
slhx agent
2026-07-13 19:00:04 +02:00
parent 6de4dcb73b
commit 3ef84e7331
7 changed files with 239 additions and 95 deletions
+2 -2
View File
@@ -42,11 +42,11 @@ encryption, retention, backup, and deployment policy remain host concerns.
## Slice 4 — authoritative reconnect and convergence ## Slice 4 — authoritative reconnect and convergence
- [ ] **User value:** offline and concurrent work reconnects without duplicate mutation, silent loss, stale authorization, or ambiguous conflict. - [ ] **User value:** offline and concurrent work reconnects without duplicate mutation, silent loss, stale authorization, or ambiguous conflict.
- **State:** In progress — one app-owned `move_card` server command validates a durable client command id, applies the authoritative canonical column once, returns the same acknowledgement for an identical retry, rejects id reuse with a different payload, assigns one server sequence, and redelivers that canonical acknowledgement after a real EventSource disconnect/reconnect. Canonical acknowledgements and the next sequence are durably stored in a strict versioned JSON envelope using fsync plus atomic replacement; startup refuses malformed/unknown state, rebuilds the canonical board, and preserves idempotency and event replay across a real process restart. A dedicated opt-in sync route reads one pending IndexedDB command, retries transient failures with capped exponential backoff and randomized jitter, exposes online/offline state plus an accessible manual retry after exhaustion, waits for canonical acknowledgement on the reconnecting transport, and only then removes the durable command; later retry converges without a new local mutation. Canonical payload conflicts are not retried and remain durable with a visible reason. If requested history predates retained events, the stream emits a typed snapshot-required event and the client loads a versioned canonical snapshot. One deterministic rebase rule treats `reorder_card` as converged only when the canonical snapshot already places that card in `done`; it then atomically stores the snapshot/cursor and removes the satisfied command. If a later canonical command instead places the same card in `doing`, the rebase is explicitly `conflicted`, retains the local command and last committed snapshot/cursor unchanged, and exposes the divergent canonical column/reason. Two same-origin tabs coordinate an app-owned expiring IndexedDB lease so only one uploads; the standby exposes its role without issuing a request, and after the leader closes it takes over, receives one canonical acknowledgement/sequence, and removes the queue once. Each activation serializes uploads with one in flight, processes at most two acknowledged commands, exposes the retained durable count when backpressured, and resumes the next bounded run only through the visible retry action. A mixed queue commits and removes its accepted prefix exactly once, then stops on the first permanent rejection with the typed server cause visible, the rejected command plus untouched suffix durable, and blind retry disabled. The IndexedDB v1-to-v2 command migration transactionally adds the explicit target column, records a typed migration receipt, preserves causal order and interaction intent through an interrupted upload, and later drains in the original order. Every command POST now derives its principal, permission, and tenant from the current same-origin session before idempotency lookup or mutation; authenticated mode accepts only app-configured opaque session tokens and fails closed when signed out. Cross-tenant and stale-permission attempts receive typed authorization denial, retain the durable command, and redact its count, id, payload, and server reason until the owning authorized session returns. Broader conflict decisions and authorization of snapshot/event reads remain. - **State:** In progress — one app-owned `move_card` server command validates a durable client command id, applies the authoritative canonical column once, returns the same acknowledgement for an identical retry, rejects id reuse with a different payload, assigns one server sequence, and redelivers that canonical acknowledgement after a real EventSource disconnect/reconnect. Canonical acknowledgements and the next sequence are durably stored in a strict versioned JSON envelope using fsync plus atomic replacement; startup refuses malformed/unknown state, rebuilds the canonical board, and preserves idempotency and event replay across a real process restart. A dedicated opt-in sync route reads one pending IndexedDB command, retries transient failures with capped exponential backoff and randomized jitter, exposes online/offline state plus an accessible manual retry after exhaustion, waits for canonical acknowledgement on the reconnecting transport, and only then removes the durable command; later retry converges without a new local mutation. Canonical payload conflicts are not retried and remain durable with a visible reason. If requested history predates retained events, the stream emits a typed snapshot-required event and the client loads a versioned canonical snapshot. One deterministic rebase rule treats `reorder_card` as converged only when the canonical snapshot already places that card in `done`; it then atomically stores the snapshot/cursor and removes the satisfied command. If a later canonical command instead places the same card in `doing`, the rebase is explicitly `conflicted`, retains the local command and last committed snapshot/cursor unchanged, and exposes the divergent canonical column/reason. Two same-origin tabs coordinate an app-owned expiring IndexedDB lease so only one uploads; the standby exposes its role without issuing a request, and after the leader closes it takes over, receives one canonical acknowledgement/sequence, and removes the queue once. Each activation serializes uploads with one in flight, processes at most two acknowledged commands, exposes the retained durable count when backpressured, and resumes the next bounded run only through the visible retry action. A mixed queue commits and removes its accepted prefix exactly once, then stops on the first permanent rejection with the typed server cause visible, the rejected command plus untouched suffix durable, and blind retry disabled. The IndexedDB v1-to-v2 command migration transactionally adds the explicit target column, records a typed migration receipt, preserves causal order and interaction intent through an interrupted upload, and later drains in the original order. Every command POST now derives its principal, permission, and tenant from the current same-origin session before idempotency lookup or mutation; authenticated mode accepts only app-configured opaque session tokens and fails closed when signed out. Direct cross-tenant and stale-permission command attempts receive typed authorization denial before mutation. IndexedDB v3 additionally indexes every command and lease by the current server-derived tenant/principal partition: switched users enumerate zero foreign commands, issue no foreign replay, cannot export foreign payloads, and the owning account can export then resume its intact queue. Signed-out context lookup fails closed before opening the queue. Broader conflict decisions and authorization of snapshot/event reads remain.
- **Build:** materialize `hemx-sync` over an integration transport with idempotent server command processing, snapshot/change cursor, durable acknowledgements, bounded ordered replay, current auth checks, rejection/conflict results, canonical replacement, reconnect jitter/backoff, multi-tab coordination, and redacted diagnostics. - **Build:** materialize `hemx-sync` over an integration transport with idempotent server command processing, snapshot/change cursor, durable acknowledgements, bounded ordered replay, current auth checks, rejection/conflict results, canonical replacement, reconnect jitter/backoff, multi-tab coordination, and redacted diagnostics.
- **Refusals:** no default CRDT, transport in core, cached enqueue-time permission, unbounded queue, or silent last-write-wins policy. - **Refusals:** no default CRDT, transport in core, cached enqueue-time permission, unbounded queue, or silent last-write-wins policy.
- **Requirements:** `sync/001-023`, `operations/001-005`, `security/002-005`, `performance/004-005`. - **Requirements:** `sync/001-023`, `operations/001-005`, `security/002-005`, `performance/004-005`.
- **Proof:** `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_converges_without_losing_local_intent -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit with acknowledged removal, then a divergent canonical update producing explicit conflict while the local command and prior committed snapshot/cursor remain intact. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 13 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e replay_revalidates_current_principal_and_tenant_without_exposing_local_work -- --exact` proves a queued alpha-tenant command cannot mutate under a current beta editor, signed-out, or stale alpha viewer session, remains durable behind redacted diagnostics without blind retry, and applies exactly once only after the current alpha editor session returns. The completed slice proof must additionally cover broader conflict decisions and authorization of snapshot/event reads. - **Proof:** `cargo test -p hemx-kanban-example --test browser_e2e idempotent_server_command_is_acknowledged_after_reconnect -- --exact` proves duplicate POST delivery yields one identical canonical acknowledgement/sequence, conflicting id reuse is rejected, EventSource reconnects after a server-closed first stream, the acknowledgement is delivered once with its sequence as event id, and a page reload shows the authoritative card in the canonical column. `cargo test -p hemx-kanban-example --test browser_e2e pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack -- --exact` proves automatic platform-store upload, one explicit transient failure, bounded exponential backoff plus jitter, reconnect acknowledgement, pending-before-ack ordering, acknowledged removal, canonical board convergence, and non-retried 409 rejection remaining durable with a visible reason. `cargo test -p hemx-kanban-example --test browser_e2e canonical_acknowledgement_survives_server_restart -- --exact` proves the versioned store is materialized before success, a real process restart reloads the same idempotent acknowledgement/sequence, EventSource replays it by id, and canonical board state is rebuilt. `cargo test -p hemx-kanban-example --test browser_e2e exhausted_offline_retries_keep_command_until_later_reconnect -- --exact` proves three bounded retries exhaust into visible offline/manual-recovery state while the command remains durable, then a later retry acknowledges/removes it and converges canonically. `cargo test -p hemx-kanban-example --test browser_e2e missing_history_rebase_converges_without_losing_local_intent -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit with acknowledged removal, then a divergent canonical update producing explicit conflict while the local command and prior committed snapshot/cursor remain intact. `cargo test -p hemx-kanban-example --test browser_e2e two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_application -- --exact` proves one retry-exhausted leader/one explicit standby, zero follower upload before takeover, lease takeover after the leader closes, one canonical sequence/event, one queue removal, and one board application. `cargo test -p hemx-kanban-example --test browser_e2e upload_backpressure_keeps_pending_work_visible_and_recoverable -- --exact` proves one in-flight upload, a two-acknowledgement activation limit, one retained durable command with visible recovery state, and explicit retry draining the final command without loss. `cargo test -p hemx-kanban-example --test browser_e2e mixed_queue_removes_accepted_prefix_and_retains_rejected_tail -- --exact` proves an accepted prefix is canonically applied and removed once before a permanent rejection stops processing, exposes its typed HTTP/server cause, disables blind retry, and leaves both the rejected command and untouched suffix durable. `cargo test -p hemx-kanban-example --test browser_e2e schema_upgrade_preserves_queued_order_and_local_intent -- --exact` proves a three-command v1 queue migrates atomically to the explicit-target v2 schema, remains byte-for-intent ordered after interrupted upload, then receives canonical sequences 13 in original order and drains without loss. `cargo test -p hemx-kanban-example --test browser_e2e account_partition_hides_replay_and_export_until_owner_returns -- --exact` proves a beta editor and alpha viewer enumerate zero commands and issue no replay for an alpha owner queue, signed-out startup cannot open a partition, no foreign id/export surface leaks, and only the returning alpha owner can export the intact command then resume it exactly once. The completed slice proof must additionally cover broader conflict decisions and authorization of snapshot/event reads.
## Slice 5 — local-first multiplayer Kanban milestone ## Slice 5 — local-first multiplayer Kanban milestone
+18
View File
@@ -428,6 +428,7 @@ async fn main() {
.route("/events", get(events)) .route("/events", get(events))
.route("/sync-demo", get(sync_demo)) .route("/sync-demo", get(sync_demo))
.route("/sync.js", get(sync_js)) .route("/sync.js", get(sync_js))
.route("/sync/context", get(sync_context))
.route("/sync/commands", post(sync_command)) .route("/sync/commands", post(sync_command))
.route("/sync/acknowledgements", get(sync_acknowledgements)) .route("/sync/acknowledgements", get(sync_acknowledgements))
.route("/sync/snapshot", get(sync_snapshot)) .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( fn authorize_sync_replay(
principal: CurrentSyncPrincipal, principal: CurrentSyncPrincipal,
card_id: u64, card_id: u64,
+66 -22
View File
@@ -1,10 +1,12 @@
const DATABASE = "hemx-kanban-v1"; const DATABASE = "hemx-kanban-v1";
const DATABASE_VERSION = 2; const DATABASE_VERSION = 3;
const COMMANDS = "commands"; const COMMANDS = "commands";
const META = "meta"; const META = "meta";
const ACCOUNT_INDEX = "byAccountPartition";
const COMMAND_SCHEMA = 2; const COMMAND_SCHEMA = 2;
const LEGACY_COMMAND_SCHEMA = 1; const LEGACY_COMMAND_SCHEMA = 1;
const MIGRATION_KEY = "commandSchemaMigration"; const MIGRATION_KEY = "commandSchemaMigration";
const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1";
const EXPORT_SCHEMA = 1; const EXPORT_SCHEMA = 1;
const MAX_REPLAY_COMMANDS = 64; const MAX_REPLAY_COMMANDS = 64;
const REPLAY_BUDGET_MS = 100; const REPLAY_BUDGET_MS = 100;
@@ -28,23 +30,30 @@ function completed(transaction) {
function migrateCommandLog(request, oldVersion) { function migrateCommandLog(request, oldVersion) {
const database = request.result; 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 (!database.objectStoreNames.contains(META)) database.createObjectStore(META);
if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return; if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return;
const transaction = request.transaction; const transaction = request.transaction;
const commands = transaction.objectStore(COMMANDS);
const meta = transaction.objectStore(META); const meta = transaction.objectStore(META);
const all = commands.getAll(); const all = commands.getAll();
all.addEventListener("success", () => { all.addEventListener("success", () => {
const legacy = all.result; 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(); transaction.abort();
return; return;
} }
for (const command of legacy) { 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 }); }, { once: true });
} }
@@ -54,6 +63,28 @@ function openCommandLog() {
return result(request); 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) { function clientReady(root) {
if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve(); if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -85,7 +116,7 @@ function stableSession() {
return session; return session;
} }
async function appendReorder(database, wire) { async function appendReorder(database, accountPartition, wire) {
const transaction = database.transaction([COMMANDS, META], "readwrite"); const transaction = database.transaction([COMMANDS, META], "readwrite");
const done = completed(transaction); const done = completed(transaction);
const completion = done.then( const completion = done.then(
@@ -94,14 +125,17 @@ async function appendReorder(database, wire) {
); );
const meta = transaction.objectStore(META); const meta = transaction.objectStore(META);
const commands = transaction.objectStore(COMMANDS); const commands = transaction.objectStore(COMMANDS);
const actorRequest = result(meta.get("actor")); const actorKey = `actor:${accountPartition}`;
const causalRequest = result(meta.get("causal")); 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 [storedActor, storedCausal] = await Promise.all([actorRequest, causalRequest]);
const actor = storedActor || crypto.randomUUID(); const actor = storedActor || crypto.randomUUID();
const causal = (storedCausal || 0) + 1; const causal = (storedCausal || 0) + 1;
const command = { const command = {
id: `${actor}:${causal}`, id: `${actor}:${causal}`,
schemaVersion: COMMAND_SCHEMA, schemaVersion: COMMAND_SCHEMA,
accountPartition,
actor, actor,
session: stableSession(), session: stableSession(),
causal, causal,
@@ -114,10 +148,10 @@ async function appendReorder(database, wire) {
let append; let append;
let counted; let counted;
try { try {
meta.put(actor, "actor"); meta.put(actor, actorKey);
meta.put(causal, "causal"); meta.put(causal, causalKey);
append = result(commands.add(command)); append = result(commands.add(command));
counted = result(commands.count()); counted = result(commands.index(ACCOUNT_INDEX).count(accountPartition));
} catch (error) { } catch (error) {
transaction.abort(); transaction.abort();
await completion; 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 transaction = database.transaction(COMMANDS, "readonly");
const done = completed(transaction); 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; await done;
return commands.sort((left, right) => left.causal - right.causal); 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"}`); throw new Error(`unsupported durable command ${command.id || "record"}`);
} }
if (typeof command.id !== "string" || !command.id) invalidCommand(command, "id"); 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.actor !== "string" || !command.actor) invalidCommand(command, "actor");
if (typeof command.session !== "string" || !command.session) invalidCommand(command, "session"); if (typeof command.session !== "string" || !command.session) invalidCommand(command, "session");
if (!Number.isSafeInteger(command.causal) || command.causal < 1) invalidCommand(command, "causal"); 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 })); 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 transaction = database.transaction(COMMANDS, "readwrite");
const done = completed(transaction); 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; 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]")]; const controls = [...root.querySelectorAll("[data-kanban-command-action]")];
for (const control of controls) { for (const control of controls) {
control.addEventListener("click", async () => { control.addEventListener("click", async () => {
@@ -260,12 +301,12 @@ function installRecoveryControls(root, database) {
controls.forEach((item) => { item.disabled = true; }); controls.forEach((item) => { item.disabled = true; });
try { try {
if (action === "export") { if (action === "export") {
exportCommands(root, await storedCommands(database)); exportCommands(root, await storedCommands(database, accountPartition));
controls.forEach((item) => { item.disabled = false; }); controls.forEach((item) => { item.disabled = false; });
return; return;
} }
if (action === "delete") { if (action === "delete") {
await clearCommands(database); await clearCommands(database, accountPartition);
root.dispatchEvent(new CustomEvent("kanban:commands-deleted")); root.dispatchEvent(new CustomEvent("kanban:commands-deleted"));
} else if (action === "reset") { } else if (action === "reset") {
await resetLocalData(database); await resetLocalData(database);
@@ -286,6 +327,9 @@ function installRecoveryControls(root, database) {
async function start() { async function start() {
const root = document.querySelector(ROOT); const root = document.querySelector(ROOT);
if (!root) return; 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 databasePromise = openCommandLog();
const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error)); const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error));
await clientReady(root); await clientReady(root);
@@ -299,7 +343,7 @@ async function start() {
let command; let command;
let count; let count;
try { try {
({ command, count } = await appendReorder(await databasePromise, wire)); ({ command, count } = await appendReorder(await databasePromise, accountPartition, wire));
} catch (error) { } catch (error) {
report(root, "persist", error); report(root, "persist", error);
throw error; throw error;
@@ -328,10 +372,10 @@ async function start() {
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler); wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered"); if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
const database = await databasePromise; const database = await databasePromise;
installRecoveryControls(root, database); installRecoveryControls(root, database, accountPartition);
root.setAttribute("data-kanban-replay-limit", String(MAX_REPLAY_COMMANDS)); root.setAttribute("data-kanban-replay-limit", String(MAX_REPLAY_COMMANDS));
try { try {
const commands = await storedCommands(database); const commands = await storedCommands(database, accountPartition);
if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length); if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length);
commands.forEach(validate); commands.forEach(validate);
const replayStarted = performance.now(); const replayStarted = performance.now();
+57 -14
View File
@@ -1,6 +1,7 @@
const DATABASE = "hemx-kanban-v1"; const DATABASE = "hemx-kanban-v1";
const DATABASE_VERSION = 2; const DATABASE_VERSION = 3;
const COMMANDS = "commands"; const COMMANDS = "commands";
const ACCOUNT_INDEX = "byAccountPartition";
const COMMAND_SCHEMA = 2; const COMMAND_SCHEMA = 2;
const LEGACY_COMMAND_SCHEMA = 1; const LEGACY_COMMAND_SCHEMA = 1;
const MIGRATION_KEY = "commandSchemaMigration"; 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 TAB_ID = sessionStorage.getItem("hemx-kanban-sync-tab-id") || crypto.randomUUID();
const LEASE_MS = 5000; const LEASE_MS = 5000;
const LEASE_POLL_MS = 100; const LEASE_POLL_MS = 100;
const LEASE_KEY = "uploaderLease";
let database; let database;
let accountPartition;
let uploadLimit; let uploadLimit;
let retryTimer; let retryTimer;
let leaseTimer; let leaseTimer;
@@ -50,23 +51,30 @@ function transactionDone(transaction) {
function migrateCommandLog(request, oldVersion) { function migrateCommandLog(request, oldVersion) {
const database = request.result; 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 (!database.objectStoreNames.contains("meta")) database.createObjectStore("meta");
if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return; if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return;
const transaction = request.transaction; const transaction = request.transaction;
const commands = transaction.objectStore(COMMANDS);
const meta = transaction.objectStore("meta"); const meta = transaction.objectStore("meta");
const all = commands.getAll(); const all = commands.getAll();
all.addEventListener("success", () => { all.addEventListener("success", () => {
const legacy = all.result; 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(); transaction.abort();
return; return;
} }
for (const command of legacy) { 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 }); }, { once: true });
} }
@@ -79,7 +87,7 @@ async function openLog() {
async function pendingCommands(database) { async function pendingCommands(database) {
const transaction = database.transaction(COMMANDS, "readonly"); const transaction = database.transaction(COMMANDS, "readonly");
const done = transactionDone(transaction); 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; await done;
return commands.sort((left, right) => left.causal - right.causal); return commands.sort((left, right) => left.causal - right.causal);
} }
@@ -105,13 +113,14 @@ async function claimUploaderLease(database) {
const done = transactionDone(transaction); const done = transactionDone(transaction);
const meta = transaction.objectStore("meta"); const meta = transaction.objectStore("meta");
const now = Date.now(); 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) { if (current && current.owner !== TAB_ID && current.expiresAt > now) {
await done; await done;
return { leader: false, owner: current.owner, expiresAt: current.expiresAt }; return { leader: false, owner: current.owner, expiresAt: current.expiresAt };
} }
const lease = { owner: TAB_ID, expiresAt: now + LEASE_MS }; const lease = { owner: TAB_ID, expiresAt: now + LEASE_MS };
meta.put(lease, LEASE_KEY); meta.put(lease, leaseKey);
await done; await done;
return { leader: true, ...lease }; return { leader: true, ...lease };
} }
@@ -120,8 +129,9 @@ async function releaseUploaderLease(database) {
const transaction = database.transaction("meta", "readwrite"); const transaction = database.transaction("meta", "readwrite");
const done = transactionDone(transaction); const done = transactionDone(transaction);
const meta = transaction.objectStore("meta"); const meta = transaction.objectStore("meta");
const current = await requestResult(meta.get(LEASE_KEY)); const leaseKey = `uploaderLease:${accountPartition}`;
if (current?.owner === TAB_ID) meta.delete(LEASE_KEY); const current = await requestResult(meta.get(leaseKey));
if (current?.owner === TAB_ID) meta.delete(leaseKey);
await done; await done;
} }
@@ -147,7 +157,7 @@ function setPhase(phase, message) {
} }
function validatePending(command) { 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"); throw new Error("invalid pending command");
} }
return command; return command;
@@ -164,6 +174,23 @@ function setManualRetryAvailable(available) {
else root.removeAttribute("data-sync-manual-retry"); 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) { function scheduleManualRetry(command, error) {
clearTimeout(retryTimer); clearTimeout(retryTimer);
root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error)); root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error));
@@ -225,6 +252,7 @@ function finishUpload() {
async function continuePendingWork() { async function continuePendingWork() {
const commands = await pendingCommands(database); const commands = await pendingCommands(database);
root.setAttribute("data-sync-pending-count", String(commands.length)); root.setAttribute("data-sync-pending-count", String(commands.length));
setExportAvailable(commands.length > 0);
if (commands.length === 0) return; if (commands.length === 0) return;
if (uploadsThisRun >= uploadLimit) { if (uploadsThisRun >= uploadLimit) {
setManualRetryAvailable(true); setManualRetryAvailable(true);
@@ -282,7 +310,9 @@ async function synchronize(command) {
uploadedTotal += 1; uploadedTotal += 1;
root.setAttribute("data-sync-uploaded-this-run", String(uploadsThisRun)); root.setAttribute("data-sync-uploaded-this-run", String(uploadsThisRun));
root.setAttribute("data-sync-uploaded-total", String(uploadedTotal)); 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-sequence", String(canonical.serverSequence));
root.setAttribute("data-sync-ack-command-id", canonical.commandId); root.setAttribute("data-sync-ack-command-id", canonical.commandId);
root.setAttribute("data-sync-canonical-column", canonical.canonicalColumn); root.setAttribute("data-sync-canonical-column", canonical.canonicalColumn);
@@ -373,6 +403,14 @@ async function runLeaseLoop(command) {
async function start() { async function start() {
if (!root) return; 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); 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"); if (!Number.isSafeInteger(uploadLimit) || uploadLimit < 1) throw new Error("data-sync-upload-limit must be a positive integer");
database = await openLog(); database = await openLog();
@@ -390,6 +428,7 @@ async function start() {
root.setAttribute("data-sync-in-flight", "0"); root.setAttribute("data-sync-in-flight", "0");
root.setAttribute("data-sync-max-observed-in-flight", "0"); root.setAttribute("data-sync-max-observed-in-flight", "0");
root.setAttribute("data-sync-pending-count", String(commands.length)); root.setAttribute("data-sync-pending-count", String(commands.length));
setExportAvailable(commands.length > 0);
setManualRetryAvailable(false); setManualRetryAvailable(false);
if (commands.length === 0) { if (commands.length === 0) {
setPhase("idle", "No pending commands."); setPhase("idle", "No pending commands.");
@@ -397,6 +436,10 @@ async function start() {
} }
const command = validatePending(commands[0]); const command = validatePending(commands[0]);
root.addEventListener("click", async (event) => { root.addEventListener("click", async (event) => {
if (event.target.closest("[data-sync-export]")) {
await exportPendingWork();
return;
}
if (!event.target.closest("[data-sync-retry]")) return; if (!event.target.closest("[data-sync-retry]")) return;
uploadsThisRun = 0; uploadsThisRun = 0;
root.setAttribute("data-sync-uploaded-this-run", "0"); root.setAttribute("data-sync-uploaded-this-run", "0");
@@ -10,6 +10,7 @@
<h2 id="sync-title">Sync status</h2> <h2 id="sync-title">Sync status</h2>
<p role="status" aria-live="polite">Waiting for pending commands.</p> <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-retry>Retry sync now</button>
<button type="button" data-sync-export disabled>Export this account's queue</button>
</section> </section>
<script +src="self.runtime_src" defer></script> <script +src="self.runtime_src" defer></script>
<script src="/sync.js" defer></script> <script src="/sync.js" defer></script>
+82 -49
View File
@@ -242,8 +242,8 @@ async fn pending_local_command_uploads_with_bounded_retry_and_is_removed_on_ack(
open.onsuccess = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ tx.objectStore('commands').add({
id: 'sync-actor:1', schemaVersion: 1, actor: 'sync-actor', session: 'sync-session', id: 'sync-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'sync-actor', session: 'sync-session',
causal: 1, kind: 'reorder_card', cardId: '1', eventKind: 'click', key: null, causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null,
}); });
tx.oncomplete = () => done({ seeded: true }); tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name }); 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 = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ 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, causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
}); });
tx.oncomplete = () => done({ seeded: true }); 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] #[tokio::test]
async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_work( async fn account_partition_hides_replay_and_export_until_owner_returns() -> WebDriverResult<()> {
) -> WebDriverResult<()> { // test req: sync/020 req: security/004 req: auth/005 req: operations/002
// test req: sync/019 req: security/004 req: auth/005 req: operations/002
let app_port = available_port(); let app_port = available_port();
let app_addr = format!("127.0.0.1:{app_port}"); let app_addr = format!("127.0.0.1:{app_port}");
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); 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( .env(
"HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR", "HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR",
"test-token-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) let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
.expect("start ready hemx-kanban"); .expect("start ready hemx-kanban");
@@ -422,7 +422,7 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
open.onsuccess = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ 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', causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done',
eventKind: 'click', key: null, enqueuedPrincipal: 'alice', enqueuedTenant: 'alpha', 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?; driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
wait_until( wait_until(
&driver, &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?; .await?;
let cross_tenant = driver let cross_tenant = driver
.execute( .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(), Vec::new(),
) )
.await? .await?
.json() .json()
.clone(); .clone();
assert_eq!(cross_tenant["phase"], "authorization-denied"); assert_eq!(cross_tenant["phase"], "idle");
assert_eq!(cross_tenant["kind"], "authorization-denial"); assert_eq!(cross_tenant["account"], "beta:carol");
assert_eq!(cross_tenant["errorStatus"], "403"); assert_eq!(cross_tenant["pending"], "0");
assert_eq!(cross_tenant["pending"], "redacted"); assert!(cross_tenant["attempts"].is_null());
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["leakedId"], false); assert_eq!(cross_tenant["leakedId"], false);
assert_eq!( assert_eq!(cross_tenant["status"], "No pending commands.");
cross_tenant["status"],
"Current session cannot access local queued work. Sign back into the owning account to continue."
);
assert_eq!(command_count(&driver).await?, 1); assert_eq!(command_count(&driver).await?, 1);
driver driver
@@ -475,20 +468,20 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
driver.refresh().await?; driver.refresh().await?;
wait_until( wait_until(
&driver, &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?; .await?;
let signed_out = driver let signed_out = driver
.execute( .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(), Vec::new(),
) )
.await? .await?
.json() .json()
.clone(); .clone();
assert_eq!(signed_out["kind"], "authorization-denial"); assert_eq!(signed_out["error"], "account context failed with 401");
assert_eq!(signed_out["status"], "401"); assert!(signed_out["pending"].is_null());
assert_eq!(signed_out["pending"], "redacted"); assert!(signed_out["account"].is_null());
assert_eq!(command_count(&driver).await?, 1); assert_eq!(command_count(&driver).await?, 1);
let before_authorized = driver let before_authorized = driver
@@ -516,30 +509,70 @@ async fn replay_revalidates_current_principal_and_tenant_without_exposing_local_
driver.refresh().await?; driver.refresh().await?;
wait_until( wait_until(
&driver, &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?; .await?;
let stale_permission = driver let switched_user = driver
.execute( .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(), Vec::new(),
) )
.await? .await?
.json() .json()
.clone(); .clone();
assert_eq!(stale_permission["kind"], "authorization-denial"); assert_eq!(switched_user["account"], "alpha:bob");
assert_eq!(stale_permission["status"], "403"); assert_eq!(switched_user["pending"], "0");
assert_eq!(stale_permission["pending"], "redacted"); assert!(switched_user["attempts"].is_null());
assert!(stale_permission["rejectedId"].is_null()); assert_eq!(switched_user["leakedId"], false);
assert_eq!(command_count(&driver).await?, 1); 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 driver
.execute( .execute(
"document.cookie = 'hemx_kanban_session=test-token-alice-alpha-editor; Path=/; SameSite=Strict'; return true;", "document.cookie = 'hemx_kanban_session=test-token-alice-alpha-editor; Path=/; SameSite=Strict'; return true;",
Vec::new(), Vec::new(),
) )
.await?; .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( wait_until(
&driver, &driver,
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'", "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? .await?
.json() .json()
.clone(); .clone();
assert_eq!(migrated["databaseVersion"], 2); assert_eq!(migrated["databaseVersion"], 3);
assert_eq!(migrated["commandSchema"], "2"); assert_eq!(migrated["commandSchema"], "2");
assert_eq!(migrated["migrationFrom"], "1"); assert_eq!(migrated["migrationFrom"], "1");
assert_eq!(migrated["migrationTo"], "2"); assert_eq!(migrated["migrationTo"], "3");
assert_eq!(migrated["migratedCount"], "3"); assert_eq!(migrated["migratedCount"], "3");
assert_eq!(migrated["pending"], "3"); assert_eq!(migrated["pending"], "3");
assert_eq!( assert_eq!(
migrated["receipt"], migrated["receipt"],
serde_json::json!({ "from": 1, "to": 2, "migrated": 3 }) serde_json::json!({ "from": 1, "to": 3, "migrated": 3 })
); );
assert_eq!( assert_eq!(
migrated["commands"], migrated["commands"],
@@ -760,8 +793,8 @@ async fn mixed_queue_removes_accepted_prefix_and_retains_rejected_tail() -> WebD
const commands = tx.objectStore('commands'); const commands = tx.objectStore('commands');
for (const [causal, cardId] of [[1, '1'], [2, '999'], [3, '2']]) { for (const [causal, cardId] of [[1, '1'], [2, '999'], [3, '2']]) {
commands.add({ commands.add({
id: `mixed:${causal}`, schemaVersion: 1, actor: 'mixed', session: 'mixed-session', id: `mixed:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'mixed', session: 'mixed-session',
causal, kind: 'reorder_card', cardId, eventKind: 'click', key: null, causal, kind: 'reorder_card', cardId, targetColumn: 'done', eventKind: 'click', key: null,
}); });
} }
tx.oncomplete = () => done({ seeded: true }); 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'); const commands = tx.objectStore('commands');
for (let causal = 1; causal <= 3; causal += 1) { for (let causal = 1; causal <= 3; causal += 1) {
commands.add({ commands.add({
id: `pressure:${causal}`, schemaVersion: 1, actor: 'pressure', session: 'pressure-session', id: `pressure:${causal}`, schemaVersion: 2, accountPartition: 'demo:demo', actor: 'pressure', session: 'pressure-session',
causal, kind: 'reorder_card', cardId: String(causal), eventKind: 'click', key: null, causal, kind: 'reorder_card', cardId: String(causal), targetColumn: 'done', eventKind: 'click', key: null,
}); });
} }
tx.oncomplete = () => done({ seeded: true }); tx.oncomplete = () => done({ seeded: true });
@@ -997,8 +1030,8 @@ async fn two_tabs_coordinate_single_uploader_and_takeover_without_duplicate_appl
open.onsuccess = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ tx.objectStore('commands').add({
id: 'tabs:1', schemaVersion: 1, actor: 'tabs', session: 'tabs-session', id: 'tabs:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'tabs', session: 'tabs-session',
causal: 1, kind: 'reorder_card', cardId: '1', eventKind: 'click', key: null, causal: 1, kind: 'reorder_card', cardId: '1', targetColumn: 'done', eventKind: 'click', key: null,
}); });
tx.oncomplete = () => done({ seeded: true }); tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name }); 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 = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ tx.objectStore('commands').add({
id: 'offline-actor:1', schemaVersion: 1, actor: 'offline-actor', session: 'offline-session', id: 'offline-actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'offline-actor', session: 'offline-session',
causal: 1, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null, causal: 1, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
}); });
tx.oncomplete = () => done({ seeded: true }); tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name }); 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 = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ tx.objectStore('commands').add({
id: 'history:2', schemaVersion: 1, actor: 'history', session: 'history-session', id: 'history:2', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'history', session: 'history-session',
causal: 2, kind: 'reorder_card', cardId: '2', eventKind: 'click', key: null, causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
}); });
tx.oncomplete = () => done({ seeded: true }); tx.oncomplete = () => done({ seeded: true });
tx.onabort = () => done({ error: tx.error && tx.error.name }); 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 = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ 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, causal: 3, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
}); });
tx.oncomplete = () => done({ seeded: true }); tx.oncomplete = () => done({ seeded: true });
+13 -8
View File
@@ -288,7 +288,7 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
.await?; .await?;
wait_until( wait_until(
&driver, &driver,
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-ready')", "return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
) )
.await?; .await?;
let restored = driver let restored = driver
@@ -312,7 +312,7 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
request.onsuccess = () => { request.onsuccess = () => {
const tx = request.result.transaction('commands', 'readwrite'); const tx = request.result.transaction('commands', 'readwrite');
tx.objectStore('commands').add({ tx.objectStore('commands').add({
id: 'future:2', schemaVersion: 3, actor: 'future', session: 'future', id: 'future:2', schemaVersion: 3, accountPartition: 'demo:demo', actor: 'future', session: 'future',
causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null, causal: 2, kind: 'reorder_card', cardId: '2', targetColumn: 'done', eventKind: 'click', key: null,
}); });
tx.oncomplete = () => { window.__futureCommandStored = true; }; tx.oncomplete = () => { window.__futureCommandStored = true; };
@@ -328,7 +328,7 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
.await?; .await?;
wait_until( wait_until(
&driver, &driver,
"return !window.__reloadPending && document.querySelector('[data-hemx-root]').hasAttribute('data-kanban-command-error')", "return !window.__reloadPending && document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-error') === true",
) )
.await?; .await?;
let rejected = driver let rejected = driver
@@ -1476,12 +1476,17 @@ fn serve(
fs::read(&app_assets.expect("checked app assets").service_worker) fs::read(&app_assets.expect("checked app assets").service_worker)
.expect("read service worker"), .expect("read service worker"),
), ),
"/sync/context" if app_assets.is_some() => (
"application/json; charset=utf-8",
br#"{"accountPartition":"demo:demo"}"#.to_vec(),
),
_ => ("text/plain", b"not found".to_vec()), _ => ("text/plain", b"not found".to_vec()),
}; };
let status = if path == "/" let status = if path == "/"
|| path == "/hemx.js" || path == "/hemx.js"
|| path == "/hemx.client.js" || path == "/hemx.client.js"
|| ((path == "/app.js" || path == "/offline.js") && app_assets.is_some()) || ((path == "/app.js" || path == "/offline.js" || path == "/sync/context")
&& app_assets.is_some())
|| path.starts_with(&format!("/{asset_stem}")) || path.starts_with(&format!("/{asset_stem}"))
{ {
"200 OK" "200 OK"
@@ -1511,7 +1516,7 @@ async fn hold_command_transaction(driver: &WebDriver) -> WebDriverResult<()> {
r#" r#"
window.__releaseCommandTransaction = false; window.__releaseCommandTransaction = false;
window.__commandTransactionHeld = false; window.__commandTransactionHeld = false;
const open = indexedDB.open('hemx-kanban-v1', 1); const open = indexedDB.open('hemx-kanban-v1');
open.onsuccess = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
const commands = tx.objectStore('commands'); const commands = tx.objectStore('commands');
@@ -1571,7 +1576,7 @@ async fn store_replay_commands(driver: &WebDriver, first: u64, last: u64) -> Web
const first = arguments[0]; const first = arguments[0];
const last = arguments[1]; const last = arguments[1];
const done = arguments[arguments.length - 1]; const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 1); const open = indexedDB.open('hemx-kanban-v1');
open.onerror = () => done({ error: open.error && open.error.name }); open.onerror = () => done({ error: open.error && open.error.name });
open.onsuccess = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
@@ -1611,7 +1616,7 @@ async fn store_malformed_command(driver: &WebDriver) -> WebDriverResult<()> {
.execute_async( .execute_async(
r#" r#"
const done = arguments[arguments.length - 1]; const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 1); const open = indexedDB.open('hemx-kanban-v1');
open.onerror = () => done({ error: open.error && open.error.name }); open.onerror = () => done({ error: open.error && open.error.name });
open.onsuccess = () => { open.onsuccess = () => {
const tx = open.result.transaction('commands', 'readwrite'); const tx = open.result.transaction('commands', 'readwrite');
@@ -1647,7 +1652,7 @@ async fn occupy_next_command_id(driver: &WebDriver) -> WebDriverResult<()> {
.execute_async( .execute_async(
r#" r#"
const done = arguments[arguments.length - 1]; const done = arguments[arguments.length - 1];
const open = indexedDB.open('hemx-kanban-v1', 1); const open = indexedDB.open('hemx-kanban-v1');
open.onerror = () => done({ error: open.error && open.error.name }); open.onerror = () => done({ error: open.error && open.error.name });
open.onsuccess = () => { open.onsuccess = () => {
const tx = open.result.transaction(['commands', 'meta'], 'readwrite'); const tx = open.result.transaction(['commands', 'meta'], 'readwrite');