Add documentation of the monorepo layout, describing the `docmost-client` and `sync` packages and their responsibilities. Clarify that all Docmost access is performed via REST for reads, while writes use collab/Yjs, documenting the architectural decision and its rationale.
36 KiB
docmost-sync — ТЗ
Двусторонняя синхронизация статей Docmost с локальной папкой Markdown, где
state store — git. Поменяли в Docmost → приехало в .md; поменяли .md →
уехало в Docmost. История, базлайны и разрешение конфликтов берутся из git.
Статус: спецификация (design fixed). Реализации ещё нет. REST-эндпойнты Docmost
подтверждены по исходникам (docmost/docmost, ветка main, 2026-06-16) — см. §16.
1. Цель и границы
- Цель: держать в файловой системе живую копию страниц Docmost в Markdown с непрерывной двусторонней синхронизацией тела страниц.
- Что синхронизируется двусторонне: тело страницы (контент) + структура дерева (иерархия, перемещения, переименования) + удаления.
- Что НЕ синхронизируется: комментарии — ни в какую сторону (см. §3).
- Не входит в первую версию: права/ACL, версии истории Docmost как отдельная сущность, вложения как отдельный поток (едут как ссылки внутри контента), realtime-подписка (Фаза 3).
Опора на существующий код
Переиспользуем проект docmost-mcp (Node/TS) как библиотеку, НЕ как обязательный
слой:
DocmostClient(логин, REST-вызовы) — основа клиента к Docmost;- lossless-конвертер
convertProseMirrorToMarkdown/markdownToProseMirror; - collab-путь записи
replacePageContent/mutatePageContent(Hocuspocus/Yjs).
Реализация — monorepo (npm workspaces): packages/docmost-client (выносной
DocmostClient + lib/*, лейаут 1:1 с docmost-mcp/src/ — sync-методы дописываем
сюда, изменения бэкпортятся в docmost-mcp вручную) и packages/sync (движок
синхронизации).
Важно: MCP-инструменты — это тонкая обёртка над HTTP API Docmost. Синк-движок ходит в REST Docmost напрямую и волен использовать любые эндпойнты, которых нет в MCP (в частности — листинг корзины и restore, см. §8).
2. Как Docmost хранит контент (контекст)
- Контент страницы — ProseMirror/TipTap JSON в колонке
content(jsonb) таблицыpages. Его отдаёт RESTPOST /pages/info. - Параллельно живёт Yjs/CRDT бинарь (ydoc) — состояние совместного
редактирования (Hocuspocus).
content— это дебаунс-снимок ydoc. - Запись обратно делаем через collab/Yjs-канал, а не прямой перезаписью jsonb-колонки — чтобы CRDT и снимок оставались согласованными и параллельные правки людей не затирались.
- Комментарии-треды лежат в отдельной таблице
comments; внутри контента живут только марки-якоря (span[data-comment-id]).
3. Комментарии — не синкаются, только якоря
- Синк-движок никогда не обращается к
/comments— ни на чтение, ни на запись. - В синхронизируемом файле нет блока тредов. Остаются только инлайновые
якоря
<span data-comment-id="…">…</span>внутри тела — чтобы подсветки переживали round-trip и не терялись. - Это отличие от
export_page_markdownвdocmost-mcp(тот кладёт треды): синку нужен режим экспорта «тело + якоря, без comments-блока» (флагincludeCommentThreads: false). - Якоря — обычные марки внутри body. Если комментарий в Docmost удалили/resolved, марка меняется → это легитимное изменение тела, приедет на pull. Наверх такие вещи не пушим никогда (треды на сервере неприкосновенны).
4. Формат файла
Самодостаточный .md с кастомными Docmost-расширениями (как в
markdown-document.ts из docmost-mcp, но без comments-блока):
<!-- docmost:meta
{"version":1,"pageId":"…","slugId":"…","title":"…","spaceId":"…","parentPageId":"…"}
-->
# Заголовок
Тело в Markdown с <span data-comment-id="abc">прокомментированным</span> куском,
диаграммами (<div data-type="drawio" …>), таблицами, callout'ами и т.д.
- Метаблок — HTML-комментарий (его выкидывает
marked, в документ не протекает). pageId— стабильный якорь связи файл↔страница; по нему различаем move и delete.- Файл без
pageId(новый файл от человека) → создать страницу, записать присвоенныйpageIdобратно в meta. - Опциональные sync-маркеры в meta (lastSyncedHash / lastSyncedUpdatedAt) —
НЕ обязательны при git-модели: базлайн держит git (§5). Идентичность (
pageId) — единственное, что обязано лежать в файле.
5. State store = git
Локальная папка — git-репозиторий. Базлайн, история, 3-way merge, rename-detection, тумбстоны удалений и перенос между машинами — всё из git.
Модель веток
main— то, что правит человек в ФС и куда вливаются изменения из Docmost.docmost— зеркало текущего состояния Docmost; пишет только движок, руками не трогают (аналогorigin/main).merge-base(main, docmost)— последняя точка совпадения сторон. Это и есть state store; git ведёт её сам, отдельная БД базлайнов не нужна.refs/docmost/last-pushed— маркер «что изmainуже отражено в Docmost» (для направления ФС→Docmost).
Маппинг страница↔файл
- По
pageIdиз meta. Перемещения путей разруливает git rename-detection, сверяясь сpageId. - Дерево Docmost (
parentPageId) зеркалится в папки:Space/Родитель/Дочерняя.md. Имя файла — из title (санитизация), но истина связи —pageId, не путь.
6. Циклы синхронизации (в терминах git)
Docmost → ФС (pull)
- Изменилась страница (поллинг
POST /api/pages/recent: сортировкаupdatedAt DESC, идём от свежих и обрываем скан на первой странице, гдеupdatedAtстал ≤ последнего синхронизированного — серверного фильтраupdatedAt > Tв Docmost нет, §16; позже — websocket) → export (тело + якоря, без комментариев) → запись файла на веткеdocmost→ commitdocmost: update "Title" (pageId). merge docmost → main: git делает настоящий 3-way merge от реального merge-base. Непересекающиеся правки сливаются сами; настоящее пересечение → конфликт-маркеры в файле (см. §9).git pushв remote.
ФС → Docmost (push)
- Человек сохранил файл → (с дебаунсом) commit на
main→git push. - diff
mainпротивrefs/docmost/last-pushed→ для каждого added/modified/deleted/renamed транслируем в:- modified →
import_page_markdown(через collab-путь); - added (нет pageId) →
create_page, записать pageId в meta; - deleted →
delete_page(в Trash, обратимо, §8); - renamed/moved →
move_page/rename_page.
- modified →
- Двигаем
refs/docmost/last-pushed; фастфорвардимdocmost(Docmost это уже содержит), записываем полученныйupdatedAt(§10).
7. Политика «push в репу после каждого изменения»
Коммит + git push в git-remote сразу после каждого устаканившегося изменения
с любой стороны. Обязательные оговорки:
- Дебаунс, а не на каждый keystroke. Коалесцировать быстрые правки за окно тишины (N секунд), иначе история и сеть захлёбываются. Docmost и сам отдаёт дебаунс-снимок, так что с его стороны это естественно.
- Push = pull-rebase-push с ретраем. Если синкает больше одной машины — пуши в git-remote конкурируют (non-fast-forward); нужен цикл «подтянуть-перебазировать-запушить». Рекомендация: один авторитетный демон на воркспейс Docmost, чтобы не писать в Docmost вдвоём.
- Провенанс в коммитах. Разные committer-identity / трейлеры для
docmost:иlocal:— чтобы по истории была видна сторона и чтобы loop-guard отличал свою запись от чужой.
8. Удаления и перемещения
Docmost имеет Trash: delete_page → POST /pages/delete — это soft-delete
(ставится deletedAt), страница лежит в корзине, восстанавливается, авто-чистка
через ~30 дней. Значит удаления обратимы с обеих сторон.
- Файл удалили локально →
delete_page(уезжает в Trash, не в небытие).- Удалять только отслеживаемые файлы (был
pageIdв meta) — чтобы глюк watcher'а / мусор не трактовался как удаление. - Порог/подтверждение только на массовое удаление.
- Удалять только отслеживаемые файлы (был
- Страница в Trash в Docmost → локальный файл удаляется коммитом на
main(восстановим из git-истории; опц. зеркалить в локальный.trash/). - Restore в Docmost (сбросился
deletedAt) → файл возвращается. - Move vs delete различаем по
pageId: страница со сменившимсяparentPageIdвсё ещё присутствует → это move (двигаем файл), а не delete.
Детекция удалений со стороны Docmost
Не ограничиваемся MCP — ходим в REST напрямую. Эндпойнты подтверждены по исходникам (детали — §16):
- листинг корзины —
POST /api/pages/trash(тело{ spaceId, …пагинация }). Корзина пер-спейс: workspace-wide варианта нет, поэтому обходим все спейсы (list_spaces/POST /api/spaces) и поллим каждый. Ответ содержитdeletedAt,parentPageId,spaceIdи дажеcontent; - restore —
POST /api/pages/restore(тело{ pageId }), сбрасываетdeletedAt.
Детекция удаления — точный запрос к trash-API (видим deletedAt), а не вывод
«pageId пропал из активного дерева». Симметрия двух корзин (git-история + Docmost
Trash) делает синк удалений безопасным.
Auto-purge. Docmost чистит корзину фоном (интервал ~24 ч): удаляет всё, где
deletedAt старше retention = workspace trashRetentionDays (по умолчанию 30
дней). Жёсткое удаление мимо корзины — это POST /api/pages/delete с флагом
permanentlyDelete:true; синк его никогда не шлёт (всегда soft-delete по
умолчанию). Поведение демона при длинном офлайне — см. §12.
9. Конфликты: маркеры НИКОГДА не уезжают в Docmost
При merge-конфликте main↔docmost:
- коммит с маркерами остаётся в git (бэкап на remote — ок);
- push в Docmost для этой страницы блокируется до ручного разрешения;
- конфликт показывается локально (git status / нотификация / conflict-копия);
- в Docmost разблокируем push только после чистого резолва.
Почему маркеры нельзя пушить в Docmost
Docmost — структурированный, общий и живой источник правды, а не текстовый файл:
- ProseMirror, не текст:
<<<<<<</=======/>>>>>>>станут литеральными абзацами, видимыми всем читателям. - Дублирование: конфликт-блок несёт обе версии → в живой документ попадают оба противоречащих куска сразу.
- Порча структуры: маркеры могут расколоть таблицу/callout/код-блок/ span-якорь комментария → битые ноды, потерянные якоря.
- Laundering на round-trip: запушенные маркеры вернутся следующим export как «настоящий контент», другие могли поправить вокруг них — чисто разрешить уже нельзя.
- Общий и живой: локальное приватное «я ещё не решил» не должно мгновенно протекать всем; до резолва в вики остаётся последний хороший контент.
Инвариант: в общий источник правды пишем только намеренные, разрешённые состояния — никогда «мы ещё не определились».
10. Предотвращение петель (loop-guard)
- Свою же запись файла watcher не должен принимать за правку человека: сравнение хэша тела (не байтов файла) + провенанс коммита.
- После push в Docmost записать
updatedAtиз ответа записи (collab-путь илиPOST /api/pages/update), чтобы следующий поллинг не утянул собственную запись обратно как «удалённое изменение». Дополнительный сигнал —lastUpdatedByIdстраницы: если последний редактор это наш сервисный пользователь и хэш тела совпадает с запушенным, изменение игнорируем. - Изменение считается «новым», только если отличается от последнего синхронизированного коммита (git как референс).
11. Жёсткий пререквизит: идемпотентность round-trip
git диффает побайтово. Если export недетерминирован, каждый pull рожает ложный дифф → бесконечные коммиты/конфликты. До включения авто-двустороннего режима:
- Block id'ы:
markdownToProseMirrorсейчас их регенерирует. Решить одним из: (а) вшивать block id'ы в md, либо (б) сравнивать нормализованную форму (ProseMirror JSON со снятыми id), а не сырые байты. - Нормализация Markdown: прогнать
export → import → exportна реальном контенте и добиться пустой разницы (whitespace, экранирование, хвостовые\nв код-блоках и т.п.). - Сравнение состояний синка делать по семантике (канонизированный контент), не по сырым байтам.
Это Задача №0 перед Фазой 2.
12. Безопасность и эксплуатация
- git-remote = доступ ко всей вики. Защищать не слабее Docmost; токены Docmost / стейт с креденшелами в репу не коммитить (gitignore + внешний secret store).
- Один авторитетный демон на воркспейс (см. §7.2).
- Идемпотентность и возобновляемость: операции должны быть идемпотентны,
повторный прогон синка — сходиться. При краше посреди push восстанавливаемся
сверкой
main/docmost/ реального состояния Docmost.
Ранее открытые вопросы — решения
- REST-эндпойнт корзины и restore (§8). ✅
POST /api/pages/trash(пер-спейс) иPOST /api/pages/restore. Детали — §16. - «Changes since T» для больших пространств. ✅ Серверного фильтра по
updatedAtнет. Механизм:POST /api/pages/recent(сортировкаupdatedAt DESC, курсорная пагинация,limit≤ 100) — идём от свежих и обрываем скан на первой странице сupdatedAt ≤ T_last. Можно пер-спейс (spaceId) или по всему воркспейсу (безspaceId). Удаления ловим отдельным поллингом/api/pages/trashпо каждому спейсу. См. §16. - Модель commit-attribution (§7.3). ✅ Источник правды для loop-guard —
git-трейлер
Docmost-Sync-Source: docmost|local(машинно-читаемо, надёжно); committer-identity задаём для наглядности истории (Docmost Sync <sync@local>для стороныdocmost; правки человека идут под его обычной git-identity). Loop-guard смотрит на трейлер, а не на identity. - Имена файлов при коллизиях/спецсимволах. ✅ Детерминированная санитизация
title: заменить запрещённые символы (
/ \ < > : " | ? *и управляющие), схлопнуть пробелы, обрезать длину, обойти зарезервированные имена Windows (CON,PRN,NUL, …). При коллизии двух соседей в одной папке добавляем суффикс со стабильнымslugId:Title ~slugId.md. Имя — косметика; истина связи —pageId/slugIdв meta, поэтому переименование файла безопасно (git rename-detection + сверка по id). - Длинный офлайн (> retention корзины). ✅ Риск: страницу удалили и Trash её
авто-вычистил, пока демон стоял, — мы видим лишь, что
pageIdисчез и из активного дерева, и из корзины. Решение — стартовая реконсиляция: собрать все отслеживаемыеpageId(из meta файлов) и сверить с объединением активного дерева (/recentили обход) и корзины (/trash) по всем спейсам;pageId, которого нет нигде, но он был известен раньше → подтверждённое удаление (файл убираем коммитом наmain, восстановим из git-истории). Эксплуатационно: держатьtrashRetentionDaysбольше максимального окна офлайна демона и предупреждать, если разрыв превысил retention.
Ранее открытые вопросы — решения (продолжение)
- Аутентификация долгоживущего демона. ✅ Отдельный сервисный пользователь.
Если сборка Docmost — EE/Cloud: завести API-key (
POST /api/api-keys/create,expiresAtопускаем → бессрочный, отзывной), слатьAuthorization: Bearer. В OSS communityapi_keyотвергается («Enterprise API Key module missing»), поэтому fallback — JWT логина (POST /api/auth/login; по умолчанию 90 дней, refresh-эндпойнта нет, токен привязан к серверной сессии). Универсально — обёртка «401 → пере-логин → повтор запроса»; опц. поднятьJWT_TOKEN_EXPIRES_INна сервере. Детали — §16. - Генерация
positionпри move/reorder. ✅ Тот же алгоритм, что у Docmost: пакетfractional-indexing-jittered,generateJitteredKeyBetween(prev,next)(первый ребёнок(null,null), в конец(last,null), между соседями(prev,next)). Соседей и ихpositionберём изPOST /api/pages/sidebar-pages; сравнивать/сортировать позиции как сырые байты (PostgresCOLLATE "C", т.е. обычное строковое</>), иначе порядок разъедется. Детали — §16. - Первичный полный клон пространства. ✅ Канонический путь — обойти дерево
POST /api/pages/sidebar-pages(структура +position+pageId, без контента) и на каждую страницу дёрнутьPOST /api/pages/info→ прогнать через наш конвертер (тело+якоря) → файл с meta. Ограниченная конкурентность, возобновимо, фиксируем максимальныйupdatedAtкак стартовыйT_last. Эндпойнты не троттлятся, но клиент дросселируем сами.POST /api/spaces/export/POST /api/pages/export(markdown-ZIP) — только быстрый bootstrap: их markdown идёт через ДРУГОЙ (turndown) конвертер и без meta/якорей, поэтому не годится как baseline без повторного прогона через наш конвертер (иначе §11 поедет). - Вложения. ✅ Scope v1: картинки/файлы едут ссылками внутри контента (URL
на сервер Docmost); локальный
.mdне самодостаточен по бинарям — это принято. Будущая фаза (флагincludeAttachmentsу export уже есть) сможет качать бинари в vault и переписывать ссылки. Сейчас — вне scope.
Решение по слою доступа
- Доступ к Docmost — всегда REST (решено). Прямое чтение Postgres отвергнуто: выигрыш на чтении не окупает связку с внутренней схемой, обход сайд-эффектов сервисов (поиск/аудит/websocket) и недоступность против Docmost Cloud. Запись — через collab/Yjs (§2), чтение — REST (§16).
13. Компоненты (скелет)
- git repo (vault): ветки
main+docmost, refrefs/docmost/last-pushed. - Docmost→git: детектор изменений/трэша (REST напрямую) → export
(тело+якоря) → commit на
docmost→ merge вmain→ push. - git→Docmost: FS-watcher (chokidar) + дебаунс → commit на
main→ push → diff противlast-pushed→ import/create/delete/move. - Conflict handler: маркеры в git, Docmost-push страницы на паузе, нотификация.
- Loop-guard: подавление self-write (хэш тела + провенанс), запись
updatedAtпосле push. - Converter:
export_page_markdown(includeCommentThreads:false)/import_page_markdownизdocmost-mcp.
Стек: Node/TS, переиспользование docmost-mcp (DocmostClient, конвертер,
collab-write), chokidar (watch), прямой REST Docmost для корзины.
14. План по фазам
- Фаза 0 — идемпотентность (§11). Стабильный детерминированный round-trip. Блокирует авто-двусторонний режим.
- Фаза 1 — зеркало + ручной push (низкий риск).
pullвсего пространства в файлы по дереву (поpageId), ручнойpushправленых файлов, конфликт-копии, поллинг. Тело двусторонне в ручном режиме, комментарии read-only. - Фаза 2 — непрерывный двусторонний git-режим. Ветки
main/docmost, merge-base как базлайн, FS-watcher + поллинг Docmost, commit+push после каждого устаканившегося изменения, conflict-gating, удаления через Trash + git. - Фаза 3 — realtime и доводка. Подписка на Hocuspocus вместо поллинга, git-история как UX конфликтов, опц. полная реконсиляция комментариев (если когда-нибудь понадобится — сейчас явно вне scope).
15. Зафиксированные решения (резюме)
- State store = git: две ветки
main/docmost,merge-baseкак базлайн. - Commit + push в git-remote после каждого устаканившегося изменения с обеих сторон (с дебаунсом, pull-rebase-push, один демон, провенанс).
- Комментарии не синкаются ни в какую сторону; в файле — только якоря, без
тредов;
/commentsне дёргается. - Конфликт-маркеры никогда не уезжают в Docmost; push конфликтной страницы блокируется до резолва.
- Удаления — через Docmost Trash (soft, обратимо) + git-история; move/delete
различаем по
pageId; детекция трэша — прямым REST мимо MCP. - Запись в Docmost — через collab/Yjs-путь, не прямой перезаписью jsonb.
- Идемпотентный round-trip — пререквизит (Задача №0) до авто-режима.
- REST-карта Docmost подтверждена по исходникам (§16): корзина/restore —
пер-спейс; «changes since T» — desc-скан
POST /api/pages/recentс клиентским обрывом (серверного фильтра поupdatedAtнет); запись тела — collab/Yjs-путь, а неPOST /api/pages/update.
16. REST-карта Docmost (подтверждено по исходникам)
Сверено с docmost/docmost, ветка main, 2026-06-16. Контроллер
apps/server/src/core/page/page.controller.ts. Все ручки — POST, общий префикс
/api (main.ts: setGlobalPrefix('api')). Глобальный
ValidationPipe({ whitelist:true }) вырезает неизвестные поля тела. Ключ тела
страницы везде pageId, не id.
Аутентификация
POST /api/auth/login— тело{ email, password }. На успехе ставит httpOnly-cookieauthToken(JWT).JwtAuthGuardпринимает и cookie, иAuthorization: Bearer <jwt>— для headless-демона используем Bearer. JWT живётJWT_TOKEN_EXPIRES_IN(по умолчанию 90 дней), refresh-эндпойнта нет, токен привязан к серверной сессии (logout/отзыв сессии его убивает) → на 401 пере-логиниваемся.- API-keys (только EE/Cloud):
POST /api/api-keys/create(тело{ name, expiresAt? }; безexpiresAtключ бессрочный; raw-токен в ответе один раз),…/update,…/revoke, листингPOST /api/api-keys. Шлётся тем жеAuthorization: Bearer. В OSS community-сборке валидацияapi_keyне включена (EE-модуль) → запрос падает с «Enterprise API Key module missing»; там используем JWT логина.
Чтение
POST /api/pages/info— тело{ pageId, includeContent:true, includeSpace? }.content(ProseMirror/TipTap JSON, jsonb) приходит только приincludeContent:true. Поля ответа:id, slugId, title, content, parentPageId, spaceId, updatedAt, deletedAt, lastUpdatedById, position, ….POST /api/pages/recent— тело{ spaceId?, limit?, cursor?, beforeCursor? }. СортировкаupdatedAt DESC, id DESC, курсорная пагинация (limit≤ 100).contentне отдаётся — за телом идём в/info. Это и есть «changes since T»: скан с конца + клиентский обрыв поT_last. Серверного фильтра поupdatedAtнет.POST /api/pages/sidebar-pages— обход дерева. Тело{ spaceId? | pageId?, …пагинация }(нуженspaceIdИЛИpageId):pageId→ прямые дети, толькоspaceId→ корни. Отдаётid, slugId, title, position, parentPageId, icon, hasChildren(безcontent), сортировка поposition COLLATE "C", курсорная пагинация. Источник соседей для расчётаposition.
Запись тела — через collab/Yjs, не через /update
POST /api/pages/update (тело { pageId, title?, content?, operation: append|prepend|replace, format: json|markdown|html }) перезаписывает jsonb
content и пересобирает ydoc из него на сервере — это может затереть живые
параллельные правки в Hocuspocus. Поэтому тело страницы пишем через collab-путь
(Hocuspocus/Yjs, как replacePageContent / mutatePageContent в docmost-mcp),
а /update допустим максимум для ручного режима Фазы 1 и для переименования
(title). Контент-правка через /update требует content + operation +
format вместе, иначе контент игнорируется.
Структура дерева
POST /api/pages/create— тело{ spaceId (обяз.), title?, parentPageId?, content?, format? }. Сервер генеритslugIdиposition. Возврат — созданная страница (берём из неёpageIdв meta).POST /api/pages/update— переименование (title) и/или иконка. Отдельной ручки rename нет.POST /api/pages/move— тело{ pageId, position (обяз., 5–12 симв., fractional-index), parentPageId? | null }.parentPageId: null→ в корень.positionобязателен — при зеркалировании локального перемещения движок обязан вычислить валидный ключ между соседями.POST /api/pages/move-to-space— тело{ pageId, spaceId }(перенос между пространствами).
Удаление / корзина
POST /api/pages/delete— тело{ pageId, permanentlyDelete? }. По умолчанию soft-delete (ставитdeletedAt);permanentlyDelete:true— жёсткое рекурсивное удаление (синк его не использует).POST /api/pages/trash— тело{ spaceId (обяз.), …пагинация }. Пер-спейс, workspace-wide нет → обходим все спейсы. Ответ включаетcontent,deletedAt,parentPageId,spaceId; сортировкаdeletedAt DESC.POST /api/pages/restore— тело{ pageId }, сбрасываетdeletedAt.- Auto-purge: фон ~24 ч, retention =
trashRetentionDays(по умолчанию 30 дней).
Экспорт (bulk, для initial clone)
POST /api/pages/export— тело{ pageId, format: 'html'|'markdown', includeChildren?, includeAttachments? }. Один файл или ZIP (если поддерево).POST /api/spaces/export— тело{ spaceId, format: 'html'|'markdown', includeAttachments? }→ ZIP всего пространства (нужно право space «Manage Settings»).- Важно: markdown отсюда идёт через turndown-конвертер Docmost (не наш) и без
meta/якорей — годится только как быстрый bootstrap, не как канонический baseline
(см. §11, §12).
positionв экспорте нет — порядок восстанавливаем черезsidebar-pages.
Подводные камни
- Пагинация курсорная (
cursor/beforeCursor/limit≤ 100) в теле JSON — неpage/offset. - Корзина и
recentмогут быть пер-спейс → перечисляем спейсы. contentотдают только/infoи/trash;/recent— без тела.- Запись тела — collab-путь, не
/update(см. выше). position—fractional-indexing-jittered; сравнивать байтами (COLLATE "C").- Троттлинга на page/export/sidebar/move/info нет (лимитируются только
authиai-chat) — клиент дросселируем сами. - API-keys — EE-only; в OSS только JWT логина (90 дней, без refresh).