docs(spec): resolve open questions §12 and add confirmed Docmost REST map

Research Docmost source (docmost/docmost@main) to pin real REST endpoints
and close all five §12 TODOs.

- §6: replace MCP `list_pages` polling with the real "changes since T"
  mechanism — `POST /api/pages/recent` (updatedAt DESC, cursor) + client cutoff
- §8: concrete trash/restore endpoints (per-space `trash`, `restore`),
  auto-purge note and `permanentlyDelete` guard
- §10: add `lastUpdatedById` loop-guard signal
- §12: turn open questions into decisions (trash/restore, changes-since-T,
  commit-attribution trailer, filename collisions, long-offline reconciliation);
  add a new list of genuinely-open items
- §15/§16: add the confirmed Docmost REST map (auth, info, recent, create,
  update, move, move-to-space, delete, trash, restore) with gotchas
- fix a nested-list markdown glitch in §12
This commit is contained in:
vvzvlad
2026-06-16 18:37:50 +03:00
parent d5cd1bba02
commit 42eb986596

159
SPEC.md
View File

@@ -4,7 +4,8 @@
**state store — git**. Поменяли в Docmost → приехало в `.md`; поменяли `.md` **state store — git**. Поменяли в Docmost → приехало в `.md`; поменяли `.md`
уехало в Docmost. История, базлайны и разрешение конфликтов берутся из git. уехало в Docmost. История, базлайны и разрешение конфликтов берутся из git.
Статус: спецификация (design fixed). Реализации ещё нет. Статус: спецификация (design fixed). Реализации ещё нет. REST-эндпойнты Docmost
подтверждены по исходникам (`docmost/docmost`, ветка `main`, 2026-06-16) — см. §16.
--- ---
@@ -114,9 +115,12 @@ rename-detection, тумбстоны удалений и перенос межд
## 6. Циклы синхронизации (в терминах git) ## 6. Циклы синхронизации (в терминах git)
### Docmost → ФС (pull) ### Docmost → ФС (pull)
1. Изменилась страница (поллинг REST `list_pages` по `updatedAt`, позже — 1. Изменилась страница (поллинг `POST /api/pages/recent`: сортировка `updatedAt
websocket) → export (тело + якоря, **без комментариев**) → запись файла на DESC`, идём от свежих и **обрываем скан** на первой странице, где `updatedAt`
ветке `docmost` → commit `docmost: update "Title" (pageId)`. стал ≤ последнего синхронизированного — серверного фильтра `updatedAt > T` в
Docmost нет, §16; позже — websocket) → export (тело + якоря, **без
комментариев**) → запись файла на ветке `docmost` → commit
`docmost: update "Title" (pageId)`.
2. `merge docmost → main`: git делает настоящий **3-way merge** от реального 2. `merge docmost → main`: git делает настоящий **3-way merge** от реального
merge-base. Непересекающиеся правки сливаются сами; настоящее пересечение → merge-base. Непересекающиеся правки сливаются сами; настоящее пересечение →
конфликт-маркеры в файле (см. §9). конфликт-маркеры в файле (см. §9).
@@ -170,14 +174,23 @@ Docmost имеет **Trash**: `delete_page` → `POST /pages/delete` — это
`parentPageId` всё ещё присутствует → это move (двигаем файл), а не delete. `parentPageId` всё ещё присутствует → это move (двигаем файл), а не delete.
### Детекция удалений со стороны Docmost ### Детекция удалений со стороны Docmost
Не ограничиваемся MCP — ходим в REST напрямую к эндпойнту корзины/restore (тому, Не ограничиваемся MCP — ходим в REST напрямую. Эндпойнты подтверждены по
что дёргает фронт Docmost во вкладке Trash). Точный путь подтвердить из исходникам (детали — §16):
Network-таба UI или из исходников pages-контроллера (TODO §12). Тогда детекция — - листинг корзины — `POST /api/pages/trash` (тело `{ spaceId, …пагинация }`).
**точный запрос к trash-API** (видим `deletedAt`/restore), а не вывод «pageId **Корзина пер-спейс**: workspace-wide варианта нет, поэтому обходим все спейсы
пропал из активного дерева». (`list_spaces` / `POST /api/spaces`) и поллим каждый. Ответ содержит `deletedAt`,
`parentPageId`, `spaceId` и даже `content`;
- restore — `POST /api/pages/restore` (тело `{ pageId }`), сбрасывает `deletedAt`.
Симметрия двух корзин (git-история + Docmost Trash) делает синк удалений Детекция удаления — **точный запрос к 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.
--- ---
@@ -212,8 +225,11 @@ Docmost — структурированный, общий и живой ист
- Свою же запись файла watcher не должен принимать за правку человека: сравнение - Свою же запись файла watcher не должен принимать за правку человека: сравнение
**хэша тела** (не байтов файла) + провенанс коммита. **хэша тела** (не байтов файла) + провенанс коммита.
- После push в Docmost записать полученный `updatedAt`, чтобы следующий поллинг - После push в Docmost записать `updatedAt` из ответа записи (collab-путь или
не утянул собственную запись обратно как «удалённое изменение». `POST /api/pages/update`), чтобы следующий поллинг не утянул собственную запись
обратно как «удалённое изменение». Дополнительный сигнал — `lastUpdatedById`
страницы: если последний редактор это наш сервисный пользователь и хэш тела
совпадает с запушенным, изменение игнорируем.
- Изменение считается «новым», только если отличается от последнего - Изменение считается «новым», только если отличается от последнего
синхронизированного коммита (git как референс). синхронизированного коммита (git как референс).
@@ -247,13 +263,46 @@ git диффает побайтово. Если export недетерминир
повторный прогон синка — сходиться. При краше посреди push восстанавливаемся повторный прогон синка — сходиться. При краше посреди push восстанавливаемся
сверкой `main` / `docmost` / реального состояния Docmost. сверкой `main` / `docmost` / реального состояния Docmost.
### Открытые вопросы / TODO ### Ранее открытые вопросы — решения
- [ ] Точный REST-эндпойнт листинга корзины и restore в Docmost (§8). - **REST-эндпойнт корзины и restore (§8).** ✅ `POST /api/pages/trash` (пер-спейс)
- [ ] Модель commit-attribution (отдельные identity vs трейлеры) (§7.3). и `POST /api/pages/restore`. Детали — §16.
- [ ] Эффективный «changes since T» для больших пространств (пагинация - **«Changes since T» для больших пространств.** ✅ Серверного фильтра по
`list_pages` vs другой механизм). `updatedAt` нет. Механизм: `POST /api/pages/recent` (сортировка `updatedAt
- [ ] Стратегия имён файлов при коллизиях title / спецсимволах. DESC`, курсорная пагинация, `limit` ≤ 100) — идём от свежих и обрываем скан на
- [ ] Поведение при >30 днях офлайна (авто-чистка Trash затирает копию). первой странице с `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.
### Остаются открытыми
- [ ] Обновление/протухание JWT сервисного пользователя (рефреш токена, реакция
на 401 в долгоживущем демоне).
- [ ] Генерация ключа `position` (fractional-index, 5–12 симв.) при move/reorder
— библиотека/алгоритм LexoRank-совместимых ключей (§16, move).
- [ ] Стратегия первичного полного клона больших пространств (initial pull):
обход дерева vs курсорный скан `/recent`, дросселирование.
- [ ] Вложения как отдельный поток (сейчас едут ссылками в контенте) — вне первой
версии.
--- ---
@@ -305,3 +354,73 @@ collab-write), `chokidar` (watch), прямой REST Docmost для корзин
различаем по `pageId`; детекция трэша — прямым REST мимо MCP. различаем по `pageId`; детекция трэша — прямым REST мимо MCP.
6. **Запись в Docmost — через collab/Yjs-путь**, не прямой перезаписью jsonb. 6. **Запись в Docmost — через collab/Yjs-путь**, не прямой перезаписью jsonb.
7. **Идемпотентный round-trip — пререквизит (Задача №0)** до авто-режима. 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**.
### Чтение
- `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` нет.
### Запись тела — через 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 дней).
### Подводные камни
1. Пагинация **курсорная** (`cursor` / `beforeCursor` / `limit` ≤ 100) в теле
JSON — не `page` / `offset`.
2. Корзина и `recent` могут быть пер-спейс → перечисляем спейсы.
3. `content` отдают только `/info` и `/trash`; `/recent` — без тела.
4. Запись тела — collab-путь, не `/update` (см. выше).