[feature][epic] Git-sync: двусторонняя синхронизация страниц Docmost ↔ git-папка Markdown (встраивание docmost-sync) #194

Open
opened 2026-06-25 22:40:53 +03:00 by Ghost · 0 comments

Перенесено из docs/git-sync-plan.md (файл удалён; этот issue — единственный носитель спецификации). Ссылки вида ../apps/... и backlog/... в тексте указывают на пути в репозитории на момент написания.


Git-sync: спека реализации (встраивание docmost-sync в gitmost)

Статус: спецификация, код не менялся. Детальный план реализации фичи
«двусторонний синк страниц Docmost ↔ локальная git-папка Markdown», встроенной
прямо в gitmost.

Источник движка: https://gitea.vvzvlad.xyz/vvzvlad/docmost-sync
(ветка main, на момент спеки HEAD b03eb35). Все сигнатуры ниже сверены с этим
исходником и с текущим кодом gitmost.

Предыстория и обоснование архитектурных развилок — в бэклоге
ai-chat-tool-definitions-duplicated.md
(раздел про дублирование конвертера) и в исходном SPEC.md репозитория
docmost-sync (нумерация §-параграфов ниже ссылается на него).


0. Зафиксированные решения

Из обсуждения архитектуры (выбор пользователя) и трёх суб-решений:

  1. Нативная in-process интеграция. Никаких REST-к-себе и сервис-юзера: чтение
    через репозитории gitmost, запись тела — через collab openDirectConnection,
    триггеры — через EventEmitter2 вместо поллинга /recent.
  2. Встроенный NestJS-модуль GitSyncModule в apps/server/src/integrations/git-sync
    с @Interval/событиями и leader-lock на Redis (single-writer при нескольких
    репликах).
  3. Настройка по спейсам в UI — флаг в space.settings.gitSync, секреты
    (git-remote) — через ENV/EnvironmentService.
  4. Конвертер — вендорим чистую часть из docmost-sync в packages/git-sync,
    гейт = round-trip-идемпотентность против схемы @docmost/editor-ext.
  5. Vaultрепозиторий на спейс; move-to-space = кросс-репо delete+create.
  6. Провенанс — отдельное значение lastUpdatedSource = 'git-sync'.

Вне scope v1 (как и в SPEC): комментарии (только якоря, без тредов), права/ACL,
вложения как отдельный поток (едут ссылками внутри контента), realtime-подписка
на Hocuspocus (остаётся поллинг-страховка + события).


1. Архитектура верхнего уровня

              gitmost server (NestJS, один процесс)
  ┌─────────────────────────────────────────────────────────────┐
  │ GitSyncModule                                                 │
  │                                                               │
  │  GitSyncOrchestrator  ── @Interval + Redis leader-lock        │
  │     │   (per enabled space: pull-cycle / push-cycle)          │
  │     │                                                         │
  │     ├── engine (vendored docmost-sync, IO инжектируется)      │
  │     │     pull.ts / push.ts / reconcile / layout / stabilize  │
  │     │                                                         │
  │     ├── GitmostDataSource  ── реализует подмножество           │
  │     │     DocmostClient НАТИВНО:                              │
  │     │        reads  → PageRepo / SpaceRepo (Kysely)           │
  │     │        writes → CollaborationGateway.openDirectConnection│
  │     │                 + PageService (create/move/delete/...)  │
  │     │                                                         │
  │     └── VaultGit  ── shell-out в системный git (как есть)     │
  │                                                               │
  │  PageChangeListener  ── подписка на EventName.PAGE_* →        │
  │                          debounce → enqueue push-cycle        │
  └─────────────────────────────────────────────────────────────┘
        ▲ читает/пишет страницы           ▼ git push/pull
  PostgreSQL (pages/spaces)         data/git-sync/<spaceId>/ (vault) → remote

Ключ интеграции: движок docmost-sync уже полностью построен на dependency
injection
— весь внешний IO (REST-клиент, git, файловая система) передаётся
через узкие интерфейсы. Мы НЕ переписываем движок; мы подставляем нативные
реализации в его DI-швы.


2. Состав вендоринга из docmost-sync

В новый пакет packages/git-sync копируем (с сохранением истории смысла —
backport-friendly, как сделано с packages/mcp):

2.1. Движок (engine) — src/engine/

Файл Что несёт IO Берём
pull.ts Docmost→FS: reconcile + write + commit + merge client+git+fs (инжектируется) да
push.ts FS→Docmost: diff + classify + apply + refs client+git+fs (инжектируется) да
git.ts VaultGit — обёртка git shell-out системный git да, как есть
reconcile.ts чистый планировщик нет да
layout.ts чистый маппер дерево→пути нет да
sanitize.ts чистая санитизация имён нет да
stabilize.ts fixpoint-нормализация md (SPEC §11) нет (lib-вызовы) да
loop-guard.ts bodyHash (sha256) нет да
settings.ts zod-конфиг .env адаптируем (см. §7)
index.ts тонкий CLI-скаффолд нет (заменяем на NestJS)

2.2. Конвертер (чистая часть) — src/lib/

Из packages/docmost-client/src/lib/ берём только чистый конвертер и формат
файла (collab/auth REST-части НЕ нужны — запись нативная):

Файл Экспорт
markdown-converter.ts convertProseMirrorToMarkdown(content): string
collaboration.ts (только конвертер-функция) markdownToProseMirror(md): Promise<doc> ⚠️
markdown-document.ts serializeDocmostMarkdownBody, parseDocmostMarkdown, serializeDocmostMarkdown, тип DocmostMdMeta
canonicalize.ts canonicalizeContent(node), docsCanonicallyEqual(a,b)
docmost-schema.ts tiptap-схема для markdownToProseMirror
node-ops.ts, diff.ts трансформации/диф (нужны транзитивно)

⚠️ markdownToProseMirror физически лежит в collaboration.ts docmost-client
(строка 289) — это чистая функция (marked→HTML→generateJSON), не путать с
collab/websocket write-path из того же файла, который НЕ берём.

Долг (зафиксирован в бэклоге): это третья копия конвертера (есть в
docmost-sync, в packages/mcp, теперь в packages/git-sync). Конвергенция в
общий пакет — отдельная задача; здесь сознательно вендорим валидированную
копию ради сохранения идемпотентности.

2.3. НЕ берём

pull/push CLI-обёртки, roundtrip.ts (харнес переносим в тесты, см. §13),
docmost-client REST-клиент целиком, lib/collaboration.ts (websocket-write),
lib/auth-utils.ts, Makefile, Docker-обвязку docmost-sync.


3. Главный шов: GitmostDataSource

Движок дёргает Docmost через Pick<DocmostClient, …>. Мы реализуем класс,
структурно совместимый с этими сигнатурами, но нативный внутри. Это
единственный нетривиальный новый код.

3.1. Точный набор методов, которых требует движок

Из pull.ts (ApplyPullActionsDeps.client) и обхода дерева:

listSpaceTree(spaceId: string, rootPageId?: string): Promise<{ pages: PageNode[]; complete: boolean }>;
getPageJson(pageId: string): Promise<{ id; slugId; title; parentPageId; spaceId; updatedAt; content }>;

Из push.ts (ApplyPushDeps.client):

