Files
docmost-sync/test/reconcile.test.ts
vvzvlad 531b320776 feat(sync): add git vault layer (§5) and the Docmost->vault pull cycle (§6)
Turn the read-only mirror into a git-backed pull cycle. Read-only toward Docmost.

- git.ts (VaultGit): system-git wrapper, all ops cwd=vaultPath (vault is its own
  repo under data/vault, never the source repo); ensureRepo/branches main+docmost,
  commit with provenance (author/committer identity + Docmost-Sync-Source trailer,
  §7.3), merge with conflict surfacing (no auto-resolve, §9), isMergeInProgress;
  GIT_DIR/GIT_WORK_TREE stripped from env (§12 cwd isolation)
- stabilize.ts: normalize-on-write (one export->import->export fixpoint pass, §11)
- reconcile.ts: pure planReconciliation (add/update/move/delete by pageId) +
  decideAbsenceDeletions gate
- pull.ts: write/commit on docmost -> merge into main; listSpaceTree completeness
  signal suppresses absence-deletions on a partial fetch (§8); mass-delete guard;
  merge-in-progress guard makes re-runs converge (§12); move old-path removal only
  on successful write
- docmost-client: listSpaceTree({pages, complete}) without touching the 1:1-copied
  enumerateSpacePages
- tests: reconcile planner + decideAbsenceDeletions, VaultGit incl. real temp-repo
  merge conflict, listSpaceTree completeness (586 green)

Push to a git remote and the FS->Docmost direction are deferred to the next increment.
2026-06-16 23:57:50 +03:00

