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:
@@ -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()
|
||||
|
||||
@@ -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