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

View File

@@ -173,9 +173,23 @@ export class PageRepo {
.returning(this.baseFields)
.executeTakeFirst();
// Enrich the event with a thin node snapshot (variant A) so the WS tree
// listener can broadcast `addTreeNode` without re-reading the DB. `result`
// already comes from `returning(this.baseFields)`, so no extra query.
this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: [result.id],
workspaceId: result.workspaceId,
pages: [
{
id: result.id,
slugId: result.slugId,
title: result.title,
icon: result.icon,
position: result.position,
spaceId: result.spaceId,
parentPageId: result.parentPageId,
},
],
});
return result;
@@ -266,6 +280,25 @@ export class PageRepo {
): Promise<void> {
const currentDate = new Date();
// Read the root snapshot up front so PAGE_SOFT_DELETED can carry it without
// a post-commit DB read (variant A). Only the root of the deleted subtree is
// needed for the tree broadcast — the client `treeModel.remove` drops all
// descendants, so we don't snapshot/broadcast every descendant.
const rootSnapshot = await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'spaceId',
'parentPageId',
])
.where('id', '=', pageId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
@@ -305,6 +338,21 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, {
pageIds: pageIds,
workspaceId,
// Root-only snapshot: one `deleteTreeNode` is enough, the client removes
// the whole subtree. Skip if the root vanished between the two reads.
pages: rootSnapshot
? [
{
id: rootSnapshot.id,
slugId: rootSnapshot.slugId,
title: rootSnapshot.title,
icon: rootSnapshot.icon,
position: rootSnapshot.position,
spaceId: rootSnapshot.spaceId,
parentPageId: rootSnapshot.parentPageId,
},
]
: [],
});
}
}
@@ -313,7 +361,7 @@ export class PageRepo {
// First, check if the page being restored has a deleted parent
const pageToRestore = await this.db
.selectFrom('pages')
.select(['id', 'parentPageId'])
.select(['id', 'parentPageId', 'spaceId'])
.where('id', '=', pageId)
.executeTakeFirst();
@@ -372,6 +420,10 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_RESTORED, {
pageIds: pageIds,
workspaceId: workspaceId,
// spaceId lets the WS listener send a space-scoped refetchRootTreeNodeEvent.
// Restore can re-attach a whole subtree, so a root refetch is simpler and
// more robust than N pointwise addTreeNode events.
spaceId: pageToRestore.spaceId,
});
}