Skip to content

feat(sync): web cursor-pull gap-filler (PR B3)#630

Merged
Rasbandit merged 13 commits into
mainfrom
feat/sync-cursor-pull-b3-web
Jun 17, 2026
Merged

feat(sync): web cursor-pull gap-filler (PR B3)#630
Rasbandit merged 13 commits into
mainfrom
feat/sync-cursor-pull-b3-web

Conversation

@Rasbandit

Copy link
Copy Markdown
Member

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>cursor deltas and feeds each note row into the existing handleNoteChanged invalidation pipeline — a change row is treated as a signal, not data.

  • Backend: ?fields=meta passthrough on GET /sync/changes (signal-only, no note content → no wasted server-side decrypt). Exposes the existing list_changes_by_seq(fields: :meta) capability; attachments/watermark/pagination untouched. Lenient parse_fields (unknown → full, forward-compatible).
  • device-id.ts — mint + persist a per-install UUID, sent as X-Device-Id.
  • cursor.ts — opaque cursor codec byte-matched to Engram.Sync.encode_cursor/2 + per-vault localStorage.
  • cursor-sync.ts — bootstrap a fresh device's cursor from the manifest change_seq; pull fields=meta deltas; single-flight per vault; reseed on a stale-cursor 400/410 (HISTORY_EXPIRED dormant until PR D); console.warn instead of silently truncating at the page cap.
  • Triggers — pull on mount, window focus, and socket reconnect (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

  • Backend: 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)
  • Frontend: 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) green
  • Manual real-browser check over the laptop CDP tunnel: confirm X-Device-Id header + engram.syncCursor.<vault> seeded, an edit from a second client propagates to the web on refocus without reload, and /sync/changes rows carry content: null

Note: full frontend suite has 2 pre-existing AbortError teardown errors in viewer/pdf tests, unrelated to this PR (missing local dev deps; CI installs fresh).

🤖 Generated with Claude Code

Rasbandit and others added 12 commits June 17, 2026 00:35
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>
@Rasbandit

Copy link
Copy Markdown
Member Author

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 (local === remote always agrees). Pure-frontend, covered by a new merge.test.ts case; not related to the cursor-sync change.

…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.
@Rasbandit Rasbandit merged commit 3e4c0bf into main Jun 17, 2026
44 of 47 checks passed
@Rasbandit Rasbandit deleted the feat/sync-cursor-pull-b3-web branch June 17, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant