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-base5336f06d). 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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user