feat(tree): server-authoritative realtime tree updates #15
Reference in New Issue
Block a user
Delete Branch "feat/realtime-tree-server"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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):
PageEventcarries thinTreeNodeSnapshots so the WS listener does zero DB reads (this is deliberate —insertPage/removePageemit while sometimes still inside an uncommittedtrx, so a synchronous re-fetch would hit the visibility race).insertPagebuilds the create snapshot from its existingreturning()row;removePageships only the deleted subtree root (the client'streeModel.removedrops descendants — no per-descendant spam);restorePagecarriesspaceId.PAGE_MOVEDevent frommovePage(old parent + new parent + position + snapshot); the genericPAGE_UPDATEDstays for content/rename.WsService.emitTreeEventmirrorsemitCommentEventexactly 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; newPageWsListener(create/delete/move/restore) registered inWsModule.Client:
emit(...)+setTimeout(50)) from create/move/delete; kept the optimistic local updates (instant author feedback).addTreeNodeinserts by fractionalpositionamong loaded siblings (consistent order across clients with different loaded sets) and stays id-idempotent.Reasoning / decisions
refetchRootTreeNodeEventto the space room (a restore can re-attach a whole subtree; N pointwise events are fragile by ordering) — robust over clever.updateOneonPAGE_UPDATED(needs a title/icon diff to avoid firing on every content save) and cross-spacemovePageToSpacerealtime.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
handleCreateid-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) andhandleDelete(remove-absent = no-op) were confirmed dup-safe.Verification
pnpm --filter server build+pnpm --filter client build— clean.ws-tree.service.spec(payload shapes for create/delete/move/refetch +emitTreeEventopen vs restricted branch). Client:tree-model(position-ordered insert +addTreeNode/optimistic-insert idempotency, both race orderings) — 69 pass.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