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:
claude code agent 227
2026-06-27 00:58:40 +03:00
parent 12ff76fb89
commit d181b5c4ff
10 changed files with 289 additions and 72 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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: [],
},
},