239 lines
8.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
planReconciliation,
decideAbsenceDeletions,
type ExistingEntry,
type LiveEntry,
} from '../src/reconcile.js';
describe('planReconciliation', () => {
it('ADD: a new live page (not tracked) is written, nothing deleted', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/New.md' }];
const existing: ExistingEntry[] = [];
const plan = planReconciliation(live, existing);
expect(plan.toWrite).toEqual([{ pageId: 'p1', relPath: 'Space/New.md' }]);
expect(plan.toDelete).toEqual([]);
expect(plan.moved).toEqual([]);
});
it('CONTENT-UPDATE: tracked page at the SAME path is rewritten, not moved/deleted', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/Doc.md' }];
const existing: ExistingEntry[] = [{ pageId: 'p1', relPath: 'Space/Doc.md' }];
const plan = planReconciliation(live, existing);
// Still written (re-emitted; identical bytes => git no-op), no move/delete.
expect(plan.toWrite).toEqual([{ pageId: 'p1', relPath: 'Space/Doc.md' }]);
expect(plan.toDelete).toEqual([]);
expect(plan.moved).toEqual([]);
});
it('MOVE: same pageId, new path -> write new + recorded as moved (NOT in toDelete)', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/NewParent/Doc.md' }];
const existing: ExistingEntry[] = [
{ pageId: 'p1', relPath: 'Space/OldParent/Doc.md' },
];
const plan = planReconciliation(live, existing);
expect(plan.toWrite).toEqual([
{ pageId: 'p1', relPath: 'Space/NewParent/Doc.md' },
]);
// The old path is a MOVE removal, NOT an absence delete -> not in toDelete.
expect(plan.toDelete).toEqual([]);
expect(plan.moved).toEqual([
{
pageId: 'p1',
fromRelPath: 'Space/OldParent/Doc.md',
toRelPath: 'Space/NewParent/Doc.md',
removeOldPath: true,
},
]);
});
it('DELETE: a tracked pageId gone from live -> its file is deleted', () => {
const live: LiveEntry[] = [{ pageId: 'p1', relPath: 'Space/Keep.md' }];
const existing: ExistingEntry[] = [
{ pageId: 'p1', relPath: 'Space/Keep.md' },
{ pageId: 'p2', relPath: 'Space/Gone.md' },
];
const plan = planReconciliation(live, existing);
expect(plan.toWrite).toEqual([{ pageId: 'p1', relPath: 'Space/Keep.md' }]);
expect(plan.toDelete).toEqual(['Space/Gone.md']);
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' },
{ pageId: 'p2', relPath: 'B.md' },
];
const existing: ExistingEntry[] = [
{ pageId: 'p1', relPath: 'A.md' },
{ pageId: 'p2', relPath: 'B.md' },
];
const plan = planReconciliation(live, existing);
expect(plan.toWrite).toEqual(live);
expect(plan.toDelete).toEqual([]);
expect(plan.moved).toEqual([]);
});
it('does NOT delete an old path that another live page will write (path reuse)', () => {
// p1 moves from X.md to Y.md; p2 is a NEW page taking over X.md. The old
// X.md must NOT be deleted, because p2 writes it.
const live: LiveEntry[] = [
{ pageId: 'p1', relPath: 'Y.md' },
{ pageId: 'p2', relPath: 'X.md' },
];
const existing: ExistingEntry[] = [{ pageId: 'p1', relPath: 'X.md' }];
const plan = planReconciliation(live, existing);
expect(new Set(plan.toWrite)).toEqual(
new Set([
{ pageId: 'p1', relPath: 'Y.md' },
{ pageId: 'p2', relPath: 'X.md' },
]),
);
// X.md is a live target, so nothing is deleted.
expect(plan.toDelete).toEqual([]);
// The move is still recorded, but its old path is NOT removable (p2 writes
// X.md): removeOldPath:false protects the reused path from data loss.
expect(plan.moved).toEqual([
{ pageId: 'p1', fromRelPath: 'X.md', toRelPath: 'Y.md', removeOldPath: false },
]);
});
it('combines add + update + move + delete in one plan', () => {
const live: LiveEntry[] = [
{ pageId: 'keep', relPath: 'Keep.md' }, // update in place
{ pageId: 'mover', relPath: 'New/Moved.md' }, // moved
{ pageId: 'fresh', relPath: 'Fresh.md' }, // added
];
const existing: ExistingEntry[] = [
{ pageId: 'keep', relPath: 'Keep.md' },
{ pageId: 'mover', relPath: 'Old/Moved.md' },
{ pageId: 'dead', relPath: 'Dead.md' }, // deleted
];
const plan = planReconciliation(live, existing);
expect(plan.toWrite).toEqual(live);
expect(plan.moved).toEqual([
{
pageId: 'mover',
fromRelPath: 'Old/Moved.md',
toRelPath: 'New/Moved.md',
removeOldPath: true,
},
]);
// toDelete is ABSENCE-only now: the moved old path lives in `moved`, so only
// the genuinely-gone page (Dead.md) is here.
expect(plan.toDelete).toEqual(['Dead.md']);
});
it('records each duplicate tracked row of a present pageId as a removable move', () => {
// Two stray files both claim pageId "dup"; the live page lives elsewhere.
// Each stray is a MOVE (same pageId, different path) -> recorded in `moved`
// with removeOldPath:true, NOT in absence-based toDelete.
const live: LiveEntry[] = [{ pageId: 'dup', relPath: 'Canonical.md' }];
const existing: ExistingEntry[] = [
{ pageId: 'dup', relPath: 'StrayA.md' },
{ pageId: 'dup', relPath: 'StrayB.md' },
];
const plan = planReconciliation(live, existing);
expect(plan.toWrite).toEqual([{ pageId: 'dup', relPath: 'Canonical.md' }]);
expect(plan.toDelete).toEqual([]);
expect(plan.moved).toEqual([
{
pageId: 'dup',
fromRelPath: 'StrayA.md',
toRelPath: 'Canonical.md',
removeOldPath: true,
},
{
pageId: 'dup',
fromRelPath: 'StrayB.md',
toRelPath: 'Canonical.md',
removeOldPath: true,
},
]);
});
});
describe('decideAbsenceDeletions (SPEC §8)', () => {
it('APPLIES when the tree is complete and the delete count is modest', () => {
const d = decideAbsenceDeletions({
treeComplete: true,
liveCount: 10,
existingCount: 10,
deleteCount: 1,
});
expect(d).toEqual({ apply: true });
});
it('SUPPRESSES all absence deletions when the tree fetch is incomplete', () => {
// Even a single absence delete is suppressed on a partial tree (a missing
// pageId in a partial tree is NOT proof of deletion).
const d = decideAbsenceDeletions({
treeComplete: false,
liveCount: 9,
existingCount: 10,
deleteCount: 1,
});
expect(d).toEqual({ apply: false, reason: 'incomplete-fetch' });
});
it('SUPPRESSES when live returned 0 pages but files are tracked (complete flag aside)', () => {
const d = decideAbsenceDeletions({
treeComplete: true,
liveCount: 0,
existingCount: 5,
deleteCount: 5,
});
expect(d).toEqual({ apply: false, reason: 'empty-live' });
});
it('SUPPRESSES over the mass-delete guard (> 50% of a non-trivial vault)', () => {
const d = decideAbsenceDeletions({
treeComplete: true,
liveCount: 4,
existingCount: 10,
deleteCount: 6, // 60% > 50%
});
expect(d).toEqual({ apply: false, reason: 'mass-delete' });
});
it('does NOT apply the fraction guard for a tiny vault (below the floor)', () => {
// 1-of-2 is normal in a tiny vault; the fraction guard does not fire.
const d = decideAbsenceDeletions({
treeComplete: true,
liveCount: 1,
existingCount: 2,
deleteCount: 1,
});
expect(d).toEqual({ apply: true });
});
it('incomplete-fetch takes precedence over the mass-delete reason', () => {
const d = decideAbsenceDeletions({
treeComplete: false,
liveCount: 4,
existingCount: 10,
deleteCount: 6,
});
expect(d).toEqual({ apply: false, reason: 'incomplete-fetch' });
});
it('trivially applies when nothing is tracked or nothing would be deleted', () => {
expect(
decideAbsenceDeletions({
treeComplete: false,
liveCount: 0,
existingCount: 0,
deleteCount: 0,
}),
).toEqual({ apply: true });
expect(
decideAbsenceDeletions({
treeComplete: false,
liveCount: 5,
existingCount: 5,
deleteCount: 0,
}),
).toEqual({ apply: true });
});
});