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>
172 lines
6.9 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|