feat(git-sync): двусторонний Docmost↔git синк на унифицированном конвертере (#359)

Схлопнутая дельта feat/git-sync-2 поверх актуального develop (3085ec1b).

Почему squash, а не буквальный rebase: в ветке 113 коммитов, из которых
develop уже впитал ~80 в курированном виде при унификации конвертера
(#326/#293) — но не patch-identical, поэтому они не отваливаются сами; а
коммит, УДАЛЯЮЩИЙ вендоренную копию конвертера, апстримный и в replay-набор
не входит, так что наивный replay заново добавил бы вендоренные копии.
Поэтому корректный итог — «develop + чистая net-дельта ветки», сведённая
3-way мержом (merge-base 5336f06d).

Net-дельта: серверный git-sync модуль (GitSyncModule/orchestrator/HTTP),
движок git-sync (layout/reconcile/pull/push/stabilize + QA), два фикса
round-trip/data-loss конвертера поверх УНИФИЦИРОВАННОГО
@docmost/prosemirror-markdown (вендоренной копии больше нет — git-sync
целиком на унифицированном конвертере, поведение унифицированного принято
за эталон), e2e-скрипты, доки.

Конфликты (4) слиты объединением: main.ts (метрики + GitHttpService),
apps/server/package.json (pretest-суперсет; moduleNameMapper: git-sync→src
и .js-strip оставлены, а bare-specifier @docmost/prosemirror-markdown→src
УБРАН — серверный jest выровнен на develop-подход #345 со сборённым
пакетом), .env.example (метрики + GIT_SYNC блоки), AGENTS.md (строки про
пакеты; устаревшая заметка про «три hand-synced копии схемы» в git-sync
поправлена — схема теперь только в @docmost/prosemirror-markdown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 05:35:03 +03:00
parent da7bb95d4f
commit 044daab357
127 changed files with 14238 additions and 193 deletions
@@ -51,6 +51,23 @@ export interface GitSyncClient {
rootPageId?: string,
): Promise<{ pages: GitSyncPageNodeLite[]; complete: boolean }>;
/**
* Existence probe for the pull-side ghost guard (D-P3-1). Given a set of
* candidate pageIds, return the subset that corresponds to a REAL page ROW —
* INCLUDING soft-deleted (trashed) and pages in ANY OTHER space, i.e. any
* `id` that has ever been a page (`SELECT id FROM pages WHERE id = ANY(...)`,
* workspace-scoped). Malformed (non-UUID) ids are filtered out before the
* query and are never returned.
*
* The pull reconciler only absence-deletes a tracked file whose pageId is
* returned here: a deleted/moved/trashed page HAS a row -> its vault file is
* cleaned up; a GHOST id (a git file whose id was NEVER a page) has NO row ->
* it is preserved rather than silently deleted (the data-loss bug). Called on
* only the small candidate-delete set (tracked ids absent from the live tree),
* so the query is bounded and usually empty.
*/
pageIdsExist(pageIds: string[]): Promise<string[]>;
/**
* One page WITH its ProseMirror body content. `applyPullActions` reads
* `id`, `slugId`, `title`, `parentPageId`, `spaceId` (for the file meta) and
+28
View File
@@ -170,10 +170,38 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
});
const tree = await client.listSpaceTree(spaceId);
// D-P3-1 ghost guard: an absence-delete must not silently remove a git file
// whose pageId was NEVER a page (a hand-authored file with an unknown id).
// Compute the candidate-delete set (tracked ids absent from the live tree)
// and ask the datasource which of them are REAL page rows (incl. trashed /
// other spaces). Only those may be absence-deleted; a ghost id is preserved
// (adopted/skipped by the push side).
//
// Size note: on a COMPLETE fetch this set is usually small/empty (only
// genuinely removed pages look absent), so the `id IN (...)` probe is cheap.
// On an INCOMPLETE fetch (`tree.complete === false`) MANY live pages look
// absent, so the set — and the probe — can be large. That is only a perf
// consideration, not a correctness one: `decideAbsenceDeletions` (inside
// `computePullActions`) still SUPPRESSES every absence delete on an
// incomplete fetch, so no ghost-guarded deletion is applied that cycle
// regardless of what the probe returns.
const livePageIds = new Set(
tree.pages.filter((p) => p && p.id).map((p) => p.id),
);
const candidateDeleteIds = existing
.map((e) => e.pageId)
.filter((id) => !livePageIds.has(id));
const deletableIds =
candidateDeleteIds.length > 0
? await client.pageIdsExist(candidateDeleteIds)
: [];
const pullActions = computePullActions({
pages: tree.pages,
treeComplete: tree.complete,
existing,
deletableIds,
});
// Bail before the first destructive write phase if the lock was lost.
+17 -3
View File
@@ -148,6 +148,14 @@ export interface PullActionsInput {
treeComplete: boolean;
/** Parsed tracked files: `{ pageId, relPath }` (from `readExisting`). */
existing: { pageId: string; relPath: string }[];
/**
* The subset of tracked pageIds that correspond to a REAL page row (D-P3-1
* ghost guard, from `client.pageIdsExist`). Only ids in this set may be
* absence-deleted; a ghost id (never a page) is preserved. When omitted, all
* absent ids are deletable (the historical behavior; pure unit callers that
* do not model ghosts).
*/
deletableIds?: string[];
}
/**
@@ -191,7 +199,7 @@ export interface PullActions {
* thin `applyPullActions`.
*/
export function computePullActions(input: PullActionsInput): PullActions {
const { pages, treeComplete, existing } = input;
const { pages, treeComplete, existing, deletableIds } = input;
const layout = buildVaultLayout(pages);
const live: LiveEntry[] = [];
@@ -206,8 +214,14 @@ export function computePullActions(input: PullActionsInput): PullActions {
}
// Plan reconciliation (pure). `plan.toDelete` is ABSENCE-based only;
// `plan.moved` carries move old-path removals separately.
const plan = planReconciliation(live, existing);
// `plan.moved` carries move old-path removals separately. The ghost guard
// (D-P3-1) gates absence-deletes to ids that are a real page row; when
// `deletableIds` is omitted, all absent ids are deletable (historical).
const plan = planReconciliation(
live,
existing,
deletableIds === undefined ? undefined : new Set(deletableIds),
);
// Decide whether the ABSENCE-based deletions may be applied this cycle
// (SPEC §8): incomplete-fetch suppression + empty-live + mass-delete guard.
+20 -2
View File
@@ -96,6 +96,16 @@ export interface ReconciliationPlan {
export function planReconciliation(
live: LiveEntry[],
existing: ExistingEntry[],
/**
* The subset of tracked pageIds that correspond to a REAL page row (D-P3-1
* ghost guard). When provided, a tracked file whose pageId is ABSENT from
* `live` is absence-deleted ONLY if its id is in this set — a deleted/moved/
* trashed page has a row (delete its stale vault file), while a GHOST id (a
* git file whose id was never a page) has NO row and is PRESERVED. When
* `undefined` (pure unit callers that do not model ghosts), every absent id is
* treated as deletable — the historical behavior.
*/
deletableIds?: ReadonlySet<string>,
): ReconciliationPlan {
// Desired path for each live pageId.
const liveByPageId = new Map<string, string>();
@@ -119,9 +129,17 @@ export function planReconciliation(
for (const ex of existing) {
const liveRel = liveByPageId.get(ex.pageId);
if (liveRel === undefined) {
// Tracked page is gone from the live tree -> absence delete.
// Tracked page is gone from the live tree -> candidate absence delete.
// D-P3-1: a candidate is only deleted when its id corresponds to a real
// page row (deleted/moved/trashed). A GHOST id (never a page) is NOT in
// `deletableIds` and is preserved rather than silently deleted. When the
// gate is not supplied (pure unit callers), fall back to the historical
// "all absent ids deletable" behavior.
const deletable = deletableIds === undefined || deletableIds.has(ex.pageId);
// Never queue a path a live page will (re)write (path reuse -> no loss).
if (!liveTargetPaths.has(ex.relPath)) toDeleteSet.add(ex.relPath);
if (deletable && !liveTargetPaths.has(ex.relPath)) {
toDeleteSet.add(ex.relPath);
}
continue;
}
if (liveRel !== ex.relPath) {
@@ -77,6 +77,8 @@ const nodeFs: CycleFs = {
function makeEmptyClientFake() {
return {
listSpaceTree: vi.fn(async () => ({ pages: [], complete: true })),
// Default: every candidate id is a real page row (historical behavior).
pageIdsExist: vi.fn(async (ids: string[]) => ids),
getPageJson: vi.fn(),
importPageMarkdown: vi.fn(async () => ({ updatedAt: "2026-06-20T00:00:00.000Z" })),
createPage: vi.fn(async (title: string) => ({
+87
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { runCycle, type RunCycleDeps } from "../src/engine/cycle";
import { serializePageFile } from "@docmost/prosemirror-markdown";
// A fake VaultGit recording the staging calls. An EMPTY vault/tree lets the real
// readExisting/computePullActions/applyPullActions/runPush run trivially (no
@@ -46,6 +47,8 @@ function baseDeps(vault: any, over: Partial<RunCycleDeps> = {}): RunCycleDeps {
spaceId: "space-1",
client: {
listSpaceTree: vi.fn(async () => ({ pages: [], complete: true })),
// Default: every candidate id is a real page row (historical behavior).
pageIdsExist: vi.fn(async (ids: string[]) => ids),
getPageJson: vi.fn(),
importPageMarkdown: vi.fn(),
createPage: vi.fn(),
@@ -235,4 +238,88 @@ describe("runCycle (composition)", () => {
expect(deps.client.listSpaceTree).toHaveBeenCalledTimes(1);
expect(vault.diffNameStatus).not.toHaveBeenCalled();
});
// D-P3-1 ghost guard, end-to-end through runCycle: a tracked file whose id is
// absent from live AND is NOT returned by `pageIdsExist` (a ghost — never a
// page) must SURVIVE the whole cycle (no rm), while a sibling id that IS
// returned (a genuinely deleted page row) is absence-deleted. This proves the
// guard flows from pageIdsExist -> computePullActions -> applyPullActions,
// not just at the pure `planReconciliation` unit.
it("GHOST GUARD (e2e): a ghost tracked file SURVIVES runCycle; a real deleted-page file is removed", async () => {
const ghostId = "019f2500-dead-7000-8000-000000000009"; // never a page
const deletedId = "019f2500-0000-7000-8000-000000000001"; // a real (deleted) row
const liveId = "019f2500-0000-7000-8000-0000000000aa"; // keeps empty-live from firing
// Two tracked files, both ABSENT from the live tree (deletion candidates).
const vault = fakeVault({
listTrackedFiles: vi.fn(async () => ["Ghost.md", "Deleted.md"]),
});
// The datasource reports ONLY the deleted page as a real row; the ghost has
// no row (a PROPER SUBSET of the probed ids, not the identity shim).
const pageIdsExist = vi.fn(async (ids: string[]) =>
ids.filter((id) => id === deletedId),
);
const rm = vi.fn(async () => undefined);
const deps = baseDeps(vault, {
fs: {
readFile: vi.fn(async (absPath: string) => {
if (absPath.includes("Ghost.md"))
return serializePageFile(ghostId, "a body authored in git");
if (absPath.includes("Deleted.md"))
return serializePageFile(deletedId, "a deleted page body");
return "";
}),
writeFile: vi.fn(async () => undefined),
mkdir: vi.fn(async () => undefined),
rm,
lstat: vi.fn(async () => ({ isSymbolicLink: false })),
realpath: vi.fn(async (p: string) => p),
},
client: {
...baseDeps(vault).client,
// A single live page so the empty-live suppression does NOT fire (which
// would mask the guard by suppressing every delete this cycle).
listSpaceTree: vi.fn(async () => ({
pages: [
{
id: liveId,
slugId: "live",
title: "Live",
parentPageId: null,
position: "a0",
hasChildren: false,
},
],
complete: true,
})),
pageIdsExist,
getPageJson: vi.fn(async (pageId: string) => ({
id: pageId,
slugId: "live",
title: "Live",
parentPageId: null,
spaceId: "space-1",
updatedAt: "2026-06-20T00:00:00.000Z",
content: { type: "doc", content: [] },
})),
} as any,
});
const res = await runCycle(deps);
expect(res.ran).toBe(true);
// The guard probed the datasource for EXACTLY the absent candidate ids.
expect(pageIdsExist).toHaveBeenCalledTimes(1);
expect(new Set(pageIdsExist.mock.calls[0][0])).toEqual(
new Set([ghostId, deletedId]),
);
// The real deleted-page file is removed; the ghost file is PRESERVED.
const rmPaths = rm.mock.calls.map((c) => c[0] as string);
expect(rmPaths.some((p) => p.includes("Deleted.md"))).toBe(true);
expect(rmPaths.some((p) => p.includes("Ghost.md"))).toBe(false);
expect(res.pull?.deleted).toBe(1);
});
});
@@ -65,6 +65,16 @@ describe('GitSyncClient contract (type-level)', () => {
expect(true).toBe(true);
});
it('pageIdsExist(ids) -> ids subset (D-P3-1 ghost guard seam)', () => {
expectTypeOf<GitSyncClient['pageIdsExist']>().parameters.toEqualTypeOf<
[string[]]
>();
expectTypeOf<
Awaited<ReturnType<GitSyncClient['pageIdsExist']>>
>().toEqualTypeOf<string[]>();
expect(true).toBe(true);
});
it('a structurally-correct adapter satisfies GitSyncClient (drift => compile error)', () => {
// A minimal dummy adapter mirroring the EXACT result shapes the engine reads.
// The `satisfies GitSyncClient` clause is the contract guard: any drift in a
@@ -74,6 +84,7 @@ describe('GitSyncClient contract (type-level)', () => {
pages: [] as GitSyncPageNodeLite[],
complete: true,
}),
pageIdsExist: async (_pageIds: string[]) => [] as string[],
getPageJson: async (pageId: string) => ({
id: pageId,
slugId: 'slug',
@@ -130,6 +141,7 @@ describe('GitSyncClient contract (type-level)', () => {
// in BOTH directions).
const bad = {
listSpaceTree: async () => ({ pages: [] as GitSyncPageNodeLite[], complete: true }),
pageIdsExist: async () => [] as string[],
getPageJson: async (pageId: string) => ({
id: pageId,
slugId: 's',
+37
View File
@@ -59,6 +59,43 @@ describe('planReconciliation', () => {
expect(plan.moved).toEqual([]);
});
// D-P3-1 ghost guard: when `deletableIds` is supplied, an absence-delete fires
// ONLY for an id that is a REAL page row. A tracked file whose id was NEVER a
// page (a hand-authored git file with an unknown id) is absent from `live` AND
// absent from `deletableIds` -> it MUST be preserved, not silently deleted.
it('GHOST GUARD: an absent id NOT in deletableIds is PRESERVED (not deleted)', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/Keep.md' }];
const existing: ExistingEntry[] = [
{ pageId: 'p1', relPath: 'Space/Keep.md' },
// A ghost: its id is not live and not a real page row.
{ pageId: 'ghost', relPath: 'Space/Ghost.md' },
// A genuinely deleted page: absent from live but IS a real row.
{ pageId: 'gone', relPath: 'Space/Gone.md' },
];
// Only the real page row ('gone') is deletable; 'ghost' has no row.
const deletableIds = new Set(['gone']);
const plan = planReconciliation(live, existing, deletableIds);
expect(plan.toWrite).toEqual([{ pageId: 'p1', relPath: 'Space/Keep.md' }]);
// The genuine delete is applied; the ghost file is preserved.
expect(plan.toDelete).toEqual(['Space/Gone.md']);
expect(plan.toDelete).not.toContain('Space/Ghost.md');
expect(plan.moved).toEqual([]);
});
// The empty gate is the maximally-safe case: no id is a real row, so NOTHING
// is absence-deleted (every absent tracked file is treated as a ghost). This
// is what a cycle sees when `pageIdsExist` returns nothing for the candidates.
it('GHOST GUARD: an EMPTY deletableIds set suppresses every absence delete', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/Keep.md' }];
const existing: ExistingEntry[] = [
{ pageId: 'p1', relPath: 'Space/Keep.md' },
{ pageId: 'gone', relPath: 'Space/Gone.md' },
];
const plan = planReconciliation(live, existing, new Set<string>());
expect(plan.toDelete).toEqual([]);
expect(plan.moved).toEqual([]);
});
it('NO-OP: live and existing identical -> writes (re-emit) but no deletes/moves', () => {
const live: LiveEntry[] = [
{ pageId: 'p1', relPath: 'A.md' },