[feature][epic] Git-sync: двусторонняя синхронизация страниц Docmost ↔ git-папка Markdown (встраивание docmost-sync) #194
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Git-sync: спека реализации (встраивание docmost-sync в gitmost)
Статус: спецификация, код не менялся. Детальный план реализации фичи
«двусторонний синк страниц Docmost ↔ локальная git-папка Markdown», встроенной
прямо в gitmost.
Источник движка:
https://gitea.vvzvlad.xyz/vvzvlad/docmost-sync(ветка
main, на момент спеки HEADb03eb35). Все сигнатуры ниже сверены с этимисходником и с текущим кодом gitmost.
Предыстория и обоснование архитектурных развилок — в бэклоге
ai-chat-tool-definitions-duplicated.md
(раздел про дублирование конвертера) и в исходном
SPEC.mdрепозиторияdocmost-sync (нумерация §-параграфов ниже ссылается на него).
0. Зафиксированные решения
Из обсуждения архитектуры (выбор пользователя) и трёх суб-решений:
через репозитории gitmost, запись тела — через collab
openDirectConnection,триггеры — через
EventEmitter2вместо поллинга/recent.GitSyncModuleвapps/server/src/integrations/git-syncс
@Interval/событиями и leader-lock на Redis (single-writer при несколькихрепликах).
space.settings.gitSync, секреты(git-remote) — через ENV/
EnvironmentService.packages/git-sync,гейт = round-trip-идемпотентность против схемы
@docmost/editor-ext.move-to-space= кросс-репо delete+create.lastUpdatedSource = 'git-sync'.Вне scope v1 (как и в SPEC): комментарии (только якоря, без тредов), права/ACL,
вложения как отдельный поток (едут ссылками внутри контента), realtime-подписка
на Hocuspocus (остаётся поллинг-страховка + события).
1. Архитектура верхнего уровня
Ключ интеграции: движок docmost-sync уже полностью построен на dependency
injection — весь внешний IO (REST-клиент, git, файловая система) передаётся
через узкие интерфейсы. Мы НЕ переписываем движок; мы подставляем нативные
реализации в его DI-швы.
2. Состав вендоринга из docmost-sync
В новый пакет
packages/git-syncкопируем (с сохранением истории смысла —backport-friendly, как сделано с
packages/mcp):2.1. Движок (engine) —
src/engine/pull.tspush.tsgit.tsVaultGit— обёртка git shell-outgitreconcile.tslayout.tssanitize.tsstabilize.tsloop-guard.tsbodyHash(sha256)settings.ts.envindex.ts2.2. Конвертер (чистая часть) —
src/lib/Из
packages/docmost-client/src/lib/берём только чистый конвертер и форматфайла (collab/auth REST-части НЕ нужны — запись нативная):
markdown-converter.tsconvertProseMirrorToMarkdown(content): stringcollaboration.ts(только конвертер-функция)markdownToProseMirror(md): Promise<doc>⚠️markdown-document.tsserializeDocmostMarkdownBody,parseDocmostMarkdown,serializeDocmostMarkdown, типDocmostMdMetacanonicalize.tscanonicalizeContent(node),docsCanonicallyEqual(a,b)docmost-schema.tsmarkdownToProseMirrornode-ops.ts,diff.ts⚠️
markdownToProseMirrorфизически лежит вcollaboration.tsdocmost-client(строка 289) — это чистая функция (marked→HTML→generateJSON), не путать с
collab/websocket write-path из того же файла, который НЕ берём.
2.3. НЕ берём
pull/pushCLI-обёртки,roundtrip.ts(харнес переносим в тесты, см. §13),docmost-clientREST-клиент целиком,lib/collaboration.ts(websocket-write),lib/auth-utils.ts,Makefile, Docker-обвязку docmost-sync.3. Главный шов:
GitmostDataSourceДвижок дёргает Docmost через
Pick<DocmostClient, …>. Мы реализуем класс,структурно совместимый с этими сигнатурами, но нативный внутри. Это
единственный нетривиальный новый код.
3.1. Точный набор методов, которых требует движок
Из
pull.ts(ApplyPullActionsDeps.client) и обхода дерева:Из
push.ts(ApplyPushDeps.client):Для непрерывного режима/детекции удалений (фаза B+, SPEC §8):
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).listRecentSincePageRepo.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)
withYdocConnection(collaboration.handler.ts:118-133).
Имя документа —
page.<pageId>(getPageId).Схему берём из
tiptapExtensions(collaboration.util.ts).Схема-совместимость (критично).
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: []).и blast-radius.
move-to-space(страница меняет спейс) → кросс-репо:deleteв исходномрепо +
createв целевом. Ловим по событиюPAGE_MOVED_TO_SPACE.git-sync:lock:<spaceId>(§9).6. NestJS-модуль
GitSyncModuleСтруктура (шаблон —
McpModule):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>.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:7.2. Секреты/тюнинг (ENV) —
EnvironmentServiceДвижковый
settings.ts(zod, читает.env) заменяем на чтение из gitmostEnvironmentService:parseSettings(env)оставляем как чистую функцию для тестов,но в проде собираем
SettingsизEnvironmentService-геттеров.Новые переменные (объявить в
environment.validation.ts
class-validator-декораторами, геттеры — в
environment.service.ts):
GIT_SYNC_ENABLEDGIT_SYNC_DATA_DIR<DATA_DIR>/git-sync)GIT_SYNC_REMOTE_TEMPLATEgit@host:vault-{spaceId}.gitGIT_SYNC_SSH_KEY_PATH/ креды remoteGIT_SYNC_POLL_INTERVAL_MSGIT_SYNC_DEBOUNCE_MSGIT_SYNC_SERVICE_USER_ID8. Провенанс и 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передаёмAuthProvenanceDatacsource: '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)не защищены от мультиинстанса. Для синка добавляем явный лок.
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, чтобы не снять чужой лок).10. Планировщик и событийные триггеры
PageChangeListenerподписывается наEventName.PAGE_CREATED | PAGE_UPDATED | PAGE_MOVED | PAGE_SOFT_DELETED | PAGE_RESTORED | PAGE_MOVED_TO_SPACEи jobPAGE_CONTENT_UPDATED(event.contants.ts).
Фильтр по
spaceId(только включённые спейсы) → дебаунс (GIT_SYNC_DEBOUNCE_MS)→ ставит pull/push-цикл спейса в очередь оркестратора.
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)
VaultGit.ensureRepo()+ensureBranch('docmost','main')+checkout('docmost').dataSource.listSpaceTree(spaceId)→{ pages, complete:true }.readExisting({ listTracked: () => git.listTrackedFiles('*.md'), readFile }).computePullActions({ pages, treeComplete:true, existing })→ план.applyPullActions(deps, actions, vaultRoot): на каждую страницуgetPageJson→stabilizePageFile(content, meta)(export→import→exportfixpoint, SPEC §11) → запись файла; затем
stageAll+commit(трейлерdocmost) наdocmost;checkout('main')+merge('docmost').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 }):git.ensureRepo/isMergeInProgress(abort при merge) /checkout('main').stageAll+commit('local: working-tree changes')(локально, в Docmost не шлёт).readRef(LAST_PUSHED_REF)??docmost;revParse('main')→pushedCommit.diffNameStatus(base, 'main')→ changes; префетчmetaAt(path, side).computePushActions({ changes, metaAt })→ creates/updates/deletes/renamesMoves/skipped.dryRun→ лог плана и выход (клиент НЕ создаётся).--apply:makeClient(settings)→ нашGitmostDataSource;applyPushActions:importPageMarkdown(pageId, fullMd)(collab-write, §3.3);createPage(...)→ записать присвоенныйpageIdобратно в meta;deletePage(pageId)(Trash);classifyRenameMoves→movePage/renamePage;updateRef(LAST_PUSHED_REF, pushedCommit)+fastForwardBranch('docmost', pushedCommit).bodyHash+updatedAt(loop-guard, §8.2);git push.12. Фазирование
packages/git-sync(вендоринг§2),
GitmostDataSource(чтение через репозитории),GitSyncModule, конфиг изEnvironmentService, ручной/однократный pull-цикл на один спейс. Гейт §13.1.runPush, ветки/refs,loop-guard (§8), Redis-лок (§9),
@Interval+PageChangeListener(§10).space.settings.gitSync(§7.1), DTO/сервис/репо/гард,тоггл на клиенте, скоуп оркестратора по включённым спейсам.
стартовая реконсиляция и
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. Риски и открытые пункты
обязателен до фазы B.
AuthProvenanceData— точную форму типа подтвердить; возможно, потребуетрасширения enum источника на сервере и в истории.
openDirectConnection/transact;не трогать
content-колонку напрямую.positionдля move — обязателен в Docmost-move; нуженfractional-indexing-jitteredмежду соседями (соседей брать сортировкойposition COLLATE "C").gitв рантайме — добавить в Dockerfile.ScheduleModule.forRoot()— не задублироватьforRoot.GIT_SYNC_SERVICE_USER_ID) — от чьего имениидут create/move (влияет на
creatorId/права); согласовать политику.уезжают в 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 referenced this issue2026-06-25 23:40:09 +03:00