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