# 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` вручную), а движок синхронизации — приложение в корне репозитория (`src/`, по конвенциям `AGENTS.md`). **Важно:** 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`** — ни на чтение, ни на запись. - В синхронизируемом файле **нет блока тредов**. Остаются только инлайновые якоря `` внутри тела — чтобы подсветки переживали round-trip и не терялись. - Это отличие от `export_page_markdown` в `docmost-mcp` (тот кладёт треды): синку нужен режим экспорта **«тело + якоря, без comments-блока»** (флаг `includeCommentThreads: false`). - Якоря — обычные марки внутри body. Если комментарий в Docmost удалили/resolved, марка меняется → это легитимное изменение тела, приедет на pull. Наверх такие вещи не пушим никогда (треды на сервере неприкосновенны). --- ## 4. Формат файла Самодостаточный `.md` с кастомными Docmost-расширениями (как в `markdown-document.ts` из `docmost-mcp`, но без comments-блока): ```markdown # Заголовок Тело в Markdown с прокомментированным куском, диаграммами (
), таблицами, 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 на `main` → `git 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_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 — структурированный, общий и живой источник правды, а не текстовый файл: 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 ` для стороны `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 ` — для 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`. `listRecentSince` (changes-since) идёт по `cursor`/`nextCursor` согласно этому, с обрывом по `updatedAt ≤ T_last`. Примечание: общий `paginateAll` переиспользуемого клиента и `sidebar-pages` исторически ходят через `page`/`limit` (сервер принимает) — на них опираются `listTrash` / `listAllSpacePages`. 2. Корзина и `recent` могут быть пер-спейс → перечисляем спейсы. 3. `content` отдают только `/info` и `/trash`; `/recent` — без тела. 4. Запись тела — collab-путь, не `/update` (см. выше). 5. `position` — `fractional-indexing-jittered`; сравнивать байтами (`COLLATE "C"`). 6. Троттлинга на page/export/sidebar/move/info нет (лимитируются только `auth` и `ai-chat`) — клиент дросселируем сами. 7. API-keys — EE-only; в OSS только JWT логина (90 дней, без refresh).