importPageMarkdown(pageId: string, fullMarkdown: string): Promise<{ updatedAt?: string; /* … */ }>;
createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{ data: { id: string }; updatedAt?: string }>;
deletePage(pageId: string): Promise<unknown>;
movePage(pageId: string, parentPageId: string | null, position?: string): Promise<unknown>;
renamePage(pageId: string, title: string): Promise<unknown>;

Для непрерывного режима/детекции удалений (фаза B+, SPEC §8):

listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise<any[]>;
listTrash(spaceId: string): Promise<any[]>;
restorePage(pageId: string): Promise<unknown>;

3.2. Маппинг на нативные сервисы gitmost

Метод адаптера Нативная реализация
listSpaceTree(spaceId) SpaceRepo.findById(spaceId, wsId) + PageRepo.getSpaceDescendants(spaceId, { includeContent: false }) → map в PageNode { id, title, slugId, parentPageId, hasChildren }. complete: true всегда (читаем БД, не пагинированный REST) → суппрессия incomplete-fetch из SPEC §8 нативно не срабатывает.
getPageJson(pageId) PageRepo.findById(pageId, { includeContent: true }){ id, slugId, title, parentPageId, spaceId, updatedAt, content }. content — ProseMirror JSON в схеме editor-ext.
importPageMarkdown(pageId, fullMd) parseDocmostMarkdown(fullMd) → body; await markdownToProseMirror(body) → doc; запись через collab (см. §3.3). Вернуть { updatedAt } свежей страницы.
createPage(title, body, spaceId, parent?) PageService.create(userId, wsId, { spaceId, title, parentPageId }, provenance) → shell; затем тело через collab (§3.3). Вернуть { data: { id }, updatedAt }.
deletePage(pageId) PageService.removePage(pageId, userId, wsId) (soft-delete → Trash, обратимо).
movePage(pageId, parent, pos?) PageService.movePage({ pageId, parentPageId: parent, position }, movedPage, provenance). position обязателен для Docmost-move — вычисляем fractional-indexing-jittered ключ между соседями (соседей берём из PageRepo).
renamePage(pageId, title) PageService.update(page, { title }, user, provenance).
listRecentSince PageRepo.getRecentPagesInSpace(spaceId, { … }), фильтр по updatedAt > since.
listTrash(spaceId) PageRepo запрос с deletedAt IS NOT NULL по спейсу.
restorePage(pageId) PageService.restore(...).

userId/wsId берём из конфигурации спейса (сервисный аккаунт воркспейса или
владелец спейса — см. §7). provenance всегда несёт source: 'git-sync' (§8).

3.3. Нативная запись тела (linchpin)

Подтверждено в коде: CollaborationGateway.openDirectConnection(documentName, context)
(collaboration.gateway.ts:148)

// In-process body write — no loopback websocket, no service-user token.
// Mirrors collaboration.handler.ts 'replace' operation exactly.
private async writeBody(pageId: string, prosemirrorJson: JSONContent): Promise<void> {
  const conn = await this.collabGateway.openDirectConnection(
    `page.${pageId}`,
    { actor: 'git-sync' }, // provenance flows into PersistenceExtension (see §8)
  );
  try {
    await conn.transact((doc) => {
      const fragment = doc.getXmlFragment('default');
      if (fragment.length > 0) fragment.delete(0, fragment.length);
      const next = TiptapTransformer.toYdoc(prosemirrorJson, 'default', tiptapExtensions);
      Y.applyUpdate(doc, Y.encodeStateAsUpdate(next));
    });
  } finally {
    await conn.disconnect();
  }
  // PersistenceExtension.onStoreDocument persists ydoc+content+textContent
  // consistently, stamps lastUpdatedSource, broadcasts 'page.updated'.
}

Схема-совместимость (критично). markdownToProseMirror производит
ProseMirror JSON в схеме docmost-client, а TiptapTransformer.toYdoc валидирует
его в схеме editor-ext. Аналогично на чтении convertProseMirrorToMarkdown
получает content в схеме editor-ext. Эти две схемы должны совпадать по
именам нод/марок/атрибутов
, иначе ноды потеряются. Это и есть гейт §13.1.


4. VaultGit и git-бинарь

VaultGit (engine/git.ts) оставляем как есть — он шеллит в системный git через
execFile (args-массив, без инъекций), всегда cwd=<vaultPath>. Константы:
DEFAULT_BRANCH = "main", BOT_AUTHOR_NAME = "Docmost Sync",
BOT_AUTHOR_EMAIL = "docmost-sync@local"; в push.ts: DOCMOST_BRANCH = "docmost",
LAST_PUSHED_REF = "refs/docmost/last-pushed", провенанс-трейлеры
Docmost-Sync-Source: docmost|local.

Ops-требование: в рантайм-образ gitmost добавить пакет git
(Dockerfile) — сейчас его там может не быть. Без бинаря
VaultGit.assertGitAvailable() падает на старте цикла.

Модель веток (пер-репо, SPEC §5): main (правит человек/файлы) ↔ docmost
(зеркало Docmost, пишет только движок) ↔ merge-base как базлайн;
refs/docmost/last-pushed — что из main уже отражено в Docmost.


5. Топология vault: репозиторий на спейс

  • Корень: <DATA_DIR>/git-sync/<spaceId>/ — отдельный git-репо на каждый
    включённый спейс. layout.ts уже спейс-скоупный (корень спейса → segments: []).
  • Remote — пер-спейс (из конфигурации спейса/ENV). Изоляция конфликтов, блокировок
    и blast-radius.
  • move-to-space (страница меняет спейс) → кросс-репо: delete в исходном
    репо + create в целевом. Ловим по событию PAGE_MOVED_TO_SPACE.
  • Redis-lock ключ — git-sync:lock:<spaceId> (§9).

6. NestJS-модуль GitSyncModule

Структура (шаблон — McpModule):

apps/server/src/integrations/git-sync/
  git-sync.module.ts
  git-sync.constants.ts                # QueueJob/event-имена, дефолты
  services/
    gitmost-datasource.service.ts      # §3 адаптер
    git-sync.orchestrator.ts           # @Interval + leader-lock + цикл по спейсам
    vault-registry.service.ts          # путь vault на спейс, VaultGit-инстансы
    fractional-index.util.ts           # position для move (reuse server util)
  listeners/
    page-change.listener.ts            # подписка на EventName.PAGE_* + debounce
  git-sync.controller.ts               # (опц.) ручной trigger/status для админа
@Module({
  imports: [DatabaseModule, EnvironmentModule, ScheduleModule.forRoot()],
  providers: [
    GitmostDataSourceService,
    GitSyncOrchestrator,
    VaultRegistryService,
    PageChangeListener,
  ],
})
export class GitSyncModule {}
  • Регистрируем в app.module.ts рядом с McpModule.
  • Зависимости: PageRepo/SpaceRepo (через DatabaseModule), PageService,
    CollaborationGateway (экспортировать из CollaborationModule),
    EnvironmentService, ioredis-клиент.
  • ScheduleModule.forRoot() уже подключается в TelemetryModule; повторный вызов
    безопасен, но лучше вынести в общий модуль или убедиться, что forRoot один раз.

7. Конфигурация

