diff --git a/PLAN.md b/PLAN.md index aa4d1ad..552c2ab 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ encryption, retention, backup, and deployment policy remain host concerns. - **Build:** add an optional durable command-log adapter around platform transactional storage; persist versioned command ids and app payload before projection; restore projection after reload; expose queue state, export/delete/reset, quota/corruption failure, and migration refusal. - **Refusals:** no server reconciliation, CRDT, mandatory IndexedDB, credential storage, or policy hidden in core. - **Requirements:** `local/001-004`, `sync/009`, `sync/014-015`, `sync/020`, `security/007`, `accessibility/004`, `client_local/013`, `performance/003`, `performance/005-006`. -- **Proof:** `cargo test -p hemx-wasm --test browser kanban_command_persists_before_projection_and_restores_after_reload -- --exact` proves transactional persist-before-project ordering, app-owned shell caching, current-version replay after a real Firefox reload with the fixture server unreachable, stable identity metadata, and explicit unknown-schema refusal through real WASM. `cargo test -p hemx-wasm --test browser kanban_command_export_delete_and_reset_are_recoverable -- --exact` proves accessible export/delete/reset entry points, versioned credential-free export, confirmation before destructive actions, preserved identity after queue deletion, and fresh identity/baseline projection after reset. `cargo test -p hemx-wasm --test browser kanban_persistence_failure_does_not_project_and_recovers -- --exact` proves transactional failure does not project or emit `kanban:command-persisted`, reports non-payload stage/code diagnostics, and recovers through the ordinary deletion path. `cargo test -p hemx-wasm --test browser kanban_corrupt_command_refuses_projection_and_recovers -- --exact` proves strict current-schema validation, no partial projection, visible non-payload diagnostics, raw versioned export for recovery, and ordinary deletion recovery. `cargo test -p hemx-wasm --test browser kanban_replay_is_bounded_and_within_budget -- --exact` proves 64-command preflight/replay within the 100 ms browser budget, zero partial projection at 65 commands, and export/delete recovery. `cargo test -p hemx-wasm --test browser kanban_quota_failure_is_fail_closed_and_recoverable -- --exact` proves quota-specific fail-closed behavior, no false durability event, transactional metadata rollback, and export/delete/reset recovery. `cargo test -p hemx-wasm --test browser kanban_queued_status_precedes_durable_projection_within_budget -- --exact` proves queued/busy feedback within the `performance/003` and `client_local/013` 100 ms budget while persistence is blocked, followed by durable projection only after commit. `cargo test -p hemx-kanban-example --test browser_e2e server_first_route_does_not_load_optional_client_assets -- --exact` proves the server-first route loads only its fingerprinted base runtime and creates no optional client root, PWA/WASM request, service worker, or IndexedDB database. +- **Proof:** `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves transactional persist-before-project ordering, app-owned shell caching, current-version replay after a real Firefox reload with the fixture server unreachable, stable identity metadata, and explicit unknown-schema refusal through real WASM. `cargo test -p hemx-wasm --test browser kanban_command_export_delete_and_reset_are_recoverable -- --exact` proves accessible export/delete/reset entry points, versioned credential-free export, confirmation before destructive actions, preserved identity after queue deletion, and fresh identity/baseline projection after reset. `cargo test -p hemx-wasm --test browser kanban_persistence_failure_does_not_project_and_recovers -- --exact` proves transactional failure does not project or emit `kanban:command-persisted`, reports non-payload stage/code diagnostics, and recovers through the ordinary deletion path. `cargo test -p hemx-wasm --test browser kanban_corrupt_command_refuses_projection_and_recovers -- --exact` proves strict current-schema validation, no partial projection, visible non-payload diagnostics, raw versioned export for recovery, and ordinary deletion recovery. `cargo test -p hemx-wasm --test browser kanban_replay_is_bounded_and_within_budget -- --exact` proves 64-command preflight/replay within the 100 ms browser budget, zero partial projection at 65 commands, and export/delete recovery. `cargo test -p hemx-wasm --test browser kanban_quota_failure_is_fail_closed_and_recoverable -- --exact` proves quota-specific fail-closed behavior, no false durability event, transactional metadata rollback, and export/delete/reset recovery. `cargo test -p hemx-wasm --test browser kanban_queued_status_precedes_durable_projection_within_budget -- --exact` proves queued/busy feedback within the `performance/003` and `client_local/013` 100 ms budget while persistence is blocked, followed by durable projection only after commit. `cargo test -p hemx-kanban-example --test browser_e2e server_first_route_does_not_load_optional_client_assets -- --exact` proves the server-first route loads only its fingerprinted base runtime and creates no optional client root, PWA/WASM request, service worker, or IndexedDB database. ## Slice 4 — authoritative reconnect and convergence @@ -46,7 +46,7 @@ encryption, retention, backup, and deployment policy remain host concerns. - **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. - **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_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `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 1–3 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. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. The completed slice proof must additionally cover broader domain-specific merge choices. +- **Proof:** `cargo test -p hemx-wasm --test browser kanban_public_api_offline_sync_journey_converges_without_duplicate_replay -- --exact` proves `sync/023` through the public generated client handler and real WASM: offline durable mutation, unavailable-origin reload projection, reconnect upload, canonical convergence, duplicate idempotency without a second event, conflicting duplicate rejection, invalid-command rejection, and an empty local queue. `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_and_user_conflict_resolution_preserve_suffix -- --exact` proves retained-history gap detection, typed/versioned snapshot fallback, deterministic already-canonical convergence, atomic snapshot/cursor commit, then a divergent canonical update producing explicit conflict with a two-command queue; the user’s canonical-state action removes only the conflicted head, retains and resumes the suffix, and reaches canonical convergence with an empty queue. `cargo test -p hemx-kanban-example --test browser_e2e keep_local_retry_preserves_conflicted_command_and_suffix_order -- --exact` proves the keep-local action derives one resolution identity; a permanent rejection preserves order and re-enables the choice; three later transport failures still preserve the original conflicted head plus suffix and expose manual recovery; final acceptance removes only the head and converges the untouched suffix without duplication or loss. `cargo test -p hemx-kanban-example --test browser_e2e redacted_sync_diagnostics_are_bounded_and_leak_no_sensitive_material -- --exact` proves a 15-second-old queue is bucketed rather than timestamped, cursor and acknowledgement latency advance after recovery, diagnostics stay under a fixed small readout, and command id/payload/actor/session/cookie material never reaches the diagnostics surface; the conflict and keep-local tests additionally assert conflict/rejection counters at their actual failure boundaries. `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 1–3 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. `cargo test -p hemx-kanban-example --test browser_e2e canonical_snapshot_and_history_are_tenant_scoped -- --exact` proves alpha and beta sessions receive only their own canonical cards and acknowledgement events with independent sequence 1 cursors, while signed-out snapshot/history reads receive 401 and expose no stream. Per-requirement audit: `sync/023` is browser-proven, but Slice 4 cannot close. `sync/002` and `sync/003` still describe an absent public optional `hemx-sync` `SyncEffect::send_patch` API and formal flat patch schema; the example currently owns a command-specific adapter. `sync/022` has no strict proof that identical snapshots, command sequences, and server results produce identical reconciliation output. All other `sync/*` rows have concrete browser proof citations. The next slice must materialize the public flat-patch API/schema through one offline/online browser path rather than relabeling the app-local adapter. ## Slice 5 — local-first multiplayer Kanban milestone diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index d44e8a3..c08c3f9 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -192,13 +192,29 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul } #[tokio::test] -async fn kanban_command_persists_before_projection_and_restores_after_reload() -> WebDriverResult<()> -{ - // test req: local/001 req: local/002 req: local/003 req: local/004 +async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_replay( +) -> WebDriverResult<()> { + // test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root") .to_owned(); + let host_build = Command::new("cargo") + .current_dir(&workspace) + .args([ + "build", + "-p", + "hemx-kanban-example", + "--bin", + "hemx-kanban-example", + ]) + .status() + .expect("build kanban host server"); + assert!(host_build.success(), "build kanban host server"); + let target_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace.join("target")); + let host_binary = target_dir.join("debug/hemx-kanban-example"); let (package, bootstrap, rendered) = build_kanban_artifact(&workspace); let runtime = workspace.join("hemx-js/runtime/hemx.js"); let mut server = StaticServer::start( @@ -304,6 +320,91 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() - assert!(restored["error"].is_null()); assert_eq!(restored["notice"], "Moved 1 with click"); + let app_addr = server.address.to_string(); + let mut app_command = Command::new(&host_binary); + app_command.env("HEMX_KANBAN_ADDR", &app_addr); + let _app = ProcessGuard::start(app_command, &app_addr); + 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') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'", + ) + .await?; + let command_id = persisted["persisted"]["detail"]["id"] + .as_str() + .expect("persisted command id"); + let duplicate_script = format!( + r#" + const done = arguments[arguments.length - 1]; + const commandId = {command_id:?}; + (async () => {{ + const duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }}); + const duplicate = await duplicateResponse.json(); + const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }}); + const conflict = await conflictResponse.json(); + const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }}); + const rejection = await rejectionResponse.json(); + const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json(); + const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text(); + const open = indexedDB.open('hemx-kanban-v1'); + open.onsuccess = () => {{ + const count = open.result.transaction('commands', 'readonly').objectStore('commands').count(); + count.onsuccess = () => done({{ + duplicateStatus: duplicateResponse.status, + duplicate, + conflictStatus: conflictResponse.status, + conflict, + rejectionStatus: rejectionResponse.status, + rejection, + snapshot, + history, + queueCount: count.result, + }}); + }}; + }})().catch((error) => done({{ error: String(error), stack: error.stack }})); + "# + ); + let convergence = driver + .execute_async(&duplicate_script, Vec::new()) + .await? + .json() + .clone(); + assert!( + convergence.get("error").is_none(), + "sync convergence failed: {convergence}" + ); + assert_eq!(convergence["duplicateStatus"], 200); + assert_eq!(convergence["duplicate"]["commandId"], command_id); + assert_eq!(convergence["duplicate"]["serverSequence"], 1); + assert_eq!(convergence["conflictStatus"], 409); + assert_eq!(convergence["conflict"]["kind"], "command-conflict"); + assert_eq!( + convergence["conflict"]["error"], + "command_id was already used for a different payload" + ); + assert_eq!(convergence["rejectionStatus"], 400); + assert_eq!(convergence["rejection"]["kind"], "invalid-command"); + assert_eq!(convergence["rejection"]["error"], "unknown card_id"); + assert_eq!(convergence["queueCount"], 0); + assert_eq!(convergence["snapshot"]["serverSequence"], 1); + assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1); + assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done"); + assert_eq!( + convergence["history"] + .as_str() + .expect("acknowledgement history") + .matches(command_id) + .count(), + 1, + "duplicate replay emitted another acknowledgement: {convergence}" + ); + + driver.goto(&format!("http://{app_addr}/")).await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true", + ) + .await?; driver .execute( r#"