Files
docmost-sync/SPEC.md
2026-06-16 18:30:04 +03:00

21 KiB

docmost-sync — ТЗ

Двусторонняя синхронизация статей Docmost с локальной папкой Markdown, где state store — git. Поменяли в Docmost → приехало в .md; поменяли .md → уехало в Docmost. История, базлайны и разрешение конфликтов берутся из git.

Статус: спецификация (design fixed). Реализации ещё нет.


1. Цель и границы

  • Цель: держать в файловой системе живую копию страниц Docmost в Markdown с непрерывной двусторонней синхронизацией тела страниц.
  • Что синхронизируется двусторонне: тело страницы (контент) + структура дерева (иерархия, перемещения, переименования) + удаления.
  • Что НЕ синхронизируется: комментарии — ни в какую сторону (см. §3).
  • Не входит в первую версию: права/ACL, версии истории Docmost как отдельная сущность, вложения как отдельный поток (едут как ссылки внутри контента), realtime-подписка (Фаза 3).

Опора на существующий код

Переиспользуем проект docmost-mcp (Node/TS) как библиотеку, НЕ как обязательный слой:

  • DocmostClient (логин, REST-вызовы) — основа клиента к Docmost;
  • lossless-конвертер convertProseMirrorToMarkdown / markdownToProseMirror;
  • collab-путь записи replacePageContent / mutatePageContent (Hocuspocus/Yjs).

Важно: 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-блока):

<!-- 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. Изменилась страница (поллинг REST list_pages по updatedAt, позже — 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 на maingit 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_pagePOST /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 напрямую к эндпойнту корзины/restore (тому, что дёргает фронт Docmost во вкладке Trash). Точный путь подтвердить из Network-таба UI или из исходников pages-контроллера (TODO §12). Тогда детекция — точный запрос к trash-API (видим deletedAt/restore), а не вывод «pageId пропал из активного дерева».

Симметрия двух корзин (git-история + Docmost Trash) делает синк удалений безопасным.


9. Конфликты: маркеры НИКОГДА не уезжают в Docmost

При merge-конфликте maindocmost:

  • коммит с маркерами остаётся в 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, чтобы следующий поллинг не утянул собственную запись обратно как «удалённое изменение».
  • Изменение считается «новым», только если отличается от последнего синхронизированного коммита (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.

Открытые вопросы / TODO

  • Точный REST-эндпойнт листинга корзины и restore в Docmost (§8).
  • Модель commit-attribution (отдельные identity vs трейлеры) (§7.3).
  • Эффективный «changes since T» для больших пространств (пагинация list_pages vs другой механизм).
  • Стратегия имён файлов при коллизиях title / спецсимволах.
  • Поведение при >30 днях офлайна (авто-чистка Trash затирает копию).

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) до авто-режима.