Files
gitmost/apps/server/src/ws/ws-tree.service.ts
claude code agent 227 d181b5c4ff 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>
2026-06-27 00:58:40 +03:00

172 lines
6.9 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WsService } from './ws.service';
import {
PageMovedEvent,
TreeNodeSnapshot,
TreeUpdateSnapshot,
toTreeNodeSnapshot,
} from '../database/listeners/page.listener';
@Injectable()
export class WsTreeService {
constructor(
private readonly wsService: WsService,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
// Server-origin tree broadcasts. Built from thin node snapshots carried in the
// domain events (variant A) so no DB read happens here — this avoids the
// in-transaction visibility race. Payload shapes mirror what the client
// receiver (`use-tree-socket.ts`) consumes.
async broadcastPageCreated(page: TreeNodeSnapshot): Promise<void> {
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
operation: 'addTreeNode',
spaceId: page.spaceId,
payload: {
parentId: page.parentPageId ?? null,
// 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: {
...toTreeNodeSnapshot(page),
name: page.title ?? '',
hasChildren: false,
children: [],
},
},
});
}
// Rename / icon change: patch the in-tree node's title/icon on every client in
// the space. Routed through the restriction-aware `emitTreeEvent` so a
// restricted page's new title/icon never leaks to sockets that can't see it.
// The payload mirrors the client `UpdateEvent` shape consumed by
// `applyUpdateOne` (entity ["pages"], `id`, `payload.title` / `payload.icon`);
// only the fields that actually changed are sent (the snapshot omits the rest).
async broadcastPageUpdated(node: TreeUpdateSnapshot): Promise<void> {
await this.wsService.emitTreeEvent(node.spaceId, node.id, {
operation: 'updateOne',
spaceId: node.spaceId,
entity: ['pages'],
id: node.id,
payload: {
slugId: node.slugId,
parentPageId: node.parentPageId,
// Only include changed fields; an absent field leaves the client node
// untouched (applyUpdateOne checks `!== undefined` per field).
...(node.title !== undefined ? { title: node.title } : {}),
...(node.icon !== undefined ? { icon: node.icon } : {}),
},
});
}
async broadcastPageDeleted(page: TreeNodeSnapshot): Promise<void> {
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
operation: 'deleteTreeNode',
spaceId: page.spaceId,
payload: {
node: {
id: page.id,
slugId: page.slugId,
parentPageId: page.parentPageId ?? null,
},
},
});
}
async broadcastPageMoved(event: PageMovedEvent): Promise<void> {
const { node } = event;
const movePayload = {
operation: 'moveTreeNode',
spaceId: node.spaceId,
payload: {
id: node.id,
parentId: node.parentPageId ?? null,
oldParentId: event.oldParentId ?? null,
// See broadcastPageCreated: receivers place by `position`, not index.
index: 0,
position: node.position,
pageData: {
id: node.id,
slugId: node.slugId,
title: node.title,
icon: node.icon,
position: node.position,
spaceId: node.spaceId,
parentPageId: node.parentPageId ?? null,
hasChildren: event.hasChildren,
},
},
};
// Decide the node's restricted state ONCE, fresh (uncached), and drive BOTH
// the move broadcast and the compensating delete from this single decision.
//
// Why not just emitTreeEvent for the move? emitTreeEvent gates the move on
// the CACHED spaceHasRestrictions (30s TTL, never invalidated). In the window
// right after a space gets its FIRST restriction, that cache still says
// "no restrictions" → emitTreeEvent would fan the move out to the WHOLE room
// (including unauthorized users) while the delete below (computed from the
// UNCACHED hasRestrictedAncestor) also fires. An unauthorized user then gets
// BOTH, and if the delete lands first it is a no-op and the later move
// renders the restricted node → leak. So when the node is known-restricted we
// must NOT route the move through the cache-gated path.
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
node.id,
);
if (!isRestricted) {
// Normal case: not under a restricted ancestor. One moveTreeNode to the
// whole space room (emitTreeEvent's open-space fast path), no delete.
await this.wsService.emitTreeEvent(node.spaceId, node.id, movePayload);
return;
}
// Restricted case: a move can push a previously-visible page UNDER a
// restricted ancestor. The move (to authorized users) and the compensating
// delete (to everyone else) are now driven from ONE socket/access snapshot:
// emitMoveWithRestrictionSplit performs a single fetchSockets + a single
// getUserIdsWithPageAccess and partitions the room from that one snapshot.
// This eliminates the race window that existed when the move and the delete
// each resolved the audience independently — a socket could otherwise have
// landed in both sets (leaking the restricted node) or in neither (losing the
// compensating delete). Authorized users get exactly the moveTreeNode,
// everyone else (unauthorized + anonymous) gets exactly the deleteTreeNode.
//
// Users who LOSE visibility need the delete because otherwise the node would
// linger in their tree at its old parent with its real title/slugId/icon
// (existence + metadata leak).
await this.wsService.emitMoveWithRestrictionSplit(node.spaceId, node.id, movePayload, {
operation: 'deleteTreeNode',
spaceId: node.spaceId,
payload: {
node: {
id: node.id,
slugId: node.slugId,
parentPageId: event.oldParentId ?? null,
},
},
});
}
// Used for restore (and other subtree re-attachments): rather than emitting N
// pointwise addTreeNode events, ask clients in the space to refetch the root
// tree. The client already understands `refetchRootTreeNodeEvent`.
async broadcastRefetchRoot(spaceId: string): Promise<void> {
this.wsService.emitToSpaceRoom(spaceId, {
operation: 'refetchRootTreeNodeEvent',
spaceId,
});
}
}