// Rehearsal-grade space clones — a writable copy of a real space store, plus
// the bookkeeping that makes repeated rehearsal attempts comparable.
//
// Design + evidence: docs/plans/space-clone-rehearsal.md. Two findings from the
// July 2026 Topics migration shape this module:
//
// * The COPY was never the hard part. A server-side `VACUUM INTO` already
// emits one correctly-named, companion-free file, and a plain `cp` was
// enough. So the copy path here is deliberately thin.
// * The BOOKKEEPING was re-invented every attempt (ad-hoc SQL and Python
// heredocs). Two rehearsals whose fingerprints were computed differently
// cannot be compared, and an improvised check that quietly measures less
// than last time looks exactly like a pass. The manifest is the payload.
//
// Layout written by `createClone`:
//
//
/clone.json manifest: source, hashes, counts
// /.cf-clone marker — "this store is NOT production"
// /pristine/.sqlite the baseline; never opened read-write
// /engine-v3/engine-v3/.sqlite
// the working copy a toolshed serves
//
// That path is not decoration, and the doubled segment is not a typo: it is
// what the memory server composes, so `clonePaths` DERIVES it rather than
// spelling it out. Pointing `MEMORY_DIR` at then serves the clone as a
// live space under the SAME DID.
import * as Path from "@std/path";
// The only read-WRITE database handle in this package, and it opens nothing:
// `assertNotInUse` takes a lock to ask whether a server is still holding the
// working copy. `db.ts` stays read-only by contract, so the probe lives here
// with the rest of the code that legitimately writes to a clone.
import { Database } from "@db/sqlite";
import {
resolveMemoryEngineStoreRootUrl,
resolveSpaceStoreUrl,
} from "@commonfabric/memory/v2/storage-path";
import type { MemorySpace } from "@commonfabric/memory/interface";
import { createHasher } from "@commonfabric/content-hash";
import { isDID } from "@commonfabric/identity/did";
import { openSpace } from "./db.ts";
import {
contentFingerprint,
diffFingerprints,
entityAddressKey,
FINGERPRINT_SCHEME,
type FingerprintReport,
type ScopedEntity,
} from "./fingerprint.ts";
/** Manifest filename the layout depends on. */
const MANIFEST = "clone.json";
/**
* Per-entity baseline hashes, written beside the manifest at clone time.
*
* `createClone` already fingerprints the pristine snapshot; keeping its
* per-entity rows means `verify` diffs against a file instead of re-opening and
* re-fingerprinting a multi-gigabyte baseline on every call — measured at 43s
* vs 13s on the 1.0 GB Estuary store, on the operation a rehearsal runs most.
* Kept out of `clone.json` so that file stays small enough to read by eye.
*/
const BASELINE = "baseline-entities.json";
const MARKER = ".cf-clone";
const PRISTINE_DIR = "pristine";
/** What every manifest version records about a clone. */
interface CloneManifestFields {
/** Space DID — the clone keeps it (see the design doc's identity section). */
space: string;
/** Where the snapshot came from: a path or URL, verbatim, for provenance. */
source: string;
/** ISO timestamp the clone was taken. */
createdAt: string;
/** SHA-256 of the pristine snapshot file. */
snapshotHash: string;
/**
* SHA-256 of the per-entity baseline sidecar.
*
* The sidecar feeds `diff` and therefore `okAfterMigration` — the verdict a
* rehearsal script gates on — so it gets the same integrity treatment as the
* snapshot. Without this, a corrupted-but-parseable sidecar (two hashes
* swapped inside valid JSON) would produce a confident, wrong diff while
* `baselineIntact` still reported true. Absent on clones taken before the
* sidecar existed, which fall back to recomputing.
*/
baselineHash?: string;
snapshotBytes: number;
/** Durable counts at clone time — the cheap half of "did content survive?". */
counts: {
commits: number;
revisions: number;
entities: number;
maxSeq: number;
};
}
/**
* A clone's manifest, as written by {@link createClone} and read by
* {@link readManifest}.
*
* Version 2 records `fingerprint.scheme`, and a version-2 manifest without one
* is refused as damaged rather than read as a version-1 clone. A tool that
* understands only 1 refuses a version-2 clone before touching it, which is
* what keeps an older checkout from resetting or verifying a clone whose
* baseline it would fingerprint differently, or with defects since fixed.
*/
export type CloneManifest =
| CloneManifestFields & { version: 1; fingerprint: ManifestFingerprint }
| CloneManifestFields & {
version: 2;
fingerprint: ManifestFingerprint & {
/** The {@link FINGERPRINT_SCHEME} the baseline was computed under. */
scheme: number;
};
};
/**
* Content fingerprint at clone time, generated cells excluded.
*
* `unhashable` and `ambiguous` record what the baseline fingerprint could NOT
* speak for. Both are absent from the roll-up by construction, so a verdict
* computed from the hash alone rests on however much evidence happened to
* exist — and without recording the number here, nobody could tell whether
* that was all of it. Optional: clones taken before these were recorded
* report them as unknown rather than as zero.
*/
interface ManifestFingerprint {
hash: string;
entities: number;
excludedGenerated: number;
unhashable?: number;
ambiguous?: number;
}
export interface CreateCloneOptions {
/** Path to a `.sqlite` snapshot (a server-side `VACUUM INTO` output). */
source: string;
/** Space DID; determines the on-disk filename the server resolves. */
space: string;
/** Destination clone directory. Created if absent, must be empty otherwise. */
targetDir: string;
/**
* Store directories that must never be written to — normally the live
* server's. Callers pass what the environment says (`MEMORY_DIR`/`DB_PATH`).
*/
forbiddenDirs?: string[];
/** Timestamp source, injectable so tests need no clock. */
now?: () => Date;
}
export interface ClonePaths {
dir: string;
manifestPath: string;
/** Per-entity baseline hashes (see {@link BASELINE}). */
baselinePath: string;
pristinePath: string;
workingPath: string;
}
/**
* Where each artifact lives for a clone of `space` in `dir`.
*
* The working copy's path is DERIVED, never spelled out: `dir` is used exactly
* as a server's `MEMORY_DIR`, and the store lands wherever composing
* `resolveMemoryEngineStoreRootUrl` with `resolveSpaceStoreUrl` puts it — which
* today means a doubled `engine-v3/engine-v3/`. Restating that here is how the
* first version of this file wrote a clone to a path no server ever reads: the
* copy/verify/reset loop was self-consistent and served nothing.
*/
export function clonePaths(dir: string, space: string): ClonePaths {
const workingPath = Path.fromFileUrl(
resolveSpaceStoreUrl(
resolveMemoryEngineStoreRootUrl(
Path.toFileUrl(dir.endsWith("/") ? dir : `${dir}/`),
{ singleFileMode: false },
),
space as MemorySpace,
),
);
return {
dir,
manifestPath: `${dir}/${MANIFEST}`,
baselinePath: `${dir}/${BASELINE}`,
pristinePath: `${dir}/${PRISTINE_DIR}/${space}.sqlite`,
workingPath,
};
}
/**
* Build a clone from a snapshot: copy to `pristine/`, copy that to
* `engine-v3/`, and record a manifest.
*
* The source is never opened read-write and never mutated — it is read for
* hashing and copied. Refuses to write into a forbidden (live) store directory
* or into the source's own directory.
*/
export async function createClone(
options: CreateCloneOptions,
): Promise {
const now = options.now ?? (() => new Date());
const dir = await canonicalPath(options.targetDir);
const source = await canonicalPath(options.source);
// Drop blank entries BEFORE canonicalizing: `Path.resolve("")` is the current
// working directory, which would forbid every target beneath it.
const forbidden = await Promise.all(
(options.forbiddenDirs ?? [])
.filter((entry) => entry.trim() !== "")
.map(canonicalPath),
);
assertSafeTarget(dir, source, forbidden);
await assertEmptyOrAbsent(dir);
const paths = clonePaths(dir, options.space);
await Deno.mkdir(`${dir}/${PRISTINE_DIR}`, { recursive: true });
await Deno.mkdir(Path.dirname(paths.workingPath), { recursive: true });
// Plain copies. `VACUUM INTO` server-side already produced a consistent,
// companion-free file; re-vacuuming here would need the live source, which by
// this point is typically an scp'd file on a laptop.
await Deno.copyFile(source, paths.pristinePath);
await Deno.copyFile(paths.pristinePath, paths.workingPath);
const snapshotHash = await hashFile(paths.pristinePath);
const snapshotBytes = (await Deno.stat(paths.pristinePath)).size;
const space = openSpace(paths.pristinePath);
let counts: CloneManifest["counts"];
let fingerprint: FingerprintReport;
try {
counts = storeCounts(space);
fingerprint = contentFingerprint(space);
} finally {
space.close();
}
const manifest: CloneManifest = {
version: 2,
space: options.space,
source: options.source,
createdAt: now().toISOString(),
snapshotHash,
snapshotBytes,
counts,
fingerprint: {
hash: fingerprint.hash,
entities: fingerprint.entities,
excludedGenerated: fingerprint.excludedGenerated,
unhashable: fingerprint.unhashable.length,
ambiguous: fingerprint.ambiguous.length,
scheme: FINGERPRINT_SCHEME,
},
};
await Deno.writeTextFile(
paths.manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
);
await Deno.writeTextFile(
paths.baselinePath,
JSON.stringify(fingerprint.perEntity),
);
// Recorded after the write so the manifest describes what is actually on disk.
manifest.baselineHash = await hashFile(paths.baselinePath);
await Deno.writeTextFile(
paths.manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
);
await Deno.writeTextFile(
`${dir}/${MARKER}`,
`This directory is a CLONE of space ${options.space}, not production.\n` +
`Taken ${manifest.createdAt} from ${options.source}.\n` +
`Reset it with: cf space reset ${dir}\n`,
);
return manifest;
}
/** What {@link resetClone} restored, and what it cleared away. */
export interface ResetResult {
manifest: CloneManifest;
/**
* DIDs of the other spaces whose stores the reset removed from beside the
* working copy.
*
* The engine directory holds the cloned space alone when a clone is taken, so
* any other store in it was created by the attempt being discarded: a link
* into another space makes the server create a store for that space on
* demand, and the pass then writes into it. Left in place, the next pass
* starts from the last one's state there while `verify` — which reads only
* the cloned space — reports the clone pristine.
*/
removedStores: string[];
/**
* File names of the cell-derived databases the reset removed.
*
* The memory server keeps these beside a file store (`#cellDbPath` in
* `packages/memory/v2/server.ts`), for the cloned space as much as for any
* other. A snapshot is the space's store alone, so a clone starts with none,
* and any present at reset time were written by the attempt.
*/
removedCellDatabases: string[];
}
/**
* Restore the working copy from the pristine snapshot, and remove every other
* database the attempt created beside it (see {@link ResetResult}).
*
* Deletes the `-wal`/`-shm` companions the engine creates on open. Leaving them
* behind would let a checkpoint replay part of the discarded attempt over the
* fresh copy — a reset that silently isn't one.
*
* REFUSES while a server still has any of those databases open, because unlinking
* a file does not disturb a process that already holds it: the server keeps
* reading and writing the unlinked inode while every new reader — including the
* `verify` this function's caller runs next — sees the restored one. Pass two of
* a rehearsal would then run against pass one's state while `cf space verify`
* reported the clone pristine, which is the failure mode the whole two-pass
* procedure exists to rule out.
*
* That check is a TRIPWIRE, not mutual exclusion. It catches the case that
* actually happens — an operator who forgot to stop a toolshed that is already
* serving the clone — and cannot prevent one that opens the store in the
* instant between the probe and the unlink. No external check can: a process
* that opens a SQLite file takes no lock in doing so, so there is no state to
* hold against it, and holding our own lock across the unlink would be worse
* (the probe connection would then rewrite `-wal`/`-shm` beside the freshly
* restored copy on close). Stopping the server is what makes a reset correct;
* this makes forgetting to loud rather than silent.
*/
export async function resetClone(dir: string): Promise {
const manifest = await readManifest(dir);
// A reset is judged by the verify that follows it, so a clone this tool
// cannot verify is refused before anything is restored.
assertComparableScheme(dir, manifest);
const paths = clonePaths(await canonicalPath(dir), manifest.space);
// Every database is probed before any is removed, so a refusal leaves the
// clone exactly as it was rather than half cleared. The working copy goes
// first: it is the store a forgotten server is most likely to hold, and the
// one a reset exists for.
await assertNotInUse(paths.workingPath);
const { stores, cellDatabases } = await attemptDatabases(paths.workingPath);
for (const database of [...stores, ...cellDatabases]) {
await assertNotInUse(database);
}
const databases = [paths.workingPath, ...stores, ...cellDatabases];
// The companions are absent on a database no engine has opened yet — the
// normal case at the start of a rehearsal — so their absence is not an error,
// while any OTHER failure must surface rather than leave a half-reset clone.
for (const database of databases) {
for (const suffix of ["", "-wal", "-shm"]) {
const path = `${database}${suffix}`;
if (await pathExists(path)) await Deno.remove(path);
}
}
await Deno.mkdir(Path.dirname(paths.workingPath), { recursive: true });
await Deno.copyFile(paths.pristinePath, paths.workingPath);
return {
manifest,
removedStores: stores.map((path) => Path.basename(path, ".sqlite")),
removedCellDatabases: cellDatabases.map((path) => Path.basename(path)),
};
}
/**
* The databases beside `workingPath` that a server created during an attempt,
* each kind sorted: other spaces' stores, and cell-derived databases.
*
* Both are recognized by the names the memory server gives them — a space's
* store is `.sqlite`, a cell-derived database `cell-.sqlite` — so
* anything else in the directory was put there by someone other than a server,
* and is left alone. What counts as a DID is `isDID`'s to say, the same test
* that admits a space in the first place, so no store a server could have
* created is missed for failing a narrower one.
*/
async function attemptDatabases(
workingPath: string,
): Promise<{ stores: string[]; cellDatabases: string[] }> {
const dir = Path.dirname(workingPath);
const own = Path.basename(workingPath);
const stores: string[] = [];
const cellDatabases: string[] = [];
// A clone whose engine directory was deleted outright has nothing beside the
// working copy, and the restore recreates the directory.
if (!(await pathExists(dir))) return { stores, cellDatabases };
for await (const entry of Deno.readDir(dir)) {
if (!entry.isFile || entry.name === own) continue;
if (!entry.name.endsWith(".sqlite")) continue;
const stem = entry.name.slice(0, -".sqlite".length);
if (isDID(stem)) {
stores.push(`${dir}/${entry.name}`);
} else if (stem.startsWith("cell-")) {
cellDatabases.push(`${dir}/${entry.name}`);
}
}
return { stores: stores.sort(), cellDatabases: cellDatabases.sort() };
}
/**
* Refuse if any process still holds the store at `storePath` open, as of now.
*
* "As of now" is the honest scope — see the tripwire note on {@link resetClone}
* for why nothing stronger is available from outside the server.
*
* The probe is the hazard itself rather than a proxy for it: take SQLite's
* exclusive lock, which in WAL mode cannot be granted while another connection
* has the shared-memory index mapped. A PID or lease file would have to be
* written by the server, kept in step with it, and disbelieved when stale; this
* asks the operating system the actual question and needs no bookkeeping. It
* also gives the right answer in the case a marker would get wrong: a toolshed
* that is running but has never opened THIS space's engine holds nothing, and
* resetting under it is safe because it will open the restored file when it
* first reads.
*
* Locking is the only signal the binding exposes — its errors carry a message
* and nothing else — so the two outcomes are separated by message. Anything
* that is not a lock conflict means the store could not be opened as a
* database at all, and that is precisely when a reset is the remedy rather than
* the risk, so it proceeds.
*/
async function assertNotInUse(storePath: string): Promise {
// Nothing to hold open, and `Database` would otherwise create an empty file
// just to probe it.
if (!(await pathExists(storePath))) return;
let db: Database;
try {
db = new Database(storePath, { create: false });
} catch {
return; // unopenable — reset is the fix, not the hazard
}
try {
db.exec("PRAGMA locking_mode=EXCLUSIVE");
db.exec("BEGIN IMMEDIATE");
db.exec("ROLLBACK");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/database (is|table is) locked/i.test(message)) return;
throw new Error(
`refusing to reset ${storePath}: another process still has it open ` +
`(${message}). Unlinking it would not reach that process — it would ` +
`keep serving the discarded attempt while verify reported the clone ` +
`pristine. Stop the server first (scripts/stop-local-dev.sh ` +
`--port-offset 10), reset, then start it again.`,
);
} finally {
db.close();
}
}
export interface VerifyResult {
/**
* Strict: the baseline is intact AND nothing moved at all.
*
* Correct for "is this clone still pristine?", and correct for catching an
* accidental clobber. NOT usable as the verdict after a migration: a schema
* update rewrites every piece's result value, so this is false after every
* successful one.
*
* Which of those two situations applies is something only the caller knows,
* so the tool does not guess — see `okAfterMigration`.
*/
ok: boolean;
/**
* Relaxed: the baseline is intact and nothing was REMOVED.
*
* The verdict to gate on when a migration was expected. Content *changing* is
* then information (results are rewritten by design); content *disappearing*
* is the alarm. It cannot distinguish a clobber from a migration — nothing
* hash-shaped can — which is why the runbook insists authored content be
* checked separately.
*/
okAfterMigration: boolean;
/** The pristine snapshot still hashes to what the manifest recorded. */
baselineIntact: boolean;
/** Working-copy counts, against the manifest's. */
counts: {
manifest: CloneManifest["counts"];
working: CloneManifest["counts"];
};
/** Working-copy content fingerprint, and whether it matches the baseline. */
fingerprint: {
manifest: string;
working: string;
match: boolean;
excludedGenerated: number;
};
/**
* WHAT moved, against the pristine baseline — the part an operator can act on.
*
* A schema migration necessarily rewrites every piece's result value, so
* `fingerprint.match` is false after ANY successful migration and cannot by
* itself distinguish "the update worked" from "content was destroyed". The
* shape of the change is what separates them: entities `removed` is the alarm,
* while `changed` confined to pieces and their derived cells is the migration
* doing its job. Measured on the July 2026 Topics rehearsal: 74 pieces and 73
* owned cells changed, 3,189 added, **0 removed**, with every authored title,
* body, comment and link byte-identical.
*/
diff: {
removed: number;
changed: number;
added: number;
/**
* Entities the baseline hashed and this store's manifests call generated at
* that same (id, scope). They are present in both stores, so they are not
* removed; the two sides derive their exclusions from their own pieces, and
* a migration that adopts an unlisted cell into a generated slot moves it
* from one list to the other. Reported rather than dropped: a number that
* silently disappears is one nobody can check.
*/
reclassifiedGenerated: number;
/**
* `changed` broken out per entity kind, so that "74 pieces" reads
* differently from "74 cells".
*/
changedByKind: Record;
/** `removed` broken out the same way. */
removedByKind: Record;
};
/**
* How much of the store the fingerprint could not speak for, on each side.
*
* Neither verdict above is computed from these, and that is deliberate rather
* than an omission — but a verdict that silently rests on partial evidence is
* indistinguishable from one that rests on all of it, so the number is
* reported wherever the verdict is.
*
* The two behave differently, and only one is already covered:
*
* * `unhashable` — an entity that BECOMES unhashable drops out of the
* working fingerprint entirely, so the diff already counts it as
* `removed` and both verdicts already fail. The gap the count closes is
* the entity unhashable on BOTH sides: invisible to the diff, so a change
* to it is silent, and the only way to know is that the number is nonzero.
* * `ambiguous` — an id one manifest calls generated and another calls
* named is counted as generated and excluded, which can hide a real
* change to authored content. Nothing gates on it, so the count is the
* whole signal.
*
* `manifest` is null on clones taken before these were recorded.
*/
uncertainty: {
unhashable: { manifest: number | null; working: number };
ambiguous: { manifest: number | null; working: number };
/**
* The fingerprint scheme the baseline was computed under, and this tool's.
* A verify under a different recorded scheme is refused, so the two differ
* only when the manifest's is null: a clone taken before the scheme was
* recorded, whose baseline may have been fingerprinted another way.
*/
scheme: { manifest: number | null; working: number };
};
}
/**
* Check a clone against its manifest: the baseline is what it claims to be, and
* the working copy still holds the same durable content.
*
* A migration rehearsal EXPECTS counts to grow — that is what a migration does.
*
* It also expects the content fingerprint to MOVE. Excluding generated cells is
* not enough to hold it still: a schema update rewrites every piece's result
* value, and results are part of the fingerprint. The first real rehearsal
* proved this — 74 pieces and 73 owned cells changed while every authored
* title, body, comment and link stayed byte-identical.
*
* So the hash cannot be the verdict after a migration. `diff` is: entities
* `removed` is the alarm, and changes confined to pieces and their derived
* cells are the update working. `ok` stays strict for checking an untouched
* clone; `okAfterMigration` is the one to gate a rehearsal on. Neither can see
* a clobber of authored content, which is why the runbook checks that
* separately.
*/
export async function verifyClone(dir: string): Promise {
const manifest = await readManifest(dir);
assertComparableScheme(dir, manifest);
const paths = clonePaths(await canonicalPath(dir), manifest.space);
const baselineIntact = await hashFile(paths.pristinePath) ===
manifest.snapshotHash;
const space = openSpace(paths.workingPath);
let counts: CloneManifest["counts"];
let fingerprint: FingerprintReport;
try {
counts = storeCounts(space);
fingerprint = contentFingerprint(space);
} finally {
space.close();
}
// Diff against the baseline recorded at clone time, so the report can say
// WHAT moved rather than merely that something did. Reading the sidecar
// avoids re-fingerprinting the pristine snapshot on every verify; a clone
// taken before the sidecar existed falls back to computing it.
let before: FingerprintReport;
try {
if (manifest.baselineHash !== undefined) {
const actual = await hashFile(paths.baselinePath);
if (actual !== manifest.baselineHash) {
throw new Error(
`${paths.baselinePath} does not match the hash recorded in ` +
`${MANIFEST}; the baseline is damaged and its diff would be wrong. ` +
`Re-clone rather than trusting this verdict.`,
);
}
}
const rows = JSON.parse(
await Deno.readTextFile(paths.baselinePath),
) as FingerprintReport["perEntity"];
before = {
hash: manifest.fingerprint.hash,
entities: rows.length,
excludedGenerated: manifest.fingerprint.excludedGenerated,
// The sidecar records how many the baseline excluded, not which. Empty
// is honest here and costs nothing: the reclassification below reads
// the WORKING store's exclusions, which is the side that can turn a
// present entity into an absent row.
excludedGeneratedAddresses: [],
ambiguous: [],
unhashable: [],
perEntity: rows,
};
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
const baseline = openSpace(paths.pristinePath);
try {
before = contentFingerprint(baseline);
} finally {
baseline.close();
}
}
const d = diffFingerprints(before, fingerprint);
// Keyed by id AND scope throughout, through the shared `entityAddressKey`:
// one id can hold a shared space value plus per-user/per-session overrides
// that are genuinely different entities, and a by-id lookup would let the
// last scope win — misclassifying the tally, and clearing a removal below
// because some OTHER scope of the same id is generated. `diffFingerprints`
// reports the address it compared by and keys it the same way, so there is
// nothing left to guess at here and no second spelling to drift from.
//
// A removal is an entity gone from the store, never one this store's own
// manifests reclassified. The two sides compute their exclusions
// independently — the baseline from the pristine pieces, this from the
// working ones — so a cell no pristine manifest listed and a migrated piece
// now calls generated is present in both stores and absent from one list.
// Subtracting them here is what keeps `removed` meaning destroyed content,
// which is the verdict the runbook tells an operator to stop on. A manifest
// link carries no scope, so an adopted id is excluded in every scope that
// holds it: only the addresses this store actually enumerated are subtracted,
// and a scope whose rows are gone is not among them.
const excludedNow = new Set(
fingerprint.excludedGeneratedAddresses.map(entityAddressKey),
);
const reclassifiedGenerated = d.removed.filter((at) =>
excludedNow.has(entityAddressKey(at))
);
const removed = d.removed.filter((at) =>
!excludedNow.has(entityAddressKey(at))
);
const kindIndex = (rows: FingerprintReport["perEntity"]) => {
const byAddress = new Map(rows.map((e) => [entityAddressKey(e), e.kind]));
return (at: ScopedEntity) =>
byAddress.get(entityAddressKey(at)) ?? "unknown";
};
const kindOf = kindIndex(fingerprint.perEntity);
const kindWas = kindIndex(before.perEntity);
const tally = (
entities: ScopedEntity[],
lookup: (at: ScopedEntity) => string,
) => {
const out: Record = {};
for (const at of entities) {
const k = lookup(at);
out[k] = (out[k] ?? 0) + 1;
}
return out;
};
const delta = {
removed: removed.length,
changed: d.changed.length,
added: d.added.length,
reclassifiedGenerated: reclassifiedGenerated.length,
changedByKind: tally(d.changed, kindOf),
removedByKind: tally(removed, kindWas),
};
const match = fingerprint.hash === manifest.fingerprint.hash;
return {
ok: baselineIntact && match,
okAfterMigration: baselineIntact && delta.removed === 0,
baselineIntact,
counts: { manifest: manifest.counts, working: counts },
fingerprint: {
manifest: manifest.fingerprint.hash,
working: fingerprint.hash,
match,
excludedGenerated: fingerprint.excludedGenerated,
},
diff: delta,
uncertainty: {
unhashable: {
manifest: manifest.fingerprint.unhashable ?? null,
working: fingerprint.unhashable.length,
},
ambiguous: {
manifest: manifest.fingerprint.ambiguous ?? null,
working: fingerprint.ambiguous.length,
},
scheme: {
manifest: manifest.version === 2 ? manifest.fingerprint.scheme : null,
working: FINGERPRINT_SCHEME,
},
},
};
}
/** Read and validate a clone manifest. */
export async function readManifest(dir: string): Promise {
const path = `${await canonicalPath(dir)}/${MANIFEST}`;
let text: string;
try {
text = await Deno.readTextFile(path);
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
throw new Error(`${dir} is not a clone directory (no ${MANIFEST}).`);
}
throw error;
}
const parsed = JSON.parse(text) as {
version?: unknown;
fingerprint?: { scheme?: unknown };
};
if (parsed.version !== 1 && parsed.version !== 2) {
throw new Error(
`${path} has manifest version ${parsed.version}; this tool understands ` +
`1 and 2.`,
);
}
// Version 2 is the version that records the scheme, so one without it is a
// damaged manifest. Reading it as a version-1 clone would let a verify
// proceed under a scheme nobody can name, which is what the record is for.
if (parsed.version === 2 && typeof parsed.fingerprint?.scheme !== "number") {
throw new Error(
`${path} has manifest version 2 but records no fingerprint scheme, so ` +
`the manifest is damaged; clone the snapshot again.`,
);
}
return parsed as CloneManifest;
}
/**
* Refuse a clone whose baseline was fingerprinted under a scheme other than
* this tool's, before anything is read or restored. A version-1 clone, which
* never recorded its scheme, passes, and its verify reports the scheme as
* unknown.
*/
function assertComparableScheme(dir: string, manifest: CloneManifest): void {
if (manifest.version === 1) return;
const scheme = manifest.fingerprint.scheme;
if (scheme === FINGERPRINT_SCHEME) return;
throw new Error(
`${dir}'s baseline was fingerprinted under scheme ${scheme}, and this ` +
`tool fingerprints under scheme ${FINGERPRINT_SCHEME}, so the two cannot ` +
`be compared. Use a checkout whose \`cf\` fingerprints under scheme ` +
`${scheme}, or clone the snapshot again with this one.`,
);
}
function storeCounts(
space: ReturnType,
): CloneManifest["counts"] {
const one = (sql: string): T =>
space.db.prepare(sql).get() as T;
const c = one<{ n: number; hi: number | null }>(
`SELECT count(*) n, max(seq) hi FROM "commit"`,
);
const r = one<{ n: number; e: number }>(
`SELECT count(*) n, count(DISTINCT id) e FROM revision`,
);
return { commits: c.n, revisions: r.n, entities: r.e, maxSeq: c.hi ?? 0 };
}
/**
* SHA-256 of a file, streamed through the canonical hasher
* (`@commonfabric/content-hash`) so a multi-GB store never lands in memory.
*/
async function hashFile(path: string): Promise {
const hasher = createHasher();
using file = await Deno.open(path, { read: true });
for await (const chunk of file.readable) hasher.update(chunk);
return hasher.digest("base64url");
}
/**
* An absolute, symlink-resolved form of `path` — even when it does not exist
* yet.
*
* Both properties are load-bearing for the safety rails. A relative path can
* never overlap an absolute live-store directory, so returning one verbatim
* silently disarms `assertSafeTarget` — the clone lands inside the store the
* rail was meant to protect, under a banner claiming it did not. And on macOS
* `/tmp` is a symlink to `/private/tmp`, so two spellings of one directory must
* canonicalize together or the comparison misses.
*
* `realPath` needs the path to exist, so for a target we are about to create we
* resolve the nearest existing ancestor and re-append the rest.
*/
async function canonicalPath(path: string): Promise {
const absolute = Path.resolve(path);
const tail: string[] = [];
let current = absolute;
while (true) {
try {
const real = await Deno.realPath(current);
return tail.length === 0
? real
: Path.join(real, ...tail.slice().reverse());
} catch (error) {
// Anything other than "not there yet" — a component that is a regular
// file, a permission problem — is a real error the caller must see.
if (!(error instanceof Deno.errors.NotFound)) throw error;
const parent = Path.dirname(current);
if (parent === current) return absolute; // reached the filesystem root
tail.push(Path.basename(current));
current = parent;
}
}
}
function isWithin(child: string, parent: string): boolean {
return child === parent || child.startsWith(`${parent}/`);
}
/**
* The rail that matters: never write a clone into the store a live server is
* serving, and never into the source's own directory.
*
* Explicit path refusal, not a "looks like production" heuristic — a guess that
* says yes when it should say no is worse than no check at all.
*/
function assertSafeTarget(
dir: string,
source: string,
forbiddenDirs: string[],
): void {
const sourceDir = source.replace(/\/[^/]*$/, "");
if (isWithin(dir, sourceDir) || isWithin(sourceDir, dir)) {
throw new Error(
`refusing to clone into the snapshot's own directory (${sourceDir}).`,
);
}
// Blank entries are dropped by the caller before canonicalization (a blank
// would resolve to the working directory and forbid everything under it), so
// there is no empty-string case to re-check here.
for (const raw of forbiddenDirs) {
const forbidden = raw.replace(/\/+$/, "");
if (isWithin(dir, forbidden) || isWithin(forbidden, dir)) {
throw new Error(
`refusing to write a clone into ${dir}: it overlaps the live store ` +
`directory ${forbidden}. Pick a target outside it.`,
);
}
}
}
/**
* Whether a path exists. An absent path is an ordinary answer; every other
* failure (a component that is not a directory, a permission problem) is a real
* error and propagates — treating those as "absent" would silently skip a
* reset or merge a clone into occupied ground.
*/
async function pathExists(path: string): Promise {
try {
await Deno.stat(path);
return true;
} catch (error) {
if (error instanceof Deno.errors.NotFound) return false;
throw error;
}
}
/** A clone directory must not silently merge into existing contents. */
async function assertEmptyOrAbsent(dir: string): Promise {
// Absent is the normal case — we are about to create it.
if (!(await pathExists(dir))) return;
for await (const entry of Deno.readDir(dir)) {
throw new Error(
`${dir} is not empty (found ${entry.name}); pick a fresh directory or ` +
`remove it first.`,
);
}
}