feat(sync): web cursor-pull gap-filler (PR B3)#630
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… reseed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent no-op Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onnect Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pe errors in new test files Fix 4 tsc errors introduced by this PR (all in new/modified test files): - channel-onopen.test.ts: rename unused channelOn/join destructures to _channelOn/_join (TS6133); add ! non-null assertion on mock.calls array access (TS2532) - client.test.ts: add ! non-null assertion on fetchMock.mock.calls array access (TS2532) - cursor-sync.test.ts: drop unnecessary `as never` cast on ApiError constructor — mocked class is directly constructable (TS2351) Pre-existing viewer tsc errors (node-diff3/react-pdf missing types) are unchanged from main and not caused by this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A REST save broadcasts its own note_changed to every socket on the vault topic, including the saving tab (no per-device origin filter). That echo races the save's HTTP response, so baseRef is momentarily stale when the note-page handler runs merge3(staleBase, local, remote) — and remote is byte-identical to the local draft. merge3 produced identical hunks over the same base lines, which the collision gate (a range overlaps itself) wrongly flagged, raising a spurious conflict on every edit. Short-circuit equal sides: a 3-way merge of two identical sides always agrees. Pre-existing bug surfaced during live B3 selfhost testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Heads-up for reviewers: this branch also includes a pre-existing, unrelated bug fix (commit `fda77032`), bundled in at the maintainer's request after it surfaced during live selfhost browser testing of B3. `fix(web): treat identical 3-way merge sides as agreement` — the web editor showed a merge conflict on every edit. Root cause (verified live): a REST save broadcasts its own `note_changed` to every socket on the vault topic including the saving tab (no per-device origin filter); that echo races the save's HTTP response so `baseRef` is momentarily stale when `merge3(staleBase, local, remote)` runs — with `remote` byte-identical to the local draft. `merge3` produced identical hunks over the same base lines, which the collision gate (a range overlaps itself) wrongly flagged. Fix: short-circuit equal sides ( |
…switch client.authFetch sets X-Vault-ID from the active vault, not the vaultId passed to runCursorSync. An in-flight pull isn't cancelled on a vault switch, so a later page/manifest fetch goes out under the new vault's header while the result is stamped under the old vault — corrupting the old vault's cursor or cross-contaminating its query cache. Guard every apply/persist on getActiveVaultId() === vaultId; discard a response that arrived after a switch (the now-active vault has its own run). Found in code review of this branch.
Summary
PR B3 (web) of the ordered cursor-sync feature — migrates the React SPA onto the durable ordered change feed as a gap-filler beside the live Phoenix socket. Builds on B1 (backend
/sync/changes+ cursor) and B2 (plugin).The web has no local mirror — it renders from the server on demand and keeps an in-memory React Query cache invalidated live by the socket. The socket drops events with no replay while disconnected (tab backgrounded, laptop sleep, network blip). This PR adds a cursor pull that replays exactly the missed
seq>cursordeltas and feeds each note row into the existinghandleNoteChangedinvalidation pipeline — a change row is treated as a signal, not data.?fields=metapassthrough onGET /sync/changes(signal-only, no note content → no wasted server-side decrypt). Exposes the existinglist_changes_by_seq(fields: :meta)capability; attachments/watermark/pagination untouched. Lenientparse_fields(unknown → full, forward-compatible).device-id.ts— mint + persist a per-install UUID, sent asX-Device-Id.cursor.ts— opaque cursor codec byte-matched toEngram.Sync.encode_cursor/2+ per-vault localStorage.cursor-sync.ts— bootstrap a fresh device's cursor from the manifestchange_seq; pullfields=metadeltas; single-flight per vault; reseed on a stale-cursor 400/410 (HISTORY_EXPIREDdormant until PR D);console.warninstead of silently truncating at the page cap.socket.onOpen). Socket stays the latency accelerator; the cursor pull is the durable convergence path. Single-flight dedupes overlapping triggers.No migration (no
phase/*label). Lays the foundation for a future PWA — the apply step swaps invalidate→write-to-IndexedDB in one place.Spec:
docs/superpowers/specs/2026-06-16-sync-cursor-pull-design.md(PR B3 row). Plan:docs/superpowers/plans/2026-06-17-sync-cursor-pull-b3-web.md.Test Plan
mix test test/engram_web/controllers/sync_changes_test.exs(6 pass — meta-strips-content, default-keeps-content, lenient-unknown, + existing pagination/watermark/change_seq)bun run test src/api/(97 pass — device_id, codec backend-parity, bootstrap seed, pull advance/multi-page/null-tail/empty/attachment-noop, 410 reseed, single-flight, focus/reconnect/cleanup wiring)bun run build(tsc + vite) greenX-Device-Idheader +engram.syncCursor.<vault>seeded, an edit from a second client propagates to the web on refocus without reload, and/sync/changesrows carrycontent: null🤖 Generated with Claude Code