7.1. Per-space (UI) — space.settings.gitSync

Расширяем существующий паттерн settings.sharing / settings.comments.

Сервер:

  • UpdateSpaceDto (update-space.dto.ts):
    добавить @IsOptional() @IsBoolean() gitSyncEnabled?: boolean; (+ опц.
    gitSyncRemote?: string, если решим хранить remote в БД, а не только в ENV).
  • SpaceService.updateSpace(dto, wsId)
    (space.service.ts:120):
    обработать как disablePublicSharing/allowViewerComments.
  • SpaceRepo: добавить updateGitSyncSettings(spaceId, wsId, prefKey, prefValue, trx?)
    по образцу updateSharingSettings
    (space.repo.ts:92) —
    jsonb-merge в settings.gitSync.<key>.
  • Гард: CASL SpaceCaslAction.Manage / SpaceCaslSubject.Settings (как в
    space.controller.ts:147).

Клиент:

  • Тоггл в форме настроек спейса
    (edit-space-form.tsx)
    через useUpdateSpaceMutation()updateSpace({ spaceId, gitSyncEnabled }).
    Образец — mcp-settings.tsx. readOnly при отсутствии Manage/Settings.

Форма space.settings.gitSync:

{ "gitSync": { "enabled": true, "remote": "git@…", "branch": "main" } }

7.2. Секреты/тюнинг (ENV) — EnvironmentService

Движковый settings.ts (zod, читает .env) заменяем на чтение из gitmost
EnvironmentService: parseSettings(env) оставляем как чистую функцию для тестов,
но в проде собираем Settings из EnvironmentService-геттеров.

Новые переменные (объявить в
environment.validation.ts
class-validator-декораторами, геттеры — в
environment.service.ts):

ENV Назначение Обяз.
GIT_SYNC_ENABLED глобальный мастер-выключатель нет (default false)
GIT_SYNC_DATA_DIR корень vault'ов (default <DATA_DIR>/git-sync) нет
GIT_SYNC_REMOTE_TEMPLATE шаблон remote, напр. git@host:vault-{spaceId}.git нет
GIT_SYNC_SSH_KEY_PATH / креды remote доступ к git-remote (secret) по ситуации
GIT_SYNC_POLL_INTERVAL_MS страховочный поллинг (default 15000) нет
GIT_SYNC_DEBOUNCE_MS окно дебаунса событий (default 2000) нет
GIT_SYNC_SERVICE_USER_ID от чьего имени писать в Docmost да (если синк включён)

git-remote = доступ ко всей вики спейса (SPEC §12): креды только в ENV/secret
store, никогда в БД/коммиты. В UI — только enabled (+ опц. имя remote из
заранее разрешённого списка).


8. Провенанс и loop-guard

8.1. Значение 'git-sync'

Сегодня lastUpdatedSource ∈ { 'user', 'agent' }
(persistence.extension.ts:132-134).
Добавляем 'git-sync':

  • PersistenceExtension: context.actor === 'git-sync'lastUpdatedSource = 'git-sync'.
  • Снапшот истории для 'git-sync' — дебаунс (как у человека), а не немедленный
    (немедленный — только для 'agent',
    persistence.extension.ts:321).
  • Для create/move/rename/delete через PageService передаём
    AuthProvenanceData c source: 'git-sync' (тип уже используется для агента —
    расширить допустимые значения; точную форму подтвердить на реализации).
  • Клиент: в истории
    (history-item.tsx:128)
    не показывать агентский бейдж/дип-линк для 'git-sync'; добавить значение в
    тип page.types.ts:23-26
    (опц. свой бейдж «sync»).

8.2. Подавление петли (SPEC §10)

На pull-стороне игнорируем страницу как «свою запись», если:
page.lastUpdatedSource === 'git-sync' И bodyHash(exportedBody) совпадает
с последним запушенным (PushedPageRecord.bodyHash из push.ts). После записи в
Docmost сохраняем updatedAt ответа, чтобы поллинг-страховка не утянул свою же
запись обратно.


9. Single-writer (Redis leader-lock)

В кодовой базе @Interval-задачи (trash-cleanup, telemetry, session-cleanup)
не защищены от мультиинстанса. Для синка добавляем явный лок.

  • ioredis уже есть (RedisModule из @nestjs-labs/nestjs-ioredis,
    app.module.ts; прямой RedisClient
    используется в collab-gateway).
  • Лок на спейс: SET git-sync:lock:<spaceId> <instanceId> NX PX <ttl>; держим
    цикл только при успехе, продлеваем по heartbeat, освобождаем в finally
    (Lua-CAS на удаление по instanceId, чтобы не снять чужой лок).
  • TTL > максимальной длительности цикла; на краше лок истекает сам.
// Acquire per-space leadership; returns false if another replica holds it.
private async acquire(spaceId: string): Promise<boolean> {
  const ok = await this.redis.set(`git-sync:lock:${spaceId}`, this.instanceId, 'PX', LOCK_TTL_MS, 'NX');
  return ok === 'OK';
}

10. Планировщик и событийные триггеры

  • События (основной триггер). PageChangeListener подписывается на
    EventName.PAGE_CREATED | PAGE_UPDATED | PAGE_MOVED | PAGE_SOFT_DELETED | PAGE_RESTORED | PAGE_MOVED_TO_SPACE и job PAGE_CONTENT_UPDATED
    (event.contants.ts).
    Фильтр по spaceId (только включённые спейсы) → дебаунс (GIT_SYNC_DEBOUNCE_MS)
    → ставит pull/push-цикл спейса в очередь оркестратора.
    • Loop-guard: события от собственных записей (source==='git-sync' + совпавший
      хэш) пропускаем (§8.2).
  • Поллинг-страховка. @Interval(GIT_SYNC_POLL_INTERVAL_MS) в оркестраторе:
    по каждому включённому спейсу (под локом) — реконсиляция (listRecentSince +
    listTrash), ловит пропущенные события и стартовую сверку после простоя
    (SPEC §12).
  • Один цикл на спейс за раз (внутри-процессный мьютекс на spaceId поверх
    Redis-лока).

11. Потоки данных (walkthroughs)

11.1. Первичный клон спейса (initial clone, SPEC §12)

  1. VaultGit.ensureRepo() + ensureBranch('docmost','main') + checkout('docmost').
  2. dataSource.listSpaceTree(spaceId){ pages, complete:true }.
  3. readExisting({ listTracked: () => git.listTrackedFiles('*.md'), readFile }).
  4. computePullActions({ pages, treeComplete:true, existing }) → план.
  5. applyPullActions(deps, actions, vaultRoot): на каждую страницу
    getPageJsonstabilizePageFile(content, meta) (export→import→export
    fixpoint, SPEC §11) → запись файла; затем stageAll + commit (трейлер
    docmost) на docmost; checkout('main') + merge('docmost').
  6. Зафиксировать max updatedAt как стартовый T_last; git push в remote.

11.2. Docmost → FS (pull-цикл)

Триггер: событие/поллинг → (под локом) шаги §11.1 п.1–5 инкрементально. 3-way
merge docmost→main делает git: непересекающиеся правки сливаются, реальное
пересечение → conflict-маркеры в файле. При конфликте push этой страницы в
Docmost блокируется
до ручного резолва (SPEC §9; фаза D).

