test(temporary-notes): cover the create race-guard, broadcast deadline + cache patch; unify page->tree-node mappers
Address review comment 2159 on the temporary-notes UI work. Tests: - tree-model: cover handleCreate's race-guard temporaryExpiresAt patch — (a) server node inserted WITHOUT a deadline + create response carries one => node gains the deadline; (b) node already has a deadline => not overwritten, prev returned by reference. - ws-tree.service.spec: broadcastPageCreated now asserts the deadline is carried when present and pinned to null (`?? null`) when absent. - page-embed-query (new spec): syncTemporaryExpiresInCache patches the in-tree node's temporaryExpiresAt, and leaves the atom value at the same reference when the id is absent from the loaded tree (no write). Refactor (closes the drift bug-class at the root): - Client: extract one canonical pageToTreeNode(page, overrides) mapper in tree/utils and route buildTree, handleCreate's optimistic insert, the restore mutation and the duplicate handler through it. Restore stays permanent (server nulls temporaryExpiresAt) and duplicate stays permanent (server arms no timer) — both now reflect the server without a reload, where before they dropped the field entirely. - Server: extract one toTreeNodeSnapshot(page) helper called by both the PAGE_CREATED event enrichment (page.repo) and the addTreeNode broadcast (ws-tree.service), so the optional temporaryExpiresAt can't drift between the two literals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,36 @@ export interface TreeNodeSnapshot {
|
||||
temporaryExpiresAt?: Date | string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single canonical builder for a `TreeNodeSnapshot` from a page-like row. Both
|
||||
* the `PAGE_CREATED` event enrichment (`page.repo.insertPage`) and the
|
||||
* `addTreeNode` broadcast (`WsTreeService.broadcastPageCreated`) build this same
|
||||
* snapshot; routing both through here keeps the optional `temporaryExpiresAt`
|
||||
* (and the `?? null` normalisation that pins a permanent note to an explicit
|
||||
* null) from silently drifting between the two literals.
|
||||
*/
|
||||
export function toTreeNodeSnapshot(page: {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
position: string;
|
||||
spaceId: string;
|
||||
parentPageId: string | null;
|
||||
temporaryExpiresAt?: Date | string | null;
|
||||
}): TreeNodeSnapshot {
|
||||
return {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
temporaryExpiresAt: page.temporaryExpiresAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class PageEvent {
|
||||
pageIds: string[];
|
||||
workspaceId: string;
|
||||
|
||||
@@ -16,7 +16,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import { TreeUpdateSnapshot } from '../../listeners/page.listener';
|
||||
import {
|
||||
TreeUpdateSnapshot,
|
||||
toTreeNodeSnapshot,
|
||||
} from '../../listeners/page.listener';
|
||||
|
||||
/**
|
||||
* Optional extras for the PAGE_UPDATED event emitted by updatePage(s). Lets the
|
||||
@@ -200,20 +203,10 @@ export class PageRepo {
|
||||
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,
|
||||
// Carry the death-timer deadline so a note created as temporary shows
|
||||
// its sidebar clock marker on every client without a reload.
|
||||
temporaryExpiresAt: result.temporaryExpiresAt,
|
||||
},
|
||||
],
|
||||
// Built via the shared snapshot helper so the field copy (and the
|
||||
// death-timer deadline that shows the sidebar clock marker without a
|
||||
// reload) can't drift from the `addTreeNode` broadcast literal.
|
||||
pages: [toTreeNodeSnapshot(result)],
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -83,6 +83,27 @@ describe('WsTreeService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('broadcastPageCreated carries temporaryExpiresAt when the page is a temporary note', async () => {
|
||||
const expiresAt = new Date('2026-07-01T00:00:00.000Z');
|
||||
await service.broadcastPageCreated({ ...snapshot, temporaryExpiresAt: expiresAt });
|
||||
|
||||
const data =
|
||||
wsService.emitTreeEvent.mock.calls[0][2].payload.data;
|
||||
// The death-timer deadline reaches receivers so the clock marker renders
|
||||
// immediately (incl. the author if this broadcast wins the optimistic race).
|
||||
expect(data.temporaryExpiresAt).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('broadcastPageCreated pins temporaryExpiresAt to null for a permanent page', async () => {
|
||||
// Fixture omits temporaryExpiresAt; the `?? null` must send an explicit null
|
||||
// (permanent) rather than undefined, so receivers clear any stale marker.
|
||||
await service.broadcastPageCreated(snapshot);
|
||||
|
||||
const data =
|
||||
wsService.emitTreeEvent.mock.calls[0][2].payload.data;
|
||||
expect(data.temporaryExpiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it('broadcastPageDeleted emits deleteTreeNode with the root node only', async () => {
|
||||
await service.broadcastPageDeleted({
|
||||
...snapshot,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PageMovedEvent,
|
||||
TreeNodeSnapshot,
|
||||
TreeUpdateSnapshot,
|
||||
toTreeNodeSnapshot,
|
||||
} from '../database/listeners/page.listener';
|
||||
|
||||
@Injectable()
|
||||
@@ -28,20 +29,17 @@ export class WsTreeService {
|
||||
// Receivers place by `position` among already-loaded siblings, not by
|
||||
// this absolute index (sender's loaded set differs from receivers').
|
||||
index: 0,
|
||||
// Built via the shared snapshot helper (same one page.repo uses to fill
|
||||
// the event), then extended with the tree-only fields the client
|
||||
// receiver consumes. The helper carries the death-timer deadline
|
||||
// (normalised to null => permanent) so receivers — and the author, if
|
||||
// this broadcast wins the race against the optimistic insert — render
|
||||
// the temporary-note clock marker immediately, without it drifting from
|
||||
// the event literal.
|
||||
data: {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
...toTreeNodeSnapshot(page),
|
||||
name: page.title ?? '',
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
hasChildren: false,
|
||||
// Carry the death-timer deadline so receivers (and the author, if this
|
||||
// broadcast wins the race against the optimistic insert) render the
|
||||
// temporary-note clock marker immediately. null => permanent.
|
||||
temporaryExpiresAt: page.temporaryExpiresAt ?? null,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user