diff --git a/docs/backlog/realtime-tree-server-authoritative.md b/docs/backlog/realtime-tree-server-authoritative.md new file mode 100644 index 00000000..e60914a3 --- /dev/null +++ b/docs/backlog/realtime-tree-server-authoritative.md @@ -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 { + 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 { + 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 { + 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 { + 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 при смене прав доступа) — + в рамках этой задачи или отдельной?