11.3. FS → Docmost (push-цикл)

runPush(deps, { dryRun }):

  1. git.ensureRepo / isMergeInProgress (abort при merge) / checkout('main').
  2. stageAll + commit('local: working-tree changes') (локально, в Docmost не шлёт).
  3. База диффа: readRef(LAST_PUSHED_REF) ?? docmost; revParse('main')pushedCommit.
  4. diffNameStatus(base, 'main') → changes; префетч metaAt(path, side).
  5. computePushActions({ changes, metaAt }) → creates/updates/deletes/renamesMoves/skipped.
  6. dryRun → лог плана и выход (клиент НЕ создаётся).
  7. --apply: makeClient(settings) → наш GitmostDataSource;
    applyPushActions:
    • update → importPageMarkdown(pageId, fullMd) (collab-write, §3.3);
    • create → createPage(...) → записать присвоенный pageId обратно в meta;
    • delete → deletePage(pageId) (Trash);
    • rename/move → classifyRenameMovesmovePage/renamePage;
    • при пустых failures: updateRef(LAST_PUSHED_REF, pushedCommit) +
      fastForwardBranch('docmost', pushedCommit).
  8. Записать bodyHash + updatedAt (loop-guard, §8.2); git push.

12. Фазирование

  • A. Каркас + односторонний pull (нативно). packages/git-sync (вендоринг
    §2), GitmostDataSource (чтение через репозитории), GitSyncModule, конфиг из
    EnvironmentService, ручной/однократный pull-цикл на один спейс. Гейт §13.1.
  • B. Push + непрерывность. Нативная запись (§3.3), runPush, ветки/refs,
    loop-guard (§8), Redis-лок (§9), @Interval + PageChangeListener (§10).
  • C. Per-space UI. space.settings.gitSync (§7.1), DTO/сервис/репо/гард,
    тоггл на клиенте, скоуп оркестратора по включённым спейсам.
  • D. Харднинг. Conflict-gating (SPEC §9), удаления через Trash + git (§5),
    стартовая реконсиляция и move-to-space кросс-репо, провенанс на клиенте,
    Dockerfile git, полный набор тестов.

13. Тестирование

13.1. Гейт идемпотентности (блокирует фазу B)

Перенести round-trip-харнес docmost-sync (roundtrip.ts + test/fixtures/corpus)
в тесты packages/git-sync, но прогонять против схемы editor-ext:
content (editor-ext) → convertProseMirrorToMarkdown → markdownToProseMirror → TiptapTransformer.toYdoc(…, tiptapExtensions) → fromYdoc → canonicalizeContent
должно давать docsCanonicallyEqual === true. Любая потеря нод/атрибутов =
расхождение схем → чинить docmost-schema.ts под editor-ext.

13.2. Юнит (чистая логика, переносится как есть)

reconcile (planReconciliation / decideAbsenceDeletions / mass-delete guards),
layout (коллизии/санитизация), computePullActions, computePushActions,
classifyRenameMoves, bodyHash.

13.3. Интеграция (нативный адаптер)

GitmostDataSource против тестовой БД: listSpaceTree/getPageJson корректно
маппят; createPage/movePage/deletePage/importPageMarkdown пишут через
collab и проставляют lastUpdatedSource='git-sync'; loop-guard не зацикливается
(write → poll → no-op).

13.4. e2e (под локом)

Полный pull→push round-trip на временном vault + временном спейсе: правка в
Docmost доезжает в файл и наоборот; конфликт даёт маркеры и блокирует push.


14. Риски и открытые пункты

  1. Схема-совместимость конвертера (§3.3, §13.1) — главный риск; гейт
    обязателен до фазы B.
  2. AuthProvenanceData — точную форму типа подтвердить; возможно, потребует
    расширения enum источника на сервере и в истории.
  3. Согласованность Yjs — писать строго через openDirectConnection/transact;
    не трогать content-колонку напрямую.
  4. position для move — обязателен в Docmost-move; нужен
    fractional-indexing-jittered между соседями (соседей брать сортировкой
    position COLLATE "C").
  5. git в рантайме — добавить в Dockerfile.
  6. ScheduleModule.forRoot() — не задублировать forRoot.
  7. Сервисный пользователь записи (GIT_SYNC_SERVICE_USER_ID) — от чьего имени
    идут create/move (влияет на creatorId/права); согласовать политику.
  8. Конфликты и удаления — фаза D строго по SPEC §8/§9 (маркеры никогда не
    уезжают в Docmost).

15. Чек-лист изменений по файлам

