Wire the push cycle (SPEC §6) into a runnable command; SAFE BY DEFAULT. - runPush + main(): dry-run by default (plan only, ZERO Docmost writes, no ref advance); --apply is the ONLY path that builds a client and mutates Docmost - orchestration mirrors pull.ts: assertGitAvailable -> ensureRepo -> merge-in-progress guard (§9/§12) -> checkout main -> commit local working tree (Docmost-Sync-Source: local, §7.3) -> base = refs/docmost/last-pushed else docmost -> diffNameStatus(base, main) -> computePushActions -> (apply) -> write-back created pageIds + advance refs; divergent-docmost escalates (exit 1) - npm run push (dry-run) / npm run push -- --apply (writes; needs creds) - fix (review Blocker): pass the WHOLE VaultGit to applyPushActions (bare method refs lost `this` -> --apply crashed on real git); regression test exercises the --apply path against a REAL VaultGit temp repo + fake client (proven to catch it) - symmetric divergent-docmost escalation in both ff branches; dry-run logs the local commit explicitly; SPEC §6 notes the dry-run/local-commit behavior - 737 -> 747 green (x2 stable); build clean; corpus STABLE Deferred (daemon increment): FS-watcher/debounce (§7.1), git-remote push (§7.2), continuous poll loop, pull-side §10 record consumption, fractional-index position.
509 lines
39 KiB
Markdown
509 lines
39 KiB
Markdown
# 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`** — ни на чтение, ни на
|
|
запись.
|
|
- В синхронизируемом файле **нет блока тредов**. Остаются только инлайновые
|
|
якоря `<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-блока):
|
|
|
|
```markdown
|
|
<!-- 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 на `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).
|
|
|
|
> `npm run push` — это **dry-run** (план без записи в Docmost), если не передан
|
|
> `--apply`. В любом режиме он коммитит ожидающие локальные правки на `main`
|
|
> (это нужно, чтобы посчитать diff `base..main`); такой коммит локальный и в
|
|
> Docmost ничего не отправляет.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
### Резолюция (реализовано)
|
|
- **Выбран вариант (б):** сравнение по канонизированной форме — `canonicalizeContent`
|
|
снимает node-`attrs.id` и сводит к default'у/убирает дефолтные атрибуты (включая
|
|
non-null дефолты схемы: `link.target/rel`, `comment.resolved`, `orderedList.start`,
|
|
`*.align`), comment-якоря и значимые атрибуты сохраняются (§3). Состояния синка
|
|
сравниваем через `docsCanonicallyEqual`, не побайтово.
|
|
- **Markdown byte-stable** на синтетическом корпусе (заголовки, марки, списки,
|
|
таблицы, callout'ы, код с хвостовым `\n`, диаграммы) — harness `--corpus`.
|
|
- **Известные асимметрии конвертера** (блочная картинка после абзаца добавляет
|
|
пустой абзац; диаграмма материализует `data-align`) **сходятся к фикспойнту за
|
|
один проход** `export→import→export`. Лечатся **normalize-on-write**: при записи
|
|
в vault прогоняем один такой проход, дальше форма стабильна. Глубокий фикс
|
|
конвертера не требуется.
|
|
|
|
---
|
|
|
|
## 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`. `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).
|