Files
gitmost/docs/backlog/realtime-tree-server-authoritative.md
vvzvlad 850d9ada22 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.
2026-06-18 20:34:18 +03:00

28 KiB

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.tsWsTreeService с методами notifyPermissionGranted (строит готовый payload addTreeNode) и notifyPageRestricted (payload deleteTreeNode). Нигде не вызывается (мёртвый код) — но это точный шаблон формата событий и доказательство, что инфраструктура серверного броадкаста работоспособна.
  • WsService.emitCommentEvent(spaceId, pageId, data) (~66-87) — образец серверного броадкаста в комнату спейса с проверкой ограничений доступа (spaceHasRestrictionshasRestrictedAncestorbroadcastToAuthorizedUsers).
  • 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).

// 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):

// 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 событий тонкие поля дерева, чтобы листенер не читал БД:

// 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). Ввести выделенное событие:

// 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

// 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-паттерна (spaceHasRestrictionshasRestrictedAncestorbroadcastToAuthorizedUsers): закрытые страницы не утекут неавторизованным.
  • Мёртвый 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.tsmovePage эмитит PAGE_MOVED со старым/новым родителем и positionmovePageToSpace — для расширения).
  • apps/server/src/ws/ws.service.tsemitTreeEvent(spaceId, pageId, data).
  • apps/server/src/ws/ws-tree.service.tsbroadcastPageCreated/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 при смене прав доступа) — в рамках этой задачи или отдельной?