Новый пакет

  • packages/git-sync/** — движок + чистый конвертер (§2), package.json
    (@docmost/git-sync, workspace:*), tsconfig.json.

Сервер (apps/server/src)

  • integrations/git-sync/** — модуль, оркестратор, адаптер, листенер (§6).
  • app.module.ts — импорт GitSyncModule.
  • collaboration/collaboration.module.ts — экспорт CollaborationGateway.
  • collaboration/extensions/persistence.extension.ts — источник 'git-sync' (§8.1).
  • core/space/dto/update-space.dto.tsgitSyncEnabled? (§7.1).
  • core/space/services/space.service.ts — обработка флага.
  • database/repos/space/space.repo.tsupdateGitSyncSettings (§7.1).
  • integrations/environment/environment.validation.ts + environment.service.ts
    новые ENV (§7.2).
  • Dockerfile — пакет git.

Клиент (apps/client/src)

  • features/space/components/edit-space-form.tsx — тоггл git-sync.
  • features/space/types — поле settings.gitSync.
  • features/page-history/types/page.types.ts + components/history-item.tsx
    значение 'git-sync' в lastUpdatedSource.

Корень

  • pnpm-workspace.yaml уже покрывает packages/*; apps/server/package.json
    зависимость @docmost/git-sync: workspace:*.
> Перенесено из `docs/git-sync-plan.md` (файл удалён; этот issue — единственный носитель спецификации). Ссылки вида `../apps/...` и `backlog/...` в тексте указывают на пути в репозитории на момент написания. --- # Git-sync: спека реализации (встраивание docmost-sync в gitmost) Статус: **спецификация, код не менялся.** Детальный план реализации фичи «двусторонний синк страниц Docmost ↔ локальная git-папка Markdown», встроенной прямо в gitmost. Источник движка: `https://gitea.vvzvlad.xyz/vvzvlad/docmost-sync` (ветка `main`, на момент спеки HEAD `b03eb35`). Все сигнатуры ниже сверены с этим исходником и с текущим кодом gitmost. Предыстория и обоснование архитектурных развилок — в бэклоге [ai-chat-tool-definitions-duplicated.md](backlog/ai-chat-tool-definitions-duplicated.md) (раздел про дублирование конвертера) и в исходном `SPEC.md` репозитория docmost-sync (нумерация §-параграфов ниже ссылается на него). --- ## 0. Зафиксированные решения Из обсуждения архитектуры (выбор пользователя) и трёх суб-решений: 1. **Нативная in-process интеграция.** Никаких REST-к-себе и сервис-юзера: чтение через репозитории gitmost, запись тела — через collab `openDirectConnection`, триггеры — через `EventEmitter2` вместо поллинга `/recent`. 2. **Встроенный NestJS-модуль** `GitSyncModule` в `apps/server/src/integrations/git-sync` с `@Interval`/событиями и **leader-lock на Redis** (single-writer при нескольких репликах). 3. **Настройка по спейсам в UI** — флаг в `space.settings.gitSync`, секреты (git-remote) — через ENV/`EnvironmentService`. 4. **Конвертер** — вендорим *чистую* часть из docmost-sync в `packages/git-sync`, гейт = round-trip-идемпотентность против схемы `@docmost/editor-ext`. 5. **Vault** — **репозиторий на спейс**; `move-to-space` = кросс-репо delete+create. 6. **Провенанс** — отдельное значение `lastUpdatedSource = 'git-sync'`. Вне scope v1 (как и в SPEC): комментарии (только якоря, без тредов), права/ACL, вложения как отдельный поток (едут ссылками внутри контента), realtime-подписка на Hocuspocus (остаётся поллинг-страховка + события). --- ## 1. Архитектура верхнего уровня ``` gitmost server (NestJS, один процесс) ┌─────────────────────────────────────────────────────────────┐ │ GitSyncModule │ │ │ │ GitSyncOrchestrator ── @Interval + Redis leader-lock │ │ │ (per enabled space: pull-cycle / push-cycle) │ │ │ │ │ ├── engine (vendored docmost-sync, IO инжектируется) │ │ │ pull.ts / push.ts / reconcile / layout / stabilize │ │ │ │ │ ├── GitmostDataSource ── реализует подмножество │ │ │ DocmostClient НАТИВНО: │ │ │ reads → PageRepo / SpaceRepo (Kysely) │ │ │ writes → CollaborationGateway.openDirectConnection│ │ │ + PageService (create/move/delete/...) │ │ │ │ │ └── VaultGit ── shell-out в системный git (как есть) │ │ │ │ PageChangeListener ── подписка на EventName.PAGE_* → │ │ debounce → enqueue push-cycle │ └─────────────────────────────────────────────────────────────┘ ▲ читает/пишет страницы ▼ git push/pull PostgreSQL (pages/spaces) data/git-sync/<spaceId>/ (vault) → remote ``` Ключ интеграции: движок docmost-sync уже **полностью построен на dependency injection** — весь внешний IO (REST-клиент, git, файловая система) передаётся через узкие интерфейсы. Мы НЕ переписываем движок; мы подставляем нативные реализации в его DI-швы. --- ## 2. Состав вендоринга из docmost-sync В новый пакет `packages/git-sync` копируем (с сохранением истории смысла — backport-friendly, как сделано с `packages/mcp`): ### 2.1. Движок (engine) — `src/engine/` | Файл | Что несёт | IO | Берём | | --- | --- | --- | --- | | `pull.ts` | Docmost→FS: reconcile + write + commit + merge | client+git+fs (инжектируется) | да | | `push.ts` | FS→Docmost: diff + classify + apply + refs | client+git+fs (инжектируется) | да | | `git.ts` | `VaultGit` — обёртка git shell-out | системный `git` | да, как есть | | `reconcile.ts` | чистый планировщик | нет | да | | `layout.ts` | чистый маппер дерево→пути | нет | да | | `sanitize.ts` | чистая санитизация имён | нет | да | | `stabilize.ts` | fixpoint-нормализация md (SPEC §11) | нет (lib-вызовы) | да | | `loop-guard.ts` | `bodyHash` (sha256) | нет | да | | `settings.ts` | zod-конфиг | `.env` | **адаптируем** (см. §7) | | `index.ts` | тонкий CLI-скаффолд | — | нет (заменяем на NestJS) | ### 2.2. Конвертер (чистая часть) — `src/lib/` Из `packages/docmost-client/src/lib/` берём **только** чистый конвертер и формат файла (collab/auth REST-части НЕ нужны — запись нативная): | Файл | Экспорт | | --- | --- | | `markdown-converter.ts` | `convertProseMirrorToMarkdown(content): string` | | `collaboration.ts` (только конвертер-функция) | `markdownToProseMirror(md): Promise<doc>` ⚠️ | | `markdown-document.ts` | `serializeDocmostMarkdownBody`, `parseDocmostMarkdown`, `serializeDocmostMarkdown`, тип `DocmostMdMeta` | | `canonicalize.ts` | `canonicalizeContent(node)`, `docsCanonicallyEqual(a,b)` | | `docmost-schema.ts` | tiptap-схема для `markdownToProseMirror` | | `node-ops.ts`, `diff.ts` | трансформации/диф (нужны транзитивно) | ⚠️ `markdownToProseMirror` физически лежит в `collaboration.ts` docmost-client (строка 289) — это **чистая** функция (marked→HTML→generateJSON), не путать с collab/websocket write-path из того же файла, который НЕ берём. > **Долг (зафиксирован в бэклоге):** это третья копия конвертера (есть в > docmost-sync, в `packages/mcp`, теперь в `packages/git-sync`). Конвергенция в > общий пакет — отдельная задача; здесь сознательно вендорим валидированную > копию ради сохранения идемпотентности. ### 2.3. НЕ берём `pull`/`push` CLI-обёртки, `roundtrip.ts` (харнес переносим в тесты, см. §13), `docmost-client` REST-клиент целиком, `lib/collaboration.ts` (websocket-write), `lib/auth-utils.ts`, `Makefile`, Docker-обвязку docmost-sync. --- ## 3. Главный шов: `GitmostDataSource` Движок дёргает Docmost через `Pick<DocmostClient, …>`. Мы реализуем класс, **структурно совместимый** с этими сигнатурами, но нативный внутри. Это единственный нетривиальный новый код. ### 3.1. Точный набор методов, которых требует движок Из `pull.ts` (`ApplyPullActionsDeps.client`) и обхода дерева: ```ts listSpaceTree(spaceId: string, rootPageId?: string): Promise<{ pages: PageNode[]; complete: boolean }>; getPageJson(pageId: string): Promise<{ id; slugId; title; parentPageId; spaceId; updatedAt; content }>; ``` Из `push.ts` (`ApplyPushDeps.client`): ```ts importPageMarkdown(pageId: string, fullMarkdown: string): Promise<{ updatedAt?: string; /* … */ }>; createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{ data: { id: string }; updatedAt?: string }>; deletePage(pageId: string): Promise<unknown>; movePage(pageId: string, parentPageId: string | null, position?: string): Promise<unknown>; renamePage(pageId: string, title: string): Promise<unknown>; ``` Для непрерывного режима/детекции удалений (фаза B+, SPEC §8): ```ts listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise<any[]>; listTrash(spaceId: string): Promise<any[]>; restorePage(pageId: string): Promise<unknown>; ``` ### 3.2. Маппинг на нативные сервисы gitmost | Метод адаптера | Нативная реализация | | --- | --- | | `listSpaceTree(spaceId)` | `SpaceRepo.findById(spaceId, wsId)` + `PageRepo.getSpaceDescendants(spaceId, { includeContent: false })` → map в `PageNode { id, title, slugId, parentPageId, hasChildren }`. **`complete: true` всегда** (читаем БД, не пагинированный REST) → суппрессия `incomplete-fetch` из SPEC §8 нативно не срабатывает. | | `getPageJson(pageId)` | `PageRepo.findById(pageId, { includeContent: true })` → `{ id, slugId, title, parentPageId, spaceId, updatedAt, content }`. `content` — ProseMirror JSON в схеме `editor-ext`. | | `importPageMarkdown(pageId, fullMd)` | `parseDocmostMarkdown(fullMd)` → body; `await markdownToProseMirror(body)` → doc; **запись через collab** (см. §3.3). Вернуть `{ updatedAt }` свежей страницы. | | `createPage(title, body, spaceId, parent?)` | `PageService.create(userId, wsId, { spaceId, title, parentPageId }, provenance)` → shell; затем тело через collab (§3.3). Вернуть `{ data: { id }, updatedAt }`. | | `deletePage(pageId)` | `PageService.removePage(pageId, userId, wsId)` (soft-delete → Trash, обратимо). | | `movePage(pageId, parent, pos?)` | `PageService.movePage({ pageId, parentPageId: parent, position }, movedPage, provenance)`. **`position` обязателен** для Docmost-move — вычисляем `fractional-indexing-jittered` ключ между соседями (соседей берём из `PageRepo`). | | `renamePage(pageId, title)` | `PageService.update(page, { title }, user, provenance)`. | | `listRecentSince` | `PageRepo.getRecentPagesInSpace(spaceId, { … })`, фильтр по `updatedAt > since`. | | `listTrash(spaceId)` | `PageRepo` запрос с `deletedAt IS NOT NULL` по спейсу. | | `restorePage(pageId)` | `PageService.restore(...)`. | `userId`/`wsId` берём из конфигурации спейса (сервисный аккаунт воркспейса или владелец спейса — см. §7). `provenance` всегда несёт `source: 'git-sync'` (§8). ### 3.3. Нативная запись тела (linchpin) Подтверждено в коде: `CollaborationGateway.openDirectConnection(documentName, context)` ([collaboration.gateway.ts:148](../apps/server/src/collaboration/collaboration.gateway.ts#L148-L150)) + паттерн `withYdocConnection` ([collaboration.handler.ts:118-133](../apps/server/src/collaboration/collaboration.handler.ts#L118-L133)). Имя документа — `page.<pageId>` ([getPageId](../apps/server/src/collaboration/collaboration.util.ts#L163-L165)). Схему берём из `tiptapExtensions` ([collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)). ```ts // In-process body write — no loopback websocket, no service-user token. // Mirrors collaboration.handler.ts 'replace' operation exactly. private async writeBody(pageId: string, prosemirrorJson: JSONContent): Promise<void> { const conn = await this.collabGateway.openDirectConnection( `page.${pageId}`, { actor: 'git-sync' }, // provenance flows into PersistenceExtension (see §8) ); try { await conn.transact((doc) => { const fragment = doc.getXmlFragment('default'); if (fragment.length > 0) fragment.delete(0, fragment.length); const next = TiptapTransformer.toYdoc(prosemirrorJson, 'default', tiptapExtensions); Y.applyUpdate(doc, Y.encodeStateAsUpdate(next)); }); } finally { await conn.disconnect(); } // PersistenceExtension.onStoreDocument persists ydoc+content+textContent // consistently, stamps lastUpdatedSource, broadcasts 'page.updated'. } ``` **Схема-совместимость (критично).** `markdownToProseMirror` производит ProseMirror JSON в схеме docmost-client, а `TiptapTransformer.toYdoc` валидирует его в схеме `editor-ext`. Аналогично на чтении `convertProseMirrorToMarkdown` получает `content` в схеме `editor-ext`. Эти две схемы **должны совпадать по именам нод/марок/атрибутов**, иначе ноды потеряются. Это и есть гейт §13.1. --- ## 4. `VaultGit` и git-бинарь `VaultGit` (engine/git.ts) оставляем как есть — он шеллит в системный `git` через `execFile` (args-массив, без инъекций), всегда `cwd=<vaultPath>`. Константы: `DEFAULT_BRANCH = "main"`, `BOT_AUTHOR_NAME = "Docmost Sync"`, `BOT_AUTHOR_EMAIL = "docmost-sync@local"`; в push.ts: `DOCMOST_BRANCH = "docmost"`, `LAST_PUSHED_REF = "refs/docmost/last-pushed"`, провенанс-трейлеры `Docmost-Sync-Source: docmost|local`. **Ops-требование:** в рантайм-образ gitmost добавить пакет `git` ([Dockerfile](../Dockerfile)) — сейчас его там может не быть. Без бинаря `VaultGit.assertGitAvailable()` падает на старте цикла. **Модель веток (пер-репо, SPEC §5):** `main` (правит человек/файлы) ↔ `docmost` (зеркало Docmost, пишет только движок) ↔ `merge-base` как базлайн; `refs/docmost/last-pushed` — что из `main` уже отражено в Docmost. --- ## 5. Топология vault: репозиторий на спейс - Корень: `<DATA_DIR>/git-sync/<spaceId>/` — отдельный git-репо на каждый включённый спейс. `layout.ts` уже спейс-скоупный (корень спейса → `segments: []`). - Remote — пер-спейс (из конфигурации спейса/ENV). Изоляция конфликтов, блокировок и blast-radius. - `move-to-space` (страница меняет спейс) → **кросс-репо**: `delete` в исходном репо + `create` в целевом. Ловим по событию `PAGE_MOVED_TO_SPACE`. - Redis-lock ключ — `git-sync:lock:<spaceId>` (§9). --- ## 6. NestJS-модуль `GitSyncModule` Структура (шаблон — `McpModule`): ``` apps/server/src/integrations/git-sync/ git-sync.module.ts git-sync.constants.ts # QueueJob/event-имена, дефолты services/ gitmost-datasource.service.ts # §3 адаптер git-sync.orchestrator.ts # @Interval + leader-lock + цикл по спейсам vault-registry.service.ts # путь vault на спейс, VaultGit-инстансы fractional-index.util.ts # position для move (reuse server util) listeners/ page-change.listener.ts # подписка на EventName.PAGE_* + debounce git-sync.controller.ts # (опц.) ручной trigger/status для админа ``` ```ts @Module({ imports: [DatabaseModule, EnvironmentModule, ScheduleModule.forRoot()], providers: [ GitmostDataSourceService, GitSyncOrchestrator, VaultRegistryService, PageChangeListener, ], }) export class GitSyncModule {} ``` - Регистрируем в [app.module.ts](../apps/server/src/app.module.ts) рядом с `McpModule`. - Зависимости: `PageRepo`/`SpaceRepo` (через `DatabaseModule`), `PageService`, `CollaborationGateway` (экспортировать из `CollaborationModule`), `EnvironmentService`, ioredis-клиент. - `ScheduleModule.forRoot()` уже подключается в `TelemetryModule`; повторный вызов безопасен, но лучше вынести в общий модуль или убедиться, что forRoot один раз. --- ## 7. Конфигурация ### 7.1. Per-space (UI) — `space.settings.gitSync` Расширяем существующий паттерн `settings.sharing` / `settings.comments`. Сервер: - `UpdateSpaceDto` ([update-space.dto.ts](../apps/server/src/core/space/dto/update-space.dto.ts)): добавить `@IsOptional() @IsBoolean() gitSyncEnabled?: boolean;` (+ опц. `gitSyncRemote?: string`, если решим хранить remote в БД, а не только в ENV). - `SpaceService.updateSpace(dto, wsId)` ([space.service.ts:120](../apps/server/src/core/space/services/space.service.ts#L120)): обработать как `disablePublicSharing`/`allowViewerComments`. - `SpaceRepo`: добавить `updateGitSyncSettings(spaceId, wsId, prefKey, prefValue, trx?)` по образцу `updateSharingSettings` ([space.repo.ts:92](../apps/server/src/database/repos/space/space.repo.ts#L92)) — jsonb-merge в `settings.gitSync.<key>`. - Гард: CASL `SpaceCaslAction.Manage / SpaceCaslSubject.Settings` (как в [space.controller.ts:147](../apps/server/src/core/space/space.controller.ts#L147)). Клиент: - Тоггл в форме настроек спейса ([edit-space-form.tsx](../apps/client/src/features/space/components/edit-space-form.tsx)) через `useUpdateSpaceMutation()` → `updateSpace({ spaceId, gitSyncEnabled })`. Образец — `mcp-settings.tsx`. `readOnly` при отсутствии `Manage/Settings`. Форма `space.settings.gitSync`: ```jsonc { "gitSync": { "enabled": true, "remote": "git@…", "branch": "main" } } ``` ### 7.2. Секреты/тюнинг (ENV) — `EnvironmentService` Движковый `settings.ts` (zod, читает `.env`) **заменяем** на чтение из gitmost `EnvironmentService`: `parseSettings(env)` оставляем как чистую функцию для тестов, но в проде собираем `Settings` из `EnvironmentService`-геттеров. Новые переменные (объявить в [environment.validation.ts](../apps/server/src/integrations/environment/environment.validation.ts) class-validator-декораторами, геттеры — в [environment.service.ts](../apps/server/src/integrations/environment/environment.service.ts)): | ENV | Назначение | Обяз. | | --- | --- | --- | | `GIT_SYNC_ENABLED` | глобальный мастер-выключатель | нет (default false) | | `GIT_SYNC_DATA_DIR` | корень vault'ов (default `<DATA_DIR>/git-sync`) | нет | | `GIT_SYNC_REMOTE_TEMPLATE` | шаблон remote, напр. `git@host:vault-{spaceId}.git` | нет | | `GIT_SYNC_SSH_KEY_PATH` / креды remote | доступ к git-remote (secret) | по ситуации | | `GIT_SYNC_POLL_INTERVAL_MS` | страховочный поллинг (default 15000) | нет | | `GIT_SYNC_DEBOUNCE_MS` | окно дебаунса событий (default 2000) | нет | | `GIT_SYNC_SERVICE_USER_ID` | от чьего имени писать в Docmost | да (если синк включён) | > git-remote = доступ ко всей вики спейса (SPEC §12): креды только в ENV/secret > store, никогда в БД/коммиты. В UI — только `enabled` (+ опц. имя remote из > заранее разрешённого списка). --- ## 8. Провенанс и loop-guard ### 8.1. Значение `'git-sync'` Сегодня `lastUpdatedSource ∈ { 'user', 'agent' }` ([persistence.extension.ts:132-134](../apps/server/src/collaboration/extensions/persistence.extension.ts#L132-L134)). Добавляем `'git-sync'`: - `PersistenceExtension`: `context.actor === 'git-sync'` → `lastUpdatedSource = 'git-sync'`. - Снапшот истории для `'git-sync'` — дебаунс (как у человека), а не немедленный (немедленный — только для `'agent'`, [persistence.extension.ts:321](../apps/server/src/collaboration/extensions/persistence.extension.ts#L321)). - Для `create/move/rename/delete` через `PageService` передаём `AuthProvenanceData` c `source: 'git-sync'` (тип уже используется для агента — расширить допустимые значения; точную форму подтвердить на реализации). - Клиент: в истории ([history-item.tsx:128](../apps/client/src/features/page-history/components/history-item.tsx#L128)) не показывать агентский бейдж/дип-линк для `'git-sync'`; добавить значение в тип [page.types.ts:23-26](../apps/client/src/features/page-history/types/page.types.ts#L23-L26) (опц. свой бейдж «sync»). ### 8.2. Подавление петли (SPEC §10) На pull-стороне игнорируем страницу как «свою запись», если: `page.lastUpdatedSource === 'git-sync'` **И** `bodyHash(exportedBody)` совпадает с последним запушенным (`PushedPageRecord.bodyHash` из `push.ts`). После записи в Docmost сохраняем `updatedAt` ответа, чтобы поллинг-страховка не утянул свою же запись обратно. --- ## 9. Single-writer (Redis leader-lock) В кодовой базе `@Interval`-задачи (`trash-cleanup`, `telemetry`, `session-cleanup`) **не защищены** от мультиинстанса. Для синка добавляем явный лок. - ioredis уже есть (`RedisModule` из `@nestjs-labs/nestjs-ioredis`, [app.module.ts](../apps/server/src/app.module.ts); прямой `RedisClient` используется в collab-gateway). - Лок на спейс: `SET git-sync:lock:<spaceId> <instanceId> NX PX <ttl>`; держим цикл только при успехе, продлеваем по heartbeat, освобождаем в `finally` (Lua-CAS на удаление по `instanceId`, чтобы не снять чужой лок). - TTL > максимальной длительности цикла; на краше лок истекает сам. ```ts // Acquire per-space leadership; returns false if another replica holds it. private async acquire(spaceId: string): Promise<boolean> { const ok = await this.redis.set(`git-sync:lock:${spaceId}`, this.instanceId, 'PX', LOCK_TTL_MS, 'NX'); return ok === 'OK'; } ``` --- ## 10. Планировщик и событийные триггеры - **События (основной триггер).** `PageChangeListener` подписывается на `EventName.PAGE_CREATED | PAGE_UPDATED | PAGE_MOVED | PAGE_SOFT_DELETED | PAGE_RESTORED | PAGE_MOVED_TO_SPACE` и job `PAGE_CONTENT_UPDATED` ([event.contants.ts](../apps/server/src/common/events/event.contants.ts)). Фильтр по `spaceId` (только включённые спейсы) → дебаунс (`GIT_SYNC_DEBOUNCE_MS`) → ставит pull/push-цикл спейса в очередь оркестратора. - Loop-guard: события от собственных записей (`source==='git-sync'` + совпавший хэш) пропускаем (§8.2). - **Поллинг-страховка.** `@Interval(GIT_SYNC_POLL_INTERVAL_MS)` в оркестраторе: по каждому включённому спейсу (под локом) — реконсиляция (`listRecentSince` + `listTrash`), ловит пропущенные события и стартовую сверку после простоя (SPEC §12). - Один цикл на спейс за раз (внутри-процессный мьютекс на `spaceId` поверх Redis-лока). --- ## 11. Потоки данных (walkthroughs) ### 11.1. Первичный клон спейса (initial clone, SPEC §12) 1. `VaultGit.ensureRepo()` + `ensureBranch('docmost','main')` + `checkout('docmost')`. 2. `dataSource.listSpaceTree(spaceId)` → `{ pages, complete:true }`. 3. `readExisting({ listTracked: () => git.listTrackedFiles('*.md'), readFile })`. 4. `computePullActions({ pages, treeComplete:true, existing })` → план. 5. `applyPullActions(deps, actions, vaultRoot)`: на каждую страницу `getPageJson` → `stabilizePageFile(content, meta)` (export→import→export fixpoint, SPEC §11) → запись файла; затем `stageAll` + `commit` (трейлер `docmost`) на `docmost`; `checkout('main')` + `merge('docmost')`. 6. Зафиксировать max `updatedAt` как стартовый `T_last`; `git push` в remote. ### 11.2. Docmost → FS (pull-цикл) Триггер: событие/поллинг → (под локом) шаги §11.1 п.1–5 инкрементально. 3-way merge `docmost→main` делает git: непересекающиеся правки сливаются, реальное пересечение → conflict-маркеры в файле. **При конфликте push этой страницы в Docmost блокируется** до ручного резолва (SPEC §9; фаза D). ### 11.3. FS → Docmost (push-цикл) `runPush(deps, { dryRun })`: 1. `git.ensureRepo` / `isMergeInProgress` (abort при merge) / `checkout('main')`. 2. `stageAll` + `commit('local: working-tree changes')` (локально, в Docmost не шлёт). 3. База диффа: `readRef(LAST_PUSHED_REF)` ?? `docmost`; `revParse('main')` → `pushedCommit`. 4. `diffNameStatus(base, 'main')` → changes; префетч `metaAt(path, side)`. 5. `computePushActions({ changes, metaAt })` → creates/updates/deletes/renamesMoves/skipped. 6. `dryRun` → лог плана и выход (клиент НЕ создаётся). 7. `--apply`: `makeClient(settings)` → наш `GitmostDataSource`; `applyPushActions`: - update → `importPageMarkdown(pageId, fullMd)` (collab-write, §3.3); - create → `createPage(...)` → записать присвоенный `pageId` обратно в meta; - delete → `deletePage(pageId)` (Trash); - rename/move → `classifyRenameMoves` → `movePage`/`renamePage`; - при пустых failures: `updateRef(LAST_PUSHED_REF, pushedCommit)` + `fastForwardBranch('docmost', pushedCommit)`. 8. Записать `bodyHash` + `updatedAt` (loop-guard, §8.2); `git push`. --- ## 12. Фазирование - **A. Каркас + односторонний pull (нативно).** `packages/git-sync` (вендоринг §2), `GitmostDataSource` (чтение через репозитории), `GitSyncModule`, конфиг из `EnvironmentService`, ручной/однократный pull-цикл на один спейс. **Гейт §13.1.** - **B. Push + непрерывность.** Нативная запись (§3.3), `runPush`, ветки/refs, loop-guard (§8), Redis-лок (§9), `@Interval` + `PageChangeListener` (§10). - **C. Per-space UI.** `space.settings.gitSync` (§7.1), DTO/сервис/репо/гард, тоггл на клиенте, скоуп оркестратора по включённым спейсам. - **D. Харднинг.** Conflict-gating (SPEC §9), удаления через Trash + git (§5), стартовая реконсиляция и `move-to-space` кросс-репо, провенанс на клиенте, Dockerfile `git`, полный набор тестов. --- ## 13. Тестирование ### 13.1. Гейт идемпотентности (блокирует фазу B) Перенести round-trip-харнес docmost-sync (`roundtrip.ts` + `test/fixtures/corpus`) в тесты `packages/git-sync`, но прогонять **против схемы `editor-ext`**: `content (editor-ext) → convertProseMirrorToMarkdown → markdownToProseMirror → TiptapTransformer.toYdoc(…, tiptapExtensions) → fromYdoc → canonicalizeContent` должно давать `docsCanonicallyEqual === true`. Любая потеря нод/атрибутов = расхождение схем → чинить `docmost-schema.ts` под `editor-ext`. ### 13.2. Юнит (чистая логика, переносится как есть) `reconcile` (planReconciliation / decideAbsenceDeletions / mass-delete guards), `layout` (коллизии/санитизация), `computePullActions`, `computePushActions`, `classifyRenameMoves`, `bodyHash`. ### 13.3. Интеграция (нативный адаптер) `GitmostDataSource` против тестовой БД: `listSpaceTree`/`getPageJson` корректно маппят; `createPage`/`movePage`/`deletePage`/`importPageMarkdown` пишут через collab и проставляют `lastUpdatedSource='git-sync'`; loop-guard не зацикливается (write → poll → no-op). ### 13.4. e2e (под локом) Полный pull→push round-trip на временном vault + временном спейсе: правка в Docmost доезжает в файл и наоборот; конфликт даёт маркеры и блокирует push. --- ## 14. Риски и открытые пункты 1. **Схема-совместимость конвертера** (§3.3, §13.1) — главный риск; гейт обязателен до фазы B. 2. **`AuthProvenanceData`** — точную форму типа подтвердить; возможно, потребует расширения enum источника на сервере и в истории. 3. **Согласованность Yjs** — писать строго через `openDirectConnection`/`transact`; не трогать `content`-колонку напрямую. 4. **`position` для move** — обязателен в Docmost-move; нужен `fractional-indexing-jittered` между соседями (соседей брать сортировкой `position COLLATE "C"`). 5. **`git` в рантайме** — добавить в Dockerfile. 6. **`ScheduleModule.forRoot()`** — не задублировать `forRoot`. 7. **Сервисный пользователь записи** (`GIT_SYNC_SERVICE_USER_ID`) — от чьего имени идут create/move (влияет на `creatorId`/права); согласовать политику. 8. **Конфликты и удаления** — фаза D строго по SPEC §8/§9 (маркеры никогда не уезжают в Docmost). --- ## 15. Чек-лист изменений по файлам **Новый пакет** - `packages/git-sync/**` — движок + чистый конвертер (§2), `package.json` (`@docmost/git-sync`, `workspace:*`), `tsconfig.json`. **Сервер (`apps/server/src`)** - `integrations/git-sync/**` — модуль, оркестратор, адаптер, листенер (§6). - `app.module.ts` — импорт `GitSyncModule`. - `collaboration/collaboration.module.ts` — экспорт `CollaborationGateway`. - `collaboration/extensions/persistence.extension.ts` — источник `'git-sync'` (§8.1). - `core/space/dto/update-space.dto.ts` — `gitSyncEnabled?` (§7.1). - `core/space/services/space.service.ts` — обработка флага. - `database/repos/space/space.repo.ts` — `updateGitSyncSettings` (§7.1). - `integrations/environment/environment.validation.ts` + `environment.service.ts` — новые ENV (§7.2). - `Dockerfile` — пакет `git`. **Клиент (`apps/client/src`)** - `features/space/components/edit-space-form.tsx` — тоггл git-sync. - `features/space/types` — поле `settings.gitSync`. - `features/page-history/types/page.types.ts` + `components/history-item.tsx` — значение `'git-sync'` в `lastUpdatedSource`. **Корень** - `pnpm-workspace.yaml` уже покрывает `packages/*`; `apps/server/package.json` — зависимость `@docmost/git-sync: workspace:*`.
Ghost added the featureepic labels 2026-06-25 22:40:53 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#194