- git.ts: fastForwardBranch(branch, toCommit) — advances ONLY on a true
fast-forward (merge-base --is-ancestor), refuses a non-ff without clobbering
divergent docmost history
- push.ts: after a CLEAN push (failures===0) advance both refs/docmost/last-pushed
AND fast-forward the docmost mirror, so the next pull sees no diff for pushed
pages (loop-guard, git-native); a partial push advances NEITHER (§12)
- push.ts: per-page error isolation (one bad page doesn't block the batch,
failures recorded); create requires a non-empty spaceId else skipped (§8 spirit)
- loop-guard.ts: bodyHash() (sha256) + per-page pushed:[{pageId,updatedAt?,bodyHash}]
record for the §10 self-write suppression (pull-side consumption deferred)
- test: markdown-roundtrip property tests get a 30s per-test timeout (deterministic
inputs via fixed seed; the only flakiness was wall-clock under parallel load,
which intermittently failed CI/docker)
- 709 -> 724 green (3x stable); build clean; corpus STABLE
Deferred (next/final increment): move/rename apply, pull-side loop-guard consumption,
FS-watcher/debounce (§7.1), git-remote push (§7.2), runnable live main(),
escalate-on-divergent-docmost.
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
import { applyPushActions, LAST_PUSHED_REF } from '../src/push.js';
|
|
import { bodyHash } from '../src/loop-guard.js';
|
|
import type { ApplyPushDeps, PushActions } from '../src/push.js';
|
|
import {
|
|
parseDocmostMarkdown,
|
|
serializeDocmostMarkdownBody,
|
|
} from '../packages/docmost-client/src/lib/markdown-document.js';
|
|
|
|
// FS→Docmost push, FIRST increment (SPEC §6). `applyPushActions` is the THIN IO
|
|
// half: create/update/delete via FAKES that record every call — no real network,
|
|
// git, or fs. Asserts: update uses importPageMarkdown (collab path, SPEC
|
|
// §2/§15.6); create writes the assigned pageId BACK into the file meta; delete
|
|
// soft-deletes; rename/move is returned as `deferred` with NO client call; the
|
|
// last-pushed ref is advanced.
|
|
|
|
/** A recording client fake; createPage returns a configurable assigned id. */
|
|
function makeClient(opts?: { createId?: string }) {
|
|
const client = {
|
|
importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => ({
|
|
success: true,
|
|
})),
|
|
createPage: vi.fn(
|
|
async (
|
|
title: string,
|
|
_content: string,
|
|
_spaceId: string,
|
|
_parentPageId?: string,
|
|
) => ({
|
|
// Mirrors the real `createPage` shape: `{ data: { id, ... }, success }`.
|
|
data: { id: opts?.createId ?? 'assigned-id', title },
|
|
success: true,
|
|
}),
|
|
),
|
|
deletePage: vi.fn(async (_pageId: string) => ({ success: true })),
|
|
};
|
|
return client;
|
|
}
|
|
|
|
/**
|
|
* A recording git fake: `updateRef` (advance last-pushed) and `fastForwardBranch`
|
|
* (advance the `docmost` mirror, the loop-close). `ffResult` configures what the
|
|
* ff returns (default a successful advance).
|
|
*/
|
|
function makeGit(opts?: { ffResult?: { ok: boolean; reason?: string } }) {
|
|
const updateRefCalls: { ref: string; target: string }[] = [];
|
|
const ffCalls: { branch: string; toCommit: string }[] = [];
|
|
const git = {
|
|
updateRef: vi.fn(async (ref: string, target: string) => {
|
|
updateRefCalls.push({ ref, target });
|
|
}),
|
|
fastForwardBranch: vi.fn(async (branch: string, toCommit: string) => {
|
|
ffCalls.push({ branch, toCommit });
|
|
return opts?.ffResult ?? { ok: true };
|
|
}),
|
|
};
|
|
return { git, updateRefCalls, ffCalls };
|
|
}
|
|
|
|
/** A recording fs fake over a path->text store. */
|
|
function makeFs(initial: Record<string, string> = {}) {
|
|
const store: Record<string, string> = { ...initial };
|
|
const writes: { path: string; text: string }[] = [];
|
|
const reads: string[] = [];
|
|
const fs = {
|
|
readFile: vi.fn(async (path: string) => {
|
|
reads.push(path);
|
|
if (!(path in store)) throw new Error(`no such file: ${path}`);
|
|
return store[path];
|
|
}),
|
|
writeFile: vi.fn(async (path: string, text: string) => {
|
|
store[path] = text;
|
|
writes.push({ path, text });
|
|
}),
|
|
};
|
|
return { fs, store, writes, reads };
|
|
}
|
|
|
|
function deps(client: any, git: any, fs: ReturnType<typeof makeFs>): ApplyPushDeps {
|
|
return {
|
|
client,
|
|
git,
|
|
readFile: fs.fs.readFile,
|
|
writeFile: fs.fs.writeFile,
|
|
};
|
|
}
|
|
|
|
function actions(partial: Partial<PushActions>): PushActions {
|
|
return {
|
|
creates: [],
|
|
updates: [],
|
|
deletes: [],
|
|
renamesMoves: [],
|
|
skipped: [],
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('applyPushActions — update (collab path, SPEC §2/§15.6)', () => {
|
|
it('reads the file body and calls importPageMarkdown with it', async () => {
|
|
const fileBody =
|
|
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nupdated body\n';
|
|
const client = makeClient();
|
|
const { git } = makeGit();
|
|
const fs = makeFs({ 'Doc.md': fileBody });
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }),
|
|
);
|
|
|
|
expect(res.updated).toBe(1);
|
|
// The collab/Yjs write path is used — NOT a raw jsonb overwrite.
|
|
expect(client.importPageMarkdown).toHaveBeenCalledTimes(1);
|
|
expect(client.importPageMarkdown).toHaveBeenCalledWith('p-1', fileBody);
|
|
// No raw-overwrite path exists on the injected client surface at all.
|
|
expect((client as any).updatePageJson).toBeUndefined();
|
|
expect(client.createPage).not.toHaveBeenCalled();
|
|
expect(client.deletePage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — create (assigned pageId written back to meta)', () => {
|
|
it('createPage is called and the new pageId is serialized back into the file', async () => {
|
|
// A brand-new local file: meta has title/spaceId but NO pageId yet.
|
|
const original = serializeDocmostMarkdownBody(
|
|
{ version: 1, title: 'My New Page', spaceId: 'sp-7', parentPageId: 'parent-9' },
|
|
'# My New Page\n\nbody text',
|
|
);
|
|
const client = makeClient({ createId: 'page-new-42' });
|
|
const { git } = makeGit();
|
|
const fs = makeFs({ 'New.md': original });
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'New.md' }] }),
|
|
);
|
|
|
|
expect(res.created).toBe(1);
|
|
// createPage was called with title/body/spaceId/parentPageId from meta.
|
|
expect(client.createPage).toHaveBeenCalledTimes(1);
|
|
const [title, content, spaceId, parentPageId] =
|
|
client.createPage.mock.calls[0];
|
|
expect(title).toBe('My New Page');
|
|
expect(spaceId).toBe('sp-7');
|
|
expect(parentPageId).toBe('parent-9');
|
|
expect(content).toContain('body text');
|
|
|
|
// The file was rewritten with the assigned pageId in meta...
|
|
expect(fs.writes.map((w) => w.path)).toEqual(['New.md']);
|
|
const rewritten = fs.store['New.md'];
|
|
const parsed = parseDocmostMarkdown(rewritten);
|
|
expect(parsed.meta?.pageId).toBe('page-new-42');
|
|
// ...preserving the rest of the meta and the body.
|
|
expect(parsed.meta?.title).toBe('My New Page');
|
|
expect(parsed.meta?.spaceId).toBe('sp-7');
|
|
expect(parsed.body).toContain('body text');
|
|
|
|
// The write-back is recorded so a follow-up commit can be made (NEXT inc).
|
|
expect(res.writtenBack).toEqual([{ path: 'New.md', pageId: 'page-new-42' }]);
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — delete (soft-delete to Trash, SPEC §8)', () => {
|
|
it('calls deletePage(pageId)', async () => {
|
|
const client = makeClient();
|
|
const { git } = makeGit();
|
|
const fs = makeFs();
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ deletes: [{ pageId: 'p-del' }] }),
|
|
);
|
|
|
|
expect(res.deleted).toBe(1);
|
|
expect(client.deletePage).toHaveBeenCalledTimes(1);
|
|
expect(client.deletePage).toHaveBeenCalledWith('p-del');
|
|
// No body read needed for a delete.
|
|
expect(fs.reads).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — rename/move is DEFERRED (NEXT increment)', () => {
|
|
it('returns renames/moves as `deferred` with NO client call', async () => {
|
|
const client = makeClient();
|
|
const { git } = makeGit();
|
|
const fs = makeFs();
|
|
|
|
const rm = { pageId: 'p-mv', oldPath: 'Old.md', newPath: 'New.md' };
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ renamesMoves: [rm] }),
|
|
);
|
|
|
|
expect(res.deferred).toEqual([rm]);
|
|
// NOTHING was pushed for the move this increment.
|
|
expect(client.importPageMarkdown).not.toHaveBeenCalled();
|
|
expect(client.createPage).not.toHaveBeenCalled();
|
|
expect(client.deletePage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — loop-close: ref advance + docmost ff (SPEC §6 step 3 / §10)', () => {
|
|
it('advances last-pushed AND fast-forwards the docmost mirror on a clean push', async () => {
|
|
const client = makeClient();
|
|
const { git, updateRefCalls, ffCalls } = makeGit();
|
|
const fs = makeFs();
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ deletes: [{ pageId: 'p' }] }),
|
|
'commit-sha-abc',
|
|
);
|
|
|
|
expect(res.lastPushedAdvanced).toBe(true);
|
|
expect(updateRefCalls).toEqual([
|
|
{ ref: LAST_PUSHED_REF, target: 'commit-sha-abc' },
|
|
]);
|
|
// The loop-close: the docmost mirror is fast-forwarded to the pushed commit.
|
|
expect(ffCalls).toEqual([{ branch: 'docmost', toCommit: 'commit-sha-abc' }]);
|
|
expect(res.docmostFastForward).toEqual({ ok: true });
|
|
});
|
|
|
|
it('surfaces a REFUSED non-fast-forward (mirror NOT clobbered)', async () => {
|
|
const client = makeClient();
|
|
// The ff is refused because docmost is not an ancestor of the pushed commit.
|
|
const { git, updateRefCalls, ffCalls } = makeGit({
|
|
ffResult: { ok: false, reason: 'not-fast-forward' },
|
|
});
|
|
const fs = makeFs();
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ deletes: [{ pageId: 'p' }] }),
|
|
'sha-div',
|
|
);
|
|
|
|
// last-pushed still advances (it is our own marker), but the ff result is
|
|
// surfaced so the caller can log the refusal.
|
|
expect(res.lastPushedAdvanced).toBe(true);
|
|
expect(updateRefCalls).toEqual([{ ref: LAST_PUSHED_REF, target: 'sha-div' }]);
|
|
expect(ffCalls).toEqual([{ branch: 'docmost', toCommit: 'sha-div' }]);
|
|
expect(res.docmostFastForward).toEqual({ ok: false, reason: 'not-fast-forward' });
|
|
});
|
|
|
|
it('does NOT advance either ref when no pushed commit is given', async () => {
|
|
const client = makeClient();
|
|
const { git, updateRefCalls } = makeGit();
|
|
const fs = makeFs();
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ updates: [] }),
|
|
);
|
|
|
|
expect(res.lastPushedAdvanced).toBe(false);
|
|
expect(updateRefCalls).toEqual([]);
|
|
expect(res.docmostFastForward).toBeNull();
|
|
expect(git.updateRef).not.toHaveBeenCalled();
|
|
expect(git.fastForwardBranch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — per-page error isolation + refs gated on success (SPEC §12)', () => {
|
|
it('continues the batch when an update throws; records the failure; refs NOT advanced', async () => {
|
|
// A client whose 2nd importPageMarkdown call throws — the 1st and 3rd must
|
|
// still be applied, the 2nd recorded as a failure, and NO ref advanced.
|
|
let call = 0;
|
|
const client = {
|
|
importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => {
|
|
call++;
|
|
if (call === 2) throw new Error('boom on page 2');
|
|
return { success: true };
|
|
}),
|
|
createPage: vi.fn(),
|
|
deletePage: vi.fn(),
|
|
};
|
|
const { git, updateRefCalls, ffCalls } = makeGit();
|
|
const fs = makeFs({
|
|
'A.md': 'a body',
|
|
'B.md': 'b body',
|
|
'C.md': 'c body',
|
|
});
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({
|
|
updates: [
|
|
{ pageId: 'p-a', path: 'A.md' },
|
|
{ pageId: 'p-b', path: 'B.md' },
|
|
{ pageId: 'p-c', path: 'C.md' },
|
|
],
|
|
}),
|
|
'sha-partial',
|
|
);
|
|
|
|
// The 1st and 3rd were applied; the 2nd threw.
|
|
expect(res.updated).toBe(2);
|
|
expect(client.importPageMarkdown).toHaveBeenCalledTimes(3);
|
|
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(1, 'p-a', 'a body');
|
|
expect(client.importPageMarkdown).toHaveBeenNthCalledWith(3, 'p-c', 'c body');
|
|
|
|
// The failure is recorded with kind/pageId/path/error.
|
|
expect(res.failures).toEqual([
|
|
{ kind: 'update', pageId: 'p-b', path: 'B.md', error: 'boom on page 2' },
|
|
]);
|
|
|
|
// Only the successful pages carry a loop-guard push record.
|
|
expect(res.pushed.map((p) => p.pageId)).toEqual(['p-a', 'p-c']);
|
|
|
|
// A PARTIAL push advances NEITHER ref, so a re-run retries cleanly (§12).
|
|
expect(res.lastPushedAdvanced).toBe(false);
|
|
expect(updateRefCalls).toEqual([]);
|
|
expect(ffCalls).toEqual([]);
|
|
expect(res.docmostFastForward).toBeNull();
|
|
expect(git.updateRef).not.toHaveBeenCalled();
|
|
expect(git.fastForwardBranch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — loop-guard push record (SPEC §10)', () => {
|
|
it('records pageId + updatedAt + bodyHash per applied update', async () => {
|
|
const fileBody =
|
|
'<!-- docmost:meta\n{"version":1,"pageId":"p-1"}\n-->\n\nupdated body\n';
|
|
const client = {
|
|
importPageMarkdown: vi.fn(async (_pageId: string, _md: string) => ({
|
|
// The write returns an updatedAt the loop-guard records.
|
|
data: { updatedAt: '2026-06-20T10:00:00.000Z' },
|
|
success: true,
|
|
})),
|
|
createPage: vi.fn(),
|
|
deletePage: vi.fn(),
|
|
};
|
|
const { git } = makeGit();
|
|
const fs = makeFs({ 'Doc.md': fileBody });
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ updates: [{ pageId: 'p-1', path: 'Doc.md' }] }),
|
|
);
|
|
|
|
expect(res.pushed).toHaveLength(1);
|
|
expect(res.pushed[0].pageId).toBe('p-1');
|
|
expect(res.pushed[0].updatedAt).toBe('2026-06-20T10:00:00.000Z');
|
|
// The bodyHash is a stable sha256 hex of the pushed markdown.
|
|
expect(res.pushed[0].bodyHash).toBe(bodyHash(fileBody));
|
|
expect(res.pushed[0].bodyHash).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it('omits updatedAt when the client result does not expose one', async () => {
|
|
const newFile = serializeDocmostMarkdownBody(
|
|
{ version: 1, title: 'N', spaceId: 'sp' },
|
|
'fresh body',
|
|
);
|
|
const client = makeClient({ createId: 'created-9' });
|
|
const { git } = makeGit();
|
|
const fs = makeFs({ 'N.md': newFile });
|
|
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'N.md' }] }),
|
|
);
|
|
|
|
expect(res.pushed).toHaveLength(1);
|
|
expect(res.pushed[0].pageId).toBe('created-9');
|
|
expect(res.pushed[0].updatedAt).toBeUndefined();
|
|
// bodyHash of the ORIGINAL pushed file text (what createPage received).
|
|
expect(res.pushed[0].bodyHash).toBe(bodyHash(newFile));
|
|
});
|
|
});
|
|
|
|
describe('applyPushActions — mixed batch + skipped passthrough', () => {
|
|
it('applies update + create + delete and carries skipped rows through', async () => {
|
|
const updFile =
|
|
'<!-- docmost:meta\n{"version":1,"pageId":"u-1"}\n-->\n\nupd\n';
|
|
const newFile = serializeDocmostMarkdownBody(
|
|
{ version: 1, title: 'N', spaceId: 'sp' },
|
|
'fresh body',
|
|
);
|
|
const client = makeClient({ createId: 'created-1' });
|
|
const { git, updateRefCalls } = makeGit();
|
|
const fs = makeFs({ 'U.md': updFile, 'N.md': newFile });
|
|
|
|
const skipped = [
|
|
{ path: 'Stray.md', status: 'D' as const, reason: 'no recoverable pageId' },
|
|
];
|
|
const res = await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({
|
|
updates: [{ pageId: 'u-1', path: 'U.md' }],
|
|
creates: [{ path: 'N.md' }],
|
|
deletes: [{ pageId: 'd-1' }],
|
|
skipped,
|
|
}),
|
|
'sha-9',
|
|
);
|
|
|
|
expect(res).toMatchObject({
|
|
created: 1,
|
|
updated: 1,
|
|
deleted: 1,
|
|
lastPushedAdvanced: true,
|
|
});
|
|
expect(res.writtenBack).toEqual([{ path: 'N.md', pageId: 'created-1' }]);
|
|
expect(res.skipped).toEqual(skipped);
|
|
expect(updateRefCalls).toEqual([{ ref: LAST_PUSHED_REF, target: 'sha-9' }]);
|
|
expect(client.importPageMarkdown).toHaveBeenCalledWith('u-1', updFile);
|
|
expect(client.deletePage).toHaveBeenCalledWith('d-1');
|
|
});
|
|
});
|