From 42eb986596ba88589a9631e5093fd5cb231ea036 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Tue, 16 Jun 2026 18:37:50 +0300 Subject: [PATCH] =?UTF-8?q?docs(spec):=20resolve=20open=20questions=20?= =?UTF-8?q?=C2=A712=20and=20add=20confirmed=20Docmost=20REST=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SPEC.md | 159 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 139 insertions(+), 20 deletions(-) diff --git a/SPEC.md b/SPEC.md index ba74806..a269614 100644 --- a/SPEC.md +++ b/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 ` + для стороны `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 ` — для 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` (см. выше).