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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user