Merge pull request 'feat(tree): server-authoritative realtime tree updates' (#15) from feat/realtime-tree-server into develop

This commit was merged in pull request #15.
This commit is contained in:
claude_code
2026-06-20 19:48:36 +03:00
22 changed files with 1413 additions and 534 deletions

View File

@@ -6,9 +6,46 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
/**
* Thin snapshot of a page node carried inside domain events so the WebSocket
* tree listener can broadcast a tree update WITHOUT reading the DB. This is
* "variant A" of the realtime-tree design: enriching the event avoids the
* in-transaction visibility race where a separate SELECT in the listener could
* run before the emitting `trx` has committed and therefore not see the row.
*/
export interface TreeNodeSnapshot {
id: string;
slugId: string;
title: string | null;
icon: string | null;
position: string;
spaceId: string;
parentPageId: string | null;
}
export class PageEvent {
pageIds: string[];
workspaceId: string;
// Optional tree snapshots so the WS listener can broadcast without a DB read
// (avoids the in-transaction visibility race on PAGE_CREATED /
// PAGE_SOFT_DELETED / PAGE_DELETED). The existing search/AI listeners ignore
// this field — they only enqueue work keyed by pageIds.
pages?: TreeNodeSnapshot[];
// Set on PAGE_RESTORED so the WS listener can scope a refetchRootTreeNodeEvent
// to the affected space (restore can re-attach a whole subtree).
spaceId?: string;
}
/**
* Emitted by `PageService.movePage` after a successful re-parent / reorder.
* Carries both the old and new parent plus the new position so the WS listener
* can build a `moveTreeNode` broadcast without a DB read.
*/
export class PageMovedEvent {
workspaceId: string;
oldParentId: string | null;
node: TreeNodeSnapshot;
hasChildren: boolean;
}
@Injectable()

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,
});
}