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:
159
SPEC.md
159
SPEC.md
@@ -4,7 +4,8 @@
|
||||
**state store — git**. Поменяли в Docmost → приехало в `.md`; поменяли `.md` →
|
||||
уехало в Docmost. История, базлайны и разрешение конфликтов берутся из git.
|
||||
|
||||
Статус: спецификация (design fixed). Реализации ещё нет.
|
||||
Статус: спецификация (design fixed). Реализации ещё нет. REST-эндпойнты Docmost
|
||||
подтверждены по исходникам (`docmost/docmost`, ветка `main`, 2026-06-16) — см. §16.
|
||||
|
||||
---
|
||||
|
||||
@@ -114,9 +115,12 @@ rename-detection, тумбстоны удалений и перенос межд
|
||||
## 6. Циклы синхронизации (в терминах git)
|
||||
|
||||
### Docmost → ФС (pull)
|
||||
1. Изменилась страница (поллинг REST `list_pages` по `updatedAt`, позже —
|
||||
websocket) → export (тело + якоря, **без комментариев**) → запись файла на
|
||||
ветке `docmost` → commit `docmost: update "Title" (pageId)`.
|
||||
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).
|
||||
@@ -170,14 +174,23 @@ Docmost имеет **Trash**: `delete_page` → `POST /pages/delete` — это
|
||||
`parentPageId` всё ещё присутствует → это move (двигаем файл), а не delete.
|
||||
|
||||
### Детекция удалений со стороны Docmost
|
||||
Не ограничиваемся MCP — ходим в REST напрямую к эндпойнту корзины/restore (тому,
|
||||
что дёргает фронт Docmost во вкладке Trash). Точный путь подтвердить из
|
||||
Network-таба UI или из исходников pages-контроллера (TODO §12). Тогда детекция —
|
||||
**точный запрос к trash-API** (видим `deletedAt`/restore), а не вывод «pageId
|
||||
пропал из активного дерева».
|
||||
Не ограничиваемся 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`.
|
||||
|
||||
Симметрия двух корзин (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 не должен принимать за правку человека: сравнение
|
||||
**хэша тела** (не байтов файла) + провенанс коммита.
|
||||
- После push в Docmost записать полученный `updatedAt`, чтобы следующий поллинг
|
||||
не утянул собственную запись обратно как «удалённое изменение».
|
||||
- После push в Docmost записать `updatedAt` из ответа записи (collab-путь или
|
||||
`POST /api/pages/update`), чтобы следующий поллинг не утянул собственную запись
|
||||
обратно как «удалённое изменение». Дополнительный сигнал — `lastUpdatedById`
|
||||
страницы: если последний редактор это наш сервисный пользователь и хэш тела
|
||||
совпадает с запушенным, изменение игнорируем.
|
||||
- Изменение считается «новым», только если отличается от последнего
|
||||
синхронизированного коммита (git как референс).
|
||||
|
||||
@@ -247,13 +263,46 @@ git диффает побайтово. Если export недетерминир
|
||||
повторный прогон синка — сходиться. При краше посреди push восстанавливаемся
|
||||
сверкой `main` / `docmost` / реального состояния Docmost.
|
||||
|
||||
### Открытые вопросы / TODO
|
||||
- [ ] Точный REST-эндпойнт листинга корзины и restore в Docmost (§8).
|
||||
- [ ] Модель commit-attribution (отдельные identity vs трейлеры) (§7.3).
|
||||
- [ ] Эффективный «changes since T» для больших пространств (пагинация
|
||||
`list_pages` vs другой механизм).
|
||||
- [ ] Стратегия имён файлов при коллизиях title / спецсимволах.
|
||||
- [ ] Поведение при >30 днях офлайна (авто-чистка Trash затирает копию).
|
||||
### Ранее открытые вопросы — решения
|
||||
- **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.
|
||||
|
||||
### Остаются открытыми
|
||||
- [ ] Обновление/протухание 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.
|
||||
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**.
|
||||
|
||||
### Чтение
|
||||
- `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` (см. выше).
|
||||
|
||||
Reference in New Issue
Block a user