feat(tree): server-authoritative realtime tree updates #15

Merged
Ghost merged 5 commits from feat/realtime-tree-server into develop 2026-06-20 19:48:36 +03:00

Implements docs/backlog/realtime-tree-server-authoritative.md.

What

The sidebar page tree only updated on other clients when a change was made via the UI tree, in an open tab, within a ~50ms client-relay window. Any other path — REST API, the AI agent, MCP, import, duplicate, background ops — never propagated. This moves the source of truth for tree events to the server, so every client in a space sees create / move / delete / restore in real time, however the change was made.

How

Server (variant A — enrich domain events with node snapshots):

  • PageEvent carries thin TreeNodeSnapshots so the WS listener does zero DB reads (this is deliberate — insertPage/removePage emit while sometimes still inside an uncommitted trx, so a synchronous re-fetch would hit the visibility race). insertPage builds the create snapshot from its existing returning() row; removePage ships only the deleted subtree root (the client's treeModel.remove drops descendants — no per-descendant spam); restorePage carries spaceId.
  • New PAGE_MOVED event from movePage (old parent + new parent + position + snapshot); the generic PAGE_UPDATED stays for content/rename.
  • WsService.emitTreeEvent mirrors emitCommentEvent exactly for access control (spaceHasRestrictionshasRestrictedAncestorbroadcastToAuthorizedUsers), so restricted pages don't leak. The author is not excluded (the receiver is idempotent, and non-UI creators must see their own page).
  • WsTreeService.broadcastPageCreated/Deleted/Moved + broadcastRefetchRoot; new PageWsListener (create/delete/move/restore) registered in WsModule.

Client:

  • Removed the client relay (emit(...) + setTimeout(50)) from create/move/delete; kept the optimistic local updates (instant author feedback).
  • addTreeNode inserts by fractional position among loaded siblings (consistent order across clients with different loaded sets) and stays id-idempotent.

Reasoning / decisions

  • Restore uses refetchRootTreeNodeEvent to the space room (a restore can re-attach a whole subtree; N pointwise events are fragile by ordering) — robust over clever.
  • Deferred (commented as follow-ups, per the plan's staging to keep the base focused): rename/icon updateOne on PAGE_UPDATED (needs a title/icon diff to avoid firing on every content save) and cross-space movePageToSpace realtime.

Review findings & fixes

A 2-client browser test caught a duplicate-node regression: with the relay gone and the server broadcast now arriving in ~4ms, it raced the author's un-deduped optimistic insert — the author's own create rendered twice (deterministic). Fixed by making the optimistic insert in handleCreate id-idempotent (find-then-skip), mirroring the socket handler's guard; the optimistic node id equals the real created page id, so the guard matches in both race orderings. handleMove (remove-then-place) and handleDelete (remove-absent = no-op) were confirmed dup-safe.

Verification

  • pnpm --filter server build + pnpm --filter client build — clean.
  • Server: ws-tree.service.spec (payload shapes for create/delete/move/refetch + emitTreeEvent open vs restricted branch). Client: tree-model (position-ordered insert + addTreeNode/optimistic-insert idempotency, both race orderings) — 69 pass.
  • Browser (headless Chromium, two clients in one space): UI create/delete propagate to the other client without reload; a REST POST /api/pages/create (no UI relay) propagates to an open client — the headline fix; move reflected. Post-fix re-run: 3 UI creates each render exactly one row in the author's tree (race-sampled), propagation intact, no duplicates, no app errors. Screenshots captured.

🤖 Generated with Claude Code

Implements `docs/backlog/realtime-tree-server-authoritative.md`. ## What The sidebar page tree only updated on other clients when a change was made **via the UI tree, in an open tab, within a ~50ms client-relay window**. Any other path — REST API, the AI agent, MCP, import, duplicate, background ops — never propagated. This moves the source of truth for tree events to the **server**, so every client in a space sees create / move / delete / restore in real time, however the change was made. ## How **Server (variant A — enrich domain events with node snapshots):** - `PageEvent` carries thin `TreeNodeSnapshot`s so the WS listener does **zero DB reads** (this is deliberate — `insertPage`/`removePage` emit while sometimes still inside an uncommitted `trx`, so a synchronous re-fetch would hit the visibility race). `insertPage` builds the create snapshot from its existing `returning()` row; `removePage` ships only the deleted subtree **root** (the client's `treeModel.remove` drops descendants — no per-descendant spam); `restorePage` carries `spaceId`. - New `PAGE_MOVED` event from `movePage` (old parent + new parent + position + snapshot); the generic `PAGE_UPDATED` stays for content/rename. - `WsService.emitTreeEvent` mirrors `emitCommentEvent` exactly for access control (`spaceHasRestrictions` → `hasRestrictedAncestor` → `broadcastToAuthorizedUsers`), so restricted pages don't leak. The author is **not** excluded (the receiver is idempotent, and non-UI creators must see their own page). - `WsTreeService.broadcastPageCreated/Deleted/Moved` + `broadcastRefetchRoot`; new `PageWsListener` (create/delete/move/restore) registered in `WsModule`. **Client:** - Removed the client relay (`emit(...)` + `setTimeout(50)`) from create/move/delete; kept the optimistic local updates (instant author feedback). - `addTreeNode` inserts by fractional `position` among loaded siblings (consistent order across clients with different loaded sets) and stays id-idempotent. ## Reasoning / decisions - **Restore** uses `refetchRootTreeNodeEvent` to the space room (a restore can re-attach a whole subtree; N pointwise events are fragile by ordering) — robust over clever. - **Deferred** (commented as follow-ups, per the plan's staging to keep the base focused): rename/icon `updateOne` on `PAGE_UPDATED` (needs a title/icon diff to avoid firing on every content save) and cross-space `movePageToSpace` realtime. ## Review findings & fixes A 2-client browser test caught a **duplicate-node regression**: with the relay gone and the server broadcast now arriving in ~4ms, it raced the author's **un-deduped** optimistic insert — the author's own create rendered twice (deterministic). Fixed by making the optimistic insert in `handleCreate` id-idempotent (`find`-then-skip), mirroring the socket handler's guard; the optimistic node id equals the real created page id, so the guard matches in both race orderings. `handleMove` (remove-then-place) and `handleDelete` (remove-absent = no-op) were confirmed dup-safe. ## Verification - `pnpm --filter server build` + `pnpm --filter client build` — clean. - Server: `ws-tree.service.spec` (payload shapes for create/delete/move/refetch + `emitTreeEvent` open vs restricted branch). Client: `tree-model` (position-ordered insert + `addTreeNode`/optimistic-insert idempotency, both race orderings) — **69 pass**. - Browser (headless Chromium, **two clients** in one space): UI create/delete propagate to the other client without reload; a **REST `POST /api/pages/create` (no UI relay) propagates** to an open client — the headline fix; move reflected. Post-fix re-run: 3 UI creates each render exactly **one** row in the author's tree (race-sampled), propagation intact, no duplicates, no app errors. Screenshots captured. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 2 commits 2026-06-20 08:28:29 +03:00
The sidebar page tree only updated on other clients when a change was made
via the UI tree, in an open tab, within a ~50ms client relay window — API/MCP/
AI/import changes never propagated. Move the source of truth to the server.

Server:
- Enrich PageEvent with thin TreeNodeSnapshot(s) so the WS listener never reads
  the DB (avoids the in-transaction visibility race). insertPage fills the
  create snapshot from its returning() row; removePage ships only the deleted
  subtree ROOT (client treeModel.remove drops descendants); restorePage carries
  spaceId.
- New PAGE_MOVED event from movePage with old/new parent + position + snapshot
  (generic PAGE_UPDATED stays for content/rename).
- WsService.emitTreeEvent mirrors emitCommentEvent (per-space restriction gate:
  spaceHasRestrictions -> hasRestrictedAncestor -> broadcastToAuthorizedUsers);
  author NOT excluded so non-UI creators see their own page (receiver is
  idempotent).
- WsTreeService.broadcastPageCreated/Deleted/Moved + broadcastRefetchRoot;
  new PageWsListener (create/delete/move/restore) registered in WsModule.

Client:
- Remove the client relay (emit + setTimeout(50)) from create/move/delete;
  keep optimistic local updates. Make the optimistic create insert id-idempotent
  (find-then-skip) so the now-fast server addTreeNode broadcast can't race it
  into a duplicate row. addTreeNode inserts by fractional position among loaded
  siblings (consistent order across clients).

Restore uses refetchRootTreeNodeEvent (robust for subtree re-attach). Rename/icon
updateOne and cross-space move realtime are deferred (commented as follow-ups).

Implements docs/backlog/realtime-tree-server-authoritative.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 14:01:48 +03:00
Release-cycle review found two move-path issues:
- Remote moves were placed at index:0 (broadcastPageMoved hardcodes index:0),
  so every observer rendered the moved node at the TOP of its new siblings
  until refetch. Client moveTreeNode now places by fractional position
  (treeModel.placeByPosition, mirroring addTreeNode/insertByPosition) and
  applies the payload's pageData (title->name, icon, hasChildren) so receivers
  keep the node correct.
- Moving a page under a restricted ancestor left a stale named node (title/
  slugId/icon) in the trees of users who lost visibility. broadcastPageMoved
  now derives one FRESH hasRestrictedAncestor decision and drives both paths
  from it: when restricted, the move goes to authorized users only
  (emitToAuthorizedUsers, not the space-cache-gated emitTreeEvent) and a
  compensating deleteTreeNode goes to the unauthorized complement (same fresh
  getUserIdsWithPageAccess set) — disjoint, no stale-cache window. Non-restricted
  moves are unchanged (one moveTreeNode to the room).

Follow-up (noted): invalidateSpaceRestrictionCache is still unwired at
permission-mutation sites; the open-space fast path can lag up to the 30s TTL,
but the move/delete consistency above no longer depends on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 15:51:05 +03:00
Release-cycle audit flagged WsService.invalidateSpaceRestrictionCache and
WsTreeService.notifyPageRestricted/notifyPermissionGranted as never-wired dead
code. Investigation: this community fork has NO page-permission grant/revoke/
restrict mutation site (the page-access repo mutators have zero callers — that
flow is EE / not yet built), so there is nothing to wire them into.
- Keep invalidateSpaceRestrictionCache (it's the one-line correctness primitive
  the future permission-mutation path must call to avoid the 30s stale-cache
  window) but document exactly that + add a test that it deletes only the
  space-scoped cache key.
- Remove the untested, security-adjacent dead methods notifyPageRestricted /
  notifyPermissionGranted and their now-orphaned helpers emitToUsers /
  emitToSpaceExceptUsers (no remaining references; build confirms). A future
  permission-change realtime feature can reintroduce them wired + tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-20 19:48:17 +03:00
- make addTreeNode receivers idempotent (invalidateOnCreatePage guard +
  buildTree dedup) so the author's self-echo no longer duplicates the node
- broadcast realtime tree updates for bulk copy/duplicate and import via a
  root refetch: PAGE_CREATED now carries spaceId and the WS listener falls
  back to refetchRootTreeNodeEvent when no per-node snapshot is present
- remove the now-dead client-relay inbound path (isTreeEvent/handleTreeEvent)
  that remained a stale-restriction-cache attack surface
- honest string|null cast for a root move's parent id
- add tests: buildTree dedup; onPageCreated per-node vs refetch branching

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost merged commit 46688074d8 into develop 2026-06-20 19:48:36 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#15