Closes the architecture item from the #119 review: drop the "vendored from docmost-sync" framing and the CJS↔ESM `Function('import()')` bridge so the engine is a normal first-class gitmost package. Part 1 — vendoring markers removed (prose only, zero behavior change): reworded "VENDORED into gitmost" / "vendored from docmost-sync" / "Engine LOGIC is byte-identical" / "it's a port" comments across the engine. Behavior-bearing strings are untouched: BOT_AUTHOR_NAME/EMAIL and the `Docmost-Sync-Source:` provenance trailers (changing them would break git authorship + the loop-guard). Part 2 — the package is now ESM (matching the sibling @docmost/mcp): `type: module`, tsconfig Node16, `.js` extensions on relative imports, and a static `import { marked }` replacing the `new Function('return import(...)')` / `loadMarked` hack — the bridge is GONE from the package. The CommonJS NestJS server loads the now-ESM engine via a new `git-sync.loader.ts` that mirrors the existing `docmost-client.loader.ts` mcp loader exactly (Function-indirected dynamic import + cached promise + retry-on-reject). The 4 server consumers (orchestrator/datasource/vault-registry/git-http-backend) call `await loadGitSync()` for value exports; types stay `import type` (erased). The converter-gate spec — which needs the real converter — loads the package's TS source via a jest moduleNameMapper + isolatedModules (documented in that spec); the other git-sync specs mock the loader. Verified: engine builds pure ESM (no Function/require leftover), vitest 614, editor-ext build, server + client tsc, full server jest 1397/0. Live stand smoke-test: server starts clean on the ESM engine (no ERR_REQUIRE_ESM), a real sync cycle runs through the loader, and the basic e2e suite is 12/12 (clone via git-http-backend, push, pull, delete, 3-way merge — all through the new loader). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
6.0 KiB
TypeScript
170 lines
6.0 KiB
TypeScript
import { VaultGit } from "./git.js";
|
|
import { GitSyncClient } from "./client.types.js";
|
|
import { Settings } from "./settings.js";
|
|
import { readExisting, computePullActions, applyPullActions } from "./pull.js";
|
|
import { runPush } from "./push.js";
|
|
|
|
/**
|
|
* Absolute-path filesystem primitives the cycle needs. Injected (not imported)
|
|
* so the engine stays IO-free and unit-testable. `mkdir` is recursive; `rm` is
|
|
* force (a missing file is a no-op).
|
|
*/
|
|
export interface CycleFs {
|
|
readFile: (absPath: string) => Promise<string>;
|
|
writeFile: (absPath: string, text: string) => Promise<void>;
|
|
mkdir: (absDir: string) => Promise<void>;
|
|
rm: (absPath: string) => Promise<void>;
|
|
}
|
|
|
|
export interface RunCycleDeps {
|
|
spaceId: string;
|
|
/** The Docmost seam (reads for pull, writes for push). */
|
|
client: GitSyncClient;
|
|
/** The per-space git vault (a real working repo). */
|
|
vault: VaultGit;
|
|
/** Engine settings; `vaultPath` roots the relPath -> absolute-path mapping. */
|
|
settings: Settings;
|
|
fs: CycleFs;
|
|
log: (line: string) => void;
|
|
/**
|
|
* Delete-cap hook (the ONLY caller-specific policy). Called with the push
|
|
* dry-run's planned delete count (`Number.POSITIVE_INFINITY` when the dry-run
|
|
* itself failed, so the hook can fail safe) and the live client; returns the
|
|
* client to use for the REAL apply. The default (omitted) applies every op
|
|
* unmodified. gitmost uses it to neutralize deletes when over its cap.
|
|
*
|
|
* When omitted, NO dry-run is performed (one fewer push planning pass).
|
|
*/
|
|
resolveApplyClient?: (
|
|
plannedDeletes: number,
|
|
client: GitSyncClient,
|
|
) => GitSyncClient;
|
|
}
|
|
|
|
export interface RunCycleResult {
|
|
ran: boolean;
|
|
/** Set when the cycle short-circuited without running pull/push. */
|
|
skipped?: "merge-in-progress";
|
|
pull?: { written: number; deleted: number; conflict: boolean };
|
|
push?: { mode: string; failures: number };
|
|
}
|
|
|
|
/**
|
|
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
|
|
* (vault -> Docmost), under the engine's required branch choreography. This is
|
|
* the single entry point the app drives — it owns the staging order so it can
|
|
* never drift from the engine it ships with.
|
|
*
|
|
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
|
|
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
|
|
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
|
|
* would fail otherwise.
|
|
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
|
|
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
|
|
* then checks out `main` and merges docmost -> main. Writing Docmost
|
|
* content straight onto `main` would clobber local file edits before push
|
|
* can diff them.
|
|
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
|
|
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
|
|
*
|
|
* Lock + cap POLICY live in the caller; this owns only the mechanics.
|
|
*/
|
|
export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
|
|
const { spaceId, client, vault, settings, fs, log, resolveApplyClient } =
|
|
deps;
|
|
const vaultRoot = settings.vaultPath;
|
|
const abs = (relPath: string) => `${vaultRoot}/${relPath}`;
|
|
|
|
// 1. The engine state store is git: make sure the repo + branches exist
|
|
// before any tracked-file listing or diff.
|
|
await vault.assertGitAvailable();
|
|
await vault.ensureRepo();
|
|
|
|
// 2. Refuse to run on top of an unresolved merge (SPEC §9): a prior
|
|
// conflicting pull leaves the vault mid-merge; the next checkout would fail.
|
|
if (await vault.isMergeInProgress()) {
|
|
log(
|
|
`vault has an unresolved merge — resolve it (or 'git merge --abort') ` +
|
|
`and re-run (SPEC §9); skipping cycle.`,
|
|
);
|
|
return { ran: false, skipped: "merge-in-progress" };
|
|
}
|
|
|
|
// 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring).
|
|
await vault.ensureBranch("docmost", "main");
|
|
await vault.checkout("docmost");
|
|
|
|
// 4. PULL --------------------------------------------------------------------
|
|
const existing = await readExisting({
|
|
listTracked: () => vault.listTrackedFiles("*.md"),
|
|
readFile: (relPath) => fs.readFile(abs(relPath)),
|
|
});
|
|
|
|
const tree = await client.listSpaceTree(spaceId);
|
|
const pullActions = computePullActions({
|
|
pages: tree.pages,
|
|
treeComplete: tree.complete,
|
|
existing,
|
|
});
|
|
|
|
const pullResult = await applyPullActions(
|
|
{
|
|
client,
|
|
git: vault,
|
|
writeFile: (absPath, text) => fs.writeFile(absPath, text),
|
|
mkdir: (absDir) => fs.mkdir(absDir),
|
|
rm: (absPath) => fs.rm(absPath),
|
|
},
|
|
pullActions,
|
|
vaultRoot,
|
|
);
|
|
|
|
// 5. PUSH --------------------------------------------------------------------
|
|
const pushDeps = {
|
|
settings,
|
|
git: vault,
|
|
makeClient: () => client,
|
|
readFile: (relPath: string) => fs.readFile(abs(relPath)),
|
|
writeFile: (relPath: string, text: string) => fs.writeFile(abs(relPath), text),
|
|
log,
|
|
};
|
|
|
|
let applyClient = client;
|
|
if (resolveApplyClient) {
|
|
// Plan the push as a DRY-RUN first to read the delete count, then let the
|
|
// caller decide the apply client (e.g. neutralize deletes over a cap). A
|
|
// failed dry-run yields Infinity so the hook can fail safe.
|
|
let plannedDeletes: number;
|
|
try {
|
|
const dry = await runPush(pushDeps, { dryRun: true });
|
|
plannedDeletes = dry.planned?.deletes ?? 0;
|
|
} catch (err) {
|
|
log(
|
|
`push dry-run planning failed (${
|
|
err instanceof Error ? err.message : String(err)
|
|
}); deferring deletion policy to the cap hook (fail-safe).`,
|
|
);
|
|
plannedDeletes = Number.POSITIVE_INFINITY;
|
|
}
|
|
applyClient = resolveApplyClient(plannedDeletes, client);
|
|
}
|
|
|
|
const pushResult = await runPush(
|
|
{ ...pushDeps, makeClient: () => applyClient },
|
|
{ dryRun: false },
|
|
);
|
|
|
|
return {
|
|
ran: true,
|
|
pull: {
|
|
written: pullResult.written,
|
|
deleted: pullResult.deleted,
|
|
conflict: pullResult.merge.conflict,
|
|
},
|
|
push: {
|
|
mode: pushResult.mode,
|
|
failures: pushResult.failures?.length ?? 0,
|
|
},
|
|
};
|
|
}
|