feat(tree): server-authoritative realtime tree updates

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>
This commit is contained in:
claude code agent 227
2026-06-20 08:27:56 +03:00
parent c8af637654
commit 046132afc7
14 changed files with 740 additions and 46 deletions
+5 -1
View File
@@ -724,7 +724,11 @@ export class PageController {
@AuthUser() user: User,
@AuthProvenance() provenance: AuthProvenanceData,
) {
const movedPage = await this.pageRepo.findById(dto.pageId);
// includeHasChildren so movePage's PAGE_MOVED snapshot carries an accurate
// hasChildren — receivers need it to keep the moved node's chevron correct.
const movedPage = await this.pageRepo.findById(dto.pageId, {
includeHasChildren: true,
});
if (!movedPage) {
throw new NotFoundException('Moved page not found');
}
@@ -887,6 +887,35 @@ export class PageService {
},
dto.pageId,
);
// The generic PAGE_UPDATED emitted by updatePage above is intentionally NOT
// used to drive the tree `moveTreeNode` broadcast: it also fires on rename /
// content-save and carries neither oldParentId nor the new position. Emit a
// dedicated PAGE_MOVED so the WS listener can build a precise moveTreeNode
// without a DB read (variant A: snapshot in the event).
//
// `parentPageId` is `undefined` when only the position changed (same
// parent); resolve it back to the page's actual parent for the snapshot.
const newParentPageId =
parentPageId === undefined ? movedPage.parentPageId : parentPageId;
this.eventEmitter.emit(EventName.PAGE_MOVED, {
workspaceId: movedPage.workspaceId,
oldParentId: movedPage.parentPageId ?? null,
// `hasChildren` is selected by findById({ includeHasChildren: true }) in
// the controller; it isn't on the base Page type, hence the cast.
hasChildren:
(movedPage as Page & { hasChildren?: boolean }).hasChildren ?? false,
node: {
id: movedPage.id,
slugId: movedPage.slugId,
title: movedPage.title,
icon: movedPage.icon,
position: dto.position,
spaceId: movedPage.spaceId,
parentPageId: newParentPageId ?? null,
},
});
}
async getPageBreadCrumbs(childPageId: string) {