Files
docmost-sync/SPEC.md
vvzvlad 2f92dc4c1f docs(spec): add monorepo implementation and access layer notes
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.
2026-06-16 19:11:53 +03:00

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. Его отдаёт REST POST /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)

  1. Изменилась страница (поллинг POST /api/pages/recent: сортировка updatedAt DESC, идём от свежих и обрываем скан на первой странице, где updatedAt стал ≤ последнего синхронизированного — серверного фильтра updatedAt > T в Docmost нет, §16; позже — websocket) → export (тело + якоря, без комментариев) → запись файла на ветке docmost → commit docmost: update "Title" (pageId).
  2. merge docmost → main: git делает настоящий 3-way merge от реального merge-base. Непересекающиеся правки сливаются сами; настоящее пересечение → конфликт-маркеры в файле (см. §9).
  3. git push в remote.

ФС → Docmost (push)

  1. Человек сохранил файл → (с дебаунсом) commit на maingit push.
  2. 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.
  3. Двигаем refs/docmost/last-pushed; фастфорвардим docmost (Docmost это уже содержит), записываем полученный updatedAt (§10).

7. Политика «push в репу после каждого изменения»

Коммит + git push в git-remote сразу после каждого устаканившегося изменения с любой стороны. Обязательные оговорки:

  1. Дебаунс, а не на каждый keystroke. Коалесцировать быстрые правки за окно тишины (N секунд), иначе история и сеть захлёбываются. Docmost и сам отдаёт дебаунс-снимок, так что с его стороны это естественно.
  2. Push = pull-rebase-push с ретраем. Если синкает больше одной машины — пуши в git-remote конкурируют (non-fast-forward); нужен цикл «подтянуть-перебазировать-запушить». Рекомендация: один авторитетный демон на воркспейс Docmost, чтобы не писать в Docmost вдвоём.
  3. Провенанс в коммитах. Разные committer-identity / трейлеры для docmost: и local: — чтобы по истории была видна сторона и чтобы loop-guard отличал свою запись от чужой.

8. Удаления и перемещения

Docmost имеет Trash: delete_pagePOST /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-конфликте maindocmost:

  • коммит с маркерами остаётся в git (бэкап на remote — ок);
  • push в Docmost для этой страницы блокируется до ручного разрешения;
  • конфликт показывается локально (git status / нотификация / conflict-копия);
  • в Docmost разблокируем push только после чистого резолва.

Почему маркеры нельзя пушить в Docmost

Docmost — структурированный, общий и живой источник правды, а не текстовый файл:

  1. ProseMirror, не текст: <<<<<<</=======/>>>>>>> станут литеральными абзацами, видимыми всем читателям.
  2. Дублирование: конфликт-блок несёт обе версии → в живой документ попадают оба противоречащих куска сразу.
  3. Порча структуры: маркеры могут расколоть таблицу/callout/код-блок/ span-якорь комментария → битые ноды, потерянные якоря.
  4. Laundering на round-trip: запушенные маркеры вернутся следующим export как «настоящий контент», другие могли поправить вокруг них — чисто разрешить уже нельзя.
  5. Общий и живой: локальное приватное «я ещё не решил» не должно мгновенно протекать всем; до резолва в вики остаётся последний хороший контент.

Инвариант: в общий источник правды пишем только намеренные, разрешённые состояния — никогда «мы ещё не определились».


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 community api_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; сравнивать/сортировать позиции как сырые байты (Postgres COLLATE "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, ref refs/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. Зафиксированные решения (резюме)

  1. State store = git: две ветки main/docmost, merge-base как базлайн.
  2. Commit + push в git-remote после каждого устаканившегося изменения с обеих сторон (с дебаунсом, pull-rebase-push, один демон, провенанс).
  3. Комментарии не синкаются ни в какую сторону; в файле — только якоря, без тредов; /comments не дёргается.
  4. Конфликт-маркеры никогда не уезжают в Docmost; push конфликтной страницы блокируется до резолва.
  5. Удаления — через Docmost Trash (soft, обратимо) + git-история; move/delete различаем по pageId; детекция трэша — прямым REST мимо MCP.
  6. Запись в Docmost — через collab/Yjs-путь, не прямой перезаписью jsonb.
  7. Идемпотентный round-trip — пререквизит (Задача №0) до авто-режима.
  8. 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-cookie authToken (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.

Подводные камни

  1. Пагинация курсорная (cursor / beforeCursor / limit ≤ 100) в теле JSON — не page / offset.
  2. Корзина и recent могут быть пер-спейс → перечисляем спейсы.
  3. content отдают только /info и /trash; /recent — без тела.
  4. Запись тела — collab-путь, не /update (см. выше).
  5. positionfractional-indexing-jittered; сравнивать байтами (COLLATE "C").
  6. Троттлинга на page/export/sidebar/move/info нет (лимитируются только auth и ai-chat) — клиент дросселируем сами.
  7. API-keys — EE-only; в OSS только JWT логина (90 дней, без refresh).