docs(backlog): design server-authoritative realtime tree updates

Add a backlog design note for making page-tree realtime updates
server-authoritative instead of client-relayed.

Problem: page content syncs via Yjs/Hocuspocus (server-authoritative),
but tree create/move/delete is broadcast by the originating browser only,
so non-UI creation paths (AI agent, MCP, REST API, import) and lost-event
races leave other clients' sidebars stale.

The note specifies a WsService.emitTreeEvent broadcaster, WsTreeService
broadcast helpers, a PageWsListener on PAGE_CREATED/SOFT_DELETED/DELETED/
MOVED/RESTORED, event-payload enrichment to avoid the in-transaction
re-fetch race, a dedicated PAGE_MOVED event, removal of the client relay,
plus edge cases, work breakdown, tests, alternatives and open questions.
This commit is contained in:
vvzvlad
2026-06-18 20:34:18 +03:00
parent 1e7a306f96
commit 850d9ada22

View File

@@ -0,0 +1,387 @@
# Realtime-дерево: сделать обновления сервер-авторитетными (как контент)
## Контекст (проблема)
Контент страницы синхронизируется между пользователями в реальном времени всегда,
а **дерево страниц в сайдбаре не обновляется**, когда кто-то создаёт / перемещает /
удаляет страницу — у других участников спейса (а часто и у самого автора в соседней
вкладке) дерево «застывает» до ручного refetch (перезагрузка страницы или
переключение спейса).
Причина — в том, что это два разных realtime-канала с разной «авторитетностью»:
- **Контент — сервер-авторитетный (Yjs / Hocuspocus).** Любое изменение текста
проходит через collab-сервер (`apps/server/src/collaboration/`) и раздаётся всем
подписчикам документа независимо от того, кто и каким способом редактировал.
- **Дерево — ретрансляция, инициируемая клиентом.** Броадкаст изменения дерева
делает **браузер автора**, а не сервер. Сервер только пересылает уже готовое
сообщение другим клиентам и **сам по событиям жизненного цикла страницы ничего
не вещает**.
Поэтому дерево обновляется у других **только если** страница создана через UI-дерево,
в открытой вкладке, при живом сокете, и вкладка не закрылась/не сменила URL в течение
~50 мс после действия. **Любой другой путь создания/изменения страницы броадкаста не
даёт вообще:** AI-агент (`core/ai-chat/tools/`), встроенный MCP `/mcp` и standalone
`@docmost/mcp`, REST API напрямую, импорт markdown/zip, копирование/дублирование
страницы, фоновые серверные операции.
Цель фичи: **перенести источник истины tree-событий на сервер** — чтобы дерево
обновлялось у всех в спейсе при любом способе изменения, надёжно, по аналогии с
контентом.
## Как сейчас устроено (цепочка)
### Клиентский relay (единственный текущий источник tree-событий)
- `apps/client/src/features/page/tree/hooks/use-tree-mutation.ts`
- `handleCreate` (строки ~133-191): после успешного `createPageMutation` делает
оптимистичную вставку в `treeDataAtom`, затем через `setTimeout(50)`
`emit({ operation: "addTreeNode", spaceId, payload: { parentId, index, data } })`.
- `handleMove` (~46-131): оптимистично двигает узел, затем `emit("moveTreeNode", …)`.
- `handleDelete` (~207-254): удаляет узел, затем `emit("deleteTreeNode", …)`.
- `handleRename` (~193-205): оптимистично меняет имя, **emit НЕ делает**.
- `apps/client/src/features/websocket/use-query-emit.ts`: `emit` — это просто
`socket?.emit("message", input)`.
### Сервер — только пересылка
- `apps/server/src/ws/ws.gateway.ts` (`@SubscribeMessage('message')`, ~64-69):
если `wsService.isTreeEvent(data)` — отдаёт в `wsService.handleTreeEvent`.
- `apps/server/src/ws/ws.service.ts` `handleTreeEvent` (~27-58):
`client.broadcast.to(getSpaceRoomName(spaceId)).emit('message', data)` — пересылка
пришедшего от клиента события в комнату спейса (с учётом ограничений доступа).
- `apps/server/src/database/listeners/page.listener.ts`: слушает `PAGE_CREATED` /
`PAGE_UPDATED` / `PAGE_DELETED` / `PAGE_SOFT_DELETED` / `PAGE_RESTORED`, но **только
ставит задачи в очереди (search / AI)** — WebSocket не трогает.
### Что уже есть для серверного броадкаста (но не используется)
- `apps/server/src/ws/ws-tree.service.ts``WsTreeService` с методами
`notifyPermissionGranted` (строит готовый payload `addTreeNode`) и
`notifyPageRestricted` (payload `deleteTreeNode`). **Нигде не вызывается** (мёртвый
код) — но это точный шаблон формата событий и доказательство, что инфраструктура
серверного броадкаста работоспособна.
- `WsService.emitCommentEvent(spaceId, pageId, data)` (~66-87) — образец
**серверного** броадкаста в комнату спейса с проверкой ограничений доступа
(`spaceHasRestrictions``hasRestrictedAncestor``broadcastToAuthorizedUsers`).
- `WsModule``@Global`, экспортирует `WsService` и `WsTreeService`.
### Приёмник на клиенте (переиспользуем как есть)
- `apps/client/src/features/websocket/use-tree-socket.ts` (`socket.on("message")`):
- `addTreeNode` (~55-74): вставляет узел; **идемпотентен**
`if (treeModel.find(prev, event.payload.data.id)) return prev;` (повторная
доставка того же id безопасна).
- `moveTreeNode` (~75-117), `deleteTreeNode` (~119-138), `updateOne` (~36-54).
- `apps/client/src/features/websocket/use-query-subscription.ts`: на те же события
синхронизирует кэш TanStack Query сайдбара (`invalidateOnCreatePage`,
`updateCacheOnMovePage`, `invalidateOnDeletePage`).
## Целевое поведение
При **любом** способе изменения структуры (UI, AI-агент, MCP, REST API, импорт,
копирование, фоновые операции) сервер сам рассылает соответствующее tree-событие всем
клиентам в комнате спейса (с учётом ограничений доступа), и у всех участников дерево
обновляется без ручного refetch:
- создание страницы → `addTreeNode`;
- перемещение/переупорядочивание → `moveTreeNode`;
- мягкое/жёсткое удаление → `deleteTreeNode`;
- восстановление из корзины → `addTreeNode` (или `refetchRootTreeNodeEvent`);
- (расширение) переименование / смена иконки → `updateOne`;
- (расширение) перенос между спейсами → `deleteTreeNode` в старом спейсе +
`addTreeNode` в новом.
## Решение (архитектура)
Перенести генерацию tree-событий на сервер и сделать его единственным источником
истины. Состоит из трёх частей: (1) серверный эмиттер, (2) обогащённые доменные
события, (3) удаление клиентского relay.
### 1. Серверный метод броадкаста tree-события
В `WsService` добавить метод по образцу `emitCommentEvent` — рассылка в комнату спейса
с учётом ограничений доступа. Не исключаем автора: повторная доставка безопасна
благодаря идемпотентности приёмника (см. edge cases).
```ts
// apps/server/src/ws/ws.service.ts
// Server-origin tree broadcast. Mirrors emitCommentEvent: respects per-space page
// restrictions, then fans the event out to everyone in the space room. The author
// is NOT excluded — the client receiver is idempotent (addTreeNode early-returns if
// the node id already exists), so the author's optimistic node is preserved and
// non-UI creators (MCP / AI / API) still see their own page appear.
async emitTreeEvent(spaceId: string, pageId: string, data: any): Promise<void> {
const room = getSpaceRoomName(spaceId);
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
return;
}
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!isRestricted) {
this.server.to(room).emit('message', data);
return;
}
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
}
```
`WsTreeService` расширить методами, которые строят payload и вызывают `emitTreeEvent`
(переиспользуя формат из существующих `notifyPermissionGranted`/`notifyPageRestricted`):
```ts
// apps/server/src/ws/ws-tree.service.ts
async broadcastPageCreated(page: TreeNodeData): Promise<void> {
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
operation: 'addTreeNode',
spaceId: page.spaceId,
payload: {
parentId: page.parentPageId ?? null,
// Receivers should place by `position`, not this index — see edge cases.
index: 0,
data: {
id: page.id, slugId: page.slugId,
name: page.title ?? '', title: page.title, icon: page.icon,
position: page.position, spaceId: page.spaceId,
parentPageId: page.parentPageId, hasChildren: false, children: [],
},
},
});
}
async broadcastPageDeleted(page: TreeNodeData): 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 } },
});
}
async broadcastPageMoved(p: MovedTreeNodeData): Promise<void> {
await this.wsService.emitTreeEvent(p.spaceId, p.id, {
operation: 'moveTreeNode',
spaceId: p.spaceId,
payload: {
id: p.id, parentId: p.parentPageId ?? null, oldParentId: p.oldParentId ?? null,
index: 0, position: p.position,
pageData: { id: p.id, slugId: p.slugId, title: p.title, icon: p.icon,
position: p.position, spaceId: p.spaceId, parentPageId: p.parentPageId,
hasChildren: p.hasChildren },
},
});
}
```
### 2. Источник событий: обогатить payload и/или эмитить из сервиса post-commit
Главная сложность — листенеру нужны поля, которых нет в `PageEvent`
(`{ pageIds, workspaceId }`), а дочитывание из БД по `pageId` гонится с транзакцией
(`insertPage`/`removePage` эмитят событие, иногда находясь внутри ещё не
закоммиченного `trx` — отдельный SELECT может не увидеть строку). Два варианта (см.
«Открытые вопросы», по умолчанию — **A**):
**Вариант A (рекомендуется): обогатить доменные события снимком узла.** Добавить в
payload событий тонкие поля дерева, чтобы листенер не читал БД:
```ts
// apps/server/src/database/listeners/page.listener.ts (PageEvent)
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).
pages?: TreeNodeSnapshot[]; // { id, slugId, title, icon, position, spaceId, parentPageId }
}
```
`insertPage` уже делает `returning(this.baseFields)` — снимок собирается из `result`
без доплат. `removePage` знает удаляемые `pageIds`; для `deleteTreeNode` достаточно
`{ id, slugId, parentPageId, spaceId }`, которые можно вернуть из того же `withRecursive`.
**Вариант B: эмитить tree-broadcast из сервиса после завершения операции (post-commit).**
Внедрить `WsTreeService` в `PageService` и вызывать `broadcastPage*` после успешного
`insertPage`/`removePage`/`movePage` (когда транзакция уже закоммичена и данные на
руках). Минус — размазывает realtime-логику по доменному сервису вместо одного
листенера.
### 3. Отдельное событие для перемещения
`movePage` сейчас эмитит общий `PAGE_UPDATED` — он непригоден: (а) не несёт
`oldParentId`/`position`, (б) срабатывает также на rename и сохранение контента (шум,
ложные `moveTreeNode`). Ввести выделенное событие:
```ts
// apps/server/src/common/events/event.contants.ts
PAGE_MOVED = 'page.moved',
```
`pageService.movePage()` знает старого родителя (читает страницу до апдейта), новый
`parentPageId` и новый `position` — эмитить `PAGE_MOVED` с полным снимком (вариант A)
после апдейта. Листенер вешает `@OnEvent(EventName.PAGE_MOVED)`
`wsTreeService.broadcastPageMoved(...)`.
### 4. Новый листенер в модуле ws
```ts
// apps/server/src/ws/listeners/page-ws.listener.ts
@Injectable()
export class PageWsListener {
constructor(private readonly wsTree: WsTreeService) {}
@OnEvent(EventName.PAGE_CREATED)
async onCreated(e: PageEvent) {
for (const p of e.pages ?? []) await this.wsTree.broadcastPageCreated(p);
}
@OnEvent(EventName.PAGE_SOFT_DELETED)
@OnEvent(EventName.PAGE_DELETED)
async onDeleted(e: PageEvent) {
for (const p of e.pages ?? []) await this.wsTree.broadcastPageDeleted(p);
}
@OnEvent(EventName.PAGE_MOVED)
async onMoved(e: PageMovedEvent) { await this.wsTree.broadcastPageMoved(e); }
@OnEvent(EventName.PAGE_RESTORED)
async onRestored(e: PageEvent) {
// Restore can re-attach a subtree; simplest correct option is a root refetch
// hint (see edge cases) instead of N addTreeNode events.
// await this.wsTree.broadcastRefetchRoot(spaceId);
}
}
```
Зарегистрировать `PageWsListener` в `WsModule.providers`. `WsTreeService` уже там;
`PageRepo` доступен из глобального `DatabaseModule` (если выберем вариант B/дочитывание).
### 5. Убрать клиентский relay (источник истины — только сервер)
После включения серверного броадкаста убрать `emit(...)` из
`use-tree-mutation.ts` (`handleCreate`/`handleMove`/`handleDelete`) и связанный
`setTimeout(50)`. Оптимистичные локальные обновления **оставить** (мгновенный отклик у
автора). Тогда на каждую операцию будет ровно один броадкаст (серверный), исчезает
гонка 50 мс и зависимость от того, успел ли браузер автора отправить событие.
> Безопасный порядок выката: серверный броадкаст можно включить, **не** удаляя relay
> сразу — приёмник идемпотентен, дубль `addTreeNode`/`deleteTreeNode` безвреден (второй
> — no-op). Это позволяет проверить серверный путь в изоляции, затем удалить relay
> отдельным коммитом. `moveTreeNode` при двойной доставке тоже идемпотентен по позиции.
## Тонкие моменты / edge cases
- **Гонка видимости транзакции.** Главная причина выбрать вариант A (снимок в
событии): `insertPage`/`removePage` эмитят событие, находясь иногда внутри
незакоммиченного `trx`; отдельный SELECT в листенере может не увидеть строку.
Существующие листенеры (search/AI) не страдают, т.к. лишь ставят отложенную задачу,
выполняемую после коммита. Синхронный re-fetch для броадкаста — нет.
- **Двойная вставка у автора.** Не исключаем автора из рассылки: приёмник `addTreeNode`
делает `if (treeModel.find(prev, id)) return prev` — у UI-автора оптимистичный узел
уже есть, серверное событие игнорируется (и не затирает редактируемое имя). У
non-UI автора (MCP/AI/API) узла нет — он его получит. Это и есть аргумент против
`emitToSpaceExceptUsers([creatorId])`: исключение автора сломало бы non-UI случай.
- **Порядок/позиция.** Сервер не знает локальный `index` каждого получателя (корневой
список пагинируется, у клиентов разный набор загруженных узлов). Поэтому в payload
кладём `position` (фракционный индекс — реальный порядок), а приёмник `addTreeNode`
стоит доработать так, чтобы вставлять **по `position`** среди уже загруженных
сиблингов, а не по абсолютному `index` отправителя. Сейчас `treeModel.insert`
принимает `index`; нужна вставка с сортировкой по `position` (или отдельный
`insertByPosition`). Без этого порядок у получателей может разойтись.
- **Пагинация корня → дубликаты.** Если новая корневая страница по `position` попадает
за пределы уже загруженного «окна» корневого инфинит-списка, прямая вставка в атом
может позже задвоиться при подгрузке следующей страницы. `use-query-subscription.ts`
уже инвалидирует кэш сайдбара на `addTreeNode` (`invalidateOnCreatePage`) — следить,
чтобы оба приёмника (`useTreeSocket` мутирует атом, `useQuerySubscription`
инвалидирует query) сходились к одному состоянию и не дублировали узлы.
- **Перенос между спейсами (`movePageToSpace`).** Сейчас эмитит `PAGE_MOVED_TO_SPACE`
**без листенера**. Корректный realtime: в **старом** спейсе — `deleteTreeNode`, в
**новом**`addTreeNode` (для всего перенесённого поддерева — вероятно проще
`refetchRootTreeNodeEvent` на оба спейса). Вынести в отдельный пункт объёма.
- **Восстановление из корзины (`PAGE_RESTORED`).** Может вернуть целое поддерево и
переприкрепить его к родителю. N точечных `addTreeNode` хрупки по порядку — проще
отправить `refetchRootTreeNodeEvent` (он уже поддержан и сервером-пересыльщиком, и
`use-query-subscription`), пусть клиенты перезапросят корень спейса.
- **Rename / иконка.** `handleRename` сейчас emit не делает, а `updateOne` хоть и
обрабатывается приёмником, серверно не рассылается → переименования тоже не
пропагируются. Естественное расширение этой же фичи: на `PAGE_UPDATED`, когда
изменились `title`/`icon`, слать `updateOne` (но фильтровать, чтобы не слать на
каждое сохранение контента). Вынесено в расширения, чтобы не раздувать базовый объём.
- **Каскадное мягкое удаление.** `removePage` удаляет всё поддерево и эмитит **все**
`pageIds` потомков. Для дерева достаточно одного `deleteTreeNode` по корню удаляемого
поддерева (клиент `treeModel.remove` убирает узел с детьми). Слать событие только по
корню удаления, а не по каждому потомку, иначе лишний трафик.
- **Ограничения доступа** наследуются бесплатно из `emitCommentEvent`-паттерна
(`spaceHasRestrictions``hasRestrictedAncestor``broadcastToAuthorizedUsers`):
закрытые страницы не утекут неавторизованным.
- **Мёртвый `WsTreeService`.** Его текущие `notifyPermissionGranted` /
`notifyPageRestricted` нигде не вызываются — заодно проверить, не должны ли они
вызываться при смене прав доступа на страницу (отдельный, но смежный баг realtime).
- **Идемпотентность move/delete.** `moveTreeNode` (place по позиции) и `deleteTreeNode`
(`if (!find) return prev`) тоже безопасны к повторной доставке — это позволяет
поэтапный выкат (п. 5).
- **Комментарии в коде — на английском** (правило проекта).
## Объём работ (файлы)
Сервер:
- [ ] `apps/server/src/common/events/event.contants.ts` — добавить `PAGE_MOVED`
(и при необходимости тип `PageMovedEvent`).
- [ ] `apps/server/src/database/listeners/page.listener.ts` — обогатить `PageEvent`
снимками узлов (вариант A); экспортировать общий тип снимка.
- [ ] `apps/server/src/database/repos/page/page.repo.ts` — класть снимок в payload
`PAGE_CREATED` (`insertPage`) и `PAGE_SOFT_DELETED` (`removePage`, только корень
удаления).
- [ ] `apps/server/src/core/page/services/page.service.ts``movePage` эмитит
`PAGE_MOVED` со старым/новым родителем и `position``movePageToSpace` — для
расширения).
- [ ] `apps/server/src/ws/ws.service.ts``emitTreeEvent(spaceId, pageId, data)`.
- [ ] `apps/server/src/ws/ws-tree.service.ts``broadcastPageCreated/Deleted/Moved`
(+ опц. `broadcastRefetchRoot`).
- [ ] `apps/server/src/ws/listeners/page-ws.listener.ts` — новый листенер.
- [ ] `apps/server/src/ws/ws.module.ts` — зарегистрировать `PageWsListener`.
Клиент:
- [ ] `apps/client/src/features/page/tree/hooks/use-tree-mutation.ts` — убрать
`emit(...)` и `setTimeout(50)` из create/move/delete (оптимистику оставить).
- [ ] `apps/client/src/features/page/tree/model/tree-model.ts`
вставка `addTreeNode` по `position` среди сиблингов (а не по абсолютному index).
- [ ] Проверить согласованность `use-tree-socket.ts` и `use-query-subscription.ts`
(мутация атома vs инвалидация кэша) — без дубликатов узлов.
## Тесты
- Сервер (Jest): юнит на `WsTreeService.broadcastPage*` — корректный формат payload
(`operation`, `spaceId`, `payload.data/node/pageData`) для create/delete/move.
`emitTreeEvent` — рассылка в комнату спейса и ветка ограничений (restricted →
только авторизованные). Запуск: `pnpm --filter server test`.
- Клиент (Vitest): приёмник `addTreeNode` идемпотентен (повтор того же id — no-op);
вставка по `position` даёт верный порядок при разном наборе загруженных сиблингов.
- Линт: `pnpm --filter server lint`, `pnpm --filter client lint`.
- Ручная проверка матрицы способов создания: UI-дерево, AI-агент, MCP `/mcp`, REST
`POST /pages/create`, импорт markdown — во всех случаях дерево обновляется у второго
пользователя без перезагрузки.
## Альтернативы
- **Только клиентский патч (быстро, не рекомендуется).** Убрать `setTimeout(50)` и/или
слать `refetchRootTreeNodeEvent` после create. Лечит лишь UI-сценарий между людьми,
не покрывает AI/MCP/API и остаётся клиент-зависимым — против цели фичи.
- **Сервер всегда шлёт `refetchRootTreeNodeEvent` вместо точечных событий.** Проще
(не нужен снимок узла, нет проблемы порядка), но грубее: каждый клиент перезапрашивает
корневое дерево спейса на любое изменение — больше нагрузки и моргание UI. Возможен
как временный/откатной режим для сложных случаев (restore, move-to-space).
- **Вариант B (эмит из сервиса post-commit)** вместо обогащения событий — см. п. 2.
Надёжно по транзакциям, но размазывает realtime-логику по доменному сервису.
## Открытые вопросы (согласовать перед реализацией)
- [ ] Источник данных для броадкаста: обогатить доменные события снимком узла
(**вариант A, рекомендуется**) или эмитить из сервиса post-commit (вариант B)?
- [ ] Удалять клиентский relay сразу в той же задаче или вторым коммитом после
проверки серверного пути (приёмник идемпотентен — оба варианта безопасны)?
- [ ] `restore` и `move-to-space`: точечные `addTreeNode`/`deleteTreeNode` или более
простой и устойчивый `refetchRootTreeNodeEvent` на затронутые спейсы?
- [ ] Включать ли в базовый объём rename/иконку (`updateOne` от сервера на
`PAGE_UPDATED`) или вынести в отдельную задачу?
- [ ] Чинить ли заодно мёртвый `WsTreeService` (broadcast при смене прав доступа) —
в рамках этой задачи или отдельной?