/** * The migration safety harness (T13448, saga T13339 / epic T13340). * * Every store move in the CleoDocs re-architecture (the T13360 migration, the * T13361/T13362 legacy retirements) wraps its steps in this harness so a * migration can never destroy or corrupt an existing project's store without * a verified recovery path. Each registered step executes inside a fixed * bracket: * * 1. **Pre-step backup** — a `VACUUM INTO` snapshot of the whole project * store via {@link planMigrationSnapshot} + {@link writeMigrationSnapshot} * (the same primitives the twin-collapse repairs use, T12535). The * snapshot is registered as a `migration` system backup * (`.cleo/backups/sqlite/cleo.db.` plus its `.meta.json` * sidecar), so `cleo backup list` shows it and the per-type rotation * applies. The sidecar is the step's **backup receipt**; its timestamp * feeds the destructive-step guard. Runs OUTSIDE the transaction * (`VACUUM INTO` requires it). * 2. **`BEGIN IMMEDIATE`** (T13609) — single-writer exclusion for the whole * bracket: a concurrent writer can neither interleave between the * fingerprints, the run and the verify (masking or forging count/hash * results) nor be silently discarded by a later restore. * 3. **Run** — the step's `run(ctx)` executes inside the transaction. * Table-rebuild recipes use `PRAGMA defer_foreign_keys = ON` (enforced * at COMMIT), never `PRAGMA foreign_keys = OFF` (a no-op in a * transaction). * 4. **Verify** — machine-readable checks: per-table row counts before/after * (matching, or explained by the step's declared `expectedDelta`), * content-hash spot checks (an order-independent multiset hash over the * table's rows, sampled to `sampleSize` rowids when the table is larger), * and `PRAGMA foreign_key_check` (must return zero rows). * 5. **Commit or roll back** (T13609) — only a fully verified step is * COMMITted; a refused, failed or throwing step is ROLLBACKed, so a * failed step is never visible in the live store. The restore path * remains the backstop for a commit that is later found bad. * 6. **Report** — a JSON-serializable {@link MigrationStepReport} per step * (including the `transaction` outcome); a failed verification **halts** * the runner and names the failed step. {@link rollbackMigrationStep} * consumes exactly the report's backup receipt and before-fingerprints. * * **Destructive guard** — a step declared `destructive: true` refuses to run * unless the harness options pass `force: true` AND the step's backup receipt * is younger than the step start (which the harness itself guarantees by * taking the snapshot first; a failed or skipped backup therefore refuses the * step by construction). * * **Rollback** — {@link rollbackMigrationStep} restores the step's snapshot * through {@link restoreStoreSnapshot} (the programmatic core of * `cleo restore backup`, T13240: verified copy, live-writer refusal, the * replaced store kept as a pinned `pre-restore-*` backup) and then re-proves * the restored store against the pre-step fingerprints (counts and content * hashes must match exactly). * * All store access here is read-only verification SQL plus the `VACUUM INTO` * snapshot primitive; steps own their own writes and route them through the * canonical accessors (gates 28/38). * * @module * @task T13448 */ import type { DatabaseSync } from 'node:sqlite'; /** * Context handed to a migration step's `run`. * * The `db` handle is the caller's live native handle (opened through the * store chokepoint by the caller); the harness never opens stores itself. */ export interface MigrationStepContext { /** Live native handle on the project store the migration operates on. */ readonly db: DatabaseSync; /** Absolute path of the project root whose store is being migrated. */ readonly projectRoot: string; /** Absolute path of the store file behind {@link db} (the backup target). */ readonly dbPath: string; /** * Step-scoped log sink (defaults to the module logger). * * Transaction contract (T13609): `run` executes INSIDE the harness's * `BEGIN IMMEDIATE … COMMIT` bracket. Never issue `BEGIN`/`COMMIT`/ * `ROLLBACK`, `VACUUM`, or checkpoint pragmas yourself — the harness owns * the transaction. Table-rebuild recipes must use * `PRAGMA defer_foreign_keys = ON` (enforced at COMMIT), not * `PRAGMA foreign_keys = OFF` (a no-op inside a transaction). DDL is * transactional: a failed verify rolls your changes back. */ readonly log: (message: string) => void; } /** * Per-step verification declaration. * * Everything is optional: the defaults verify EVERY user table with a zero * expected row-count delta, which is the strictest useful contract for a * carried-table migration. */ export interface MigrationStepVerificationSpec { /** * Tables to fingerprint before/after. Defaults to every user table * (`sqlite_*` internals excluded) present when the step starts. Tables the * step creates are reported as `addedTables`, not verified. */ readonly tables?: readonly string[]; /** * Expected row-count delta per table (`after === before + delta`). A table * with a non-zero declared delta is exempt from the content-hash spot * check (its sampled row set legitimately changes); declare its carried * subset in `tables` + `skipHash` when a hash check is still wanted. */ readonly expectedDelta?: Readonly>; /** * Content-hash sample size: tables with more rows are spot-checked on * `sampleSize` evenly-spaced rowids (sorted order) instead of a full scan. * Defaults to {@link DEFAULT_VERIFICATION_SAMPLE_SIZE}. */ readonly sampleSize?: number; /** Tables exempt from the content-hash spot check even with a zero delta. */ readonly skipHash?: readonly string[]; } /** * A single registered migration step. `name` must be unique within a run and * is what a halted runner reports. */ export interface MigrationStep { /** Unique kebab-case step name (surfaced in reports and the backup note). */ readonly name: string; /** Human-readable description (goes into the backup note). */ readonly description?: string; /** * Destructive steps (table drops, legacy retirement) refuse to run without * `force: true` in the harness options AND a backup receipt younger than * the step start. */ readonly destructive?: boolean; /** Verification declaration; defaults verify all tables with zero delta. */ readonly verify?: MigrationStepVerificationSpec; /** The migration body. Runs inside the backup → run → verify → report bracket. */ readonly run: (ctx: MigrationStepContext) => void | Promise; } /** * Order-independent content fingerprint of one table at one point in time. */ export interface TableFingerprint { /** Table name. */ readonly table: string; /** Row count at capture time. */ readonly count: number; /** * SHA-256 of the sorted multiset of per-row digests — identical for the * same row contents in any physical order (so a table REBUILD that carries * every row verbatim still matches). */ readonly hash: string; /** `true` when the hash covers every row; `false` when it is a sample. */ readonly full: boolean; /** Rowids hashed, when sampled (`full: false`); empty for full scans. */ readonly sampledRowids: readonly (number | string)[]; } /** * The backup receipt a step runs under: the migration snapshot registered as * a `migration` system backup, plus the sidecar timestamp the destructive * guard compares against the step start. */ export interface StepBackupReceipt { /** `-YYYYMMDD-HHmmss[-n]` system-backup id (`cleo backup list`). */ readonly backupId: string; /** Absolute path of the `VACUUM INTO` snapshot file. */ readonly snapshotPath: string; /** ISO-8601 timestamp read back from the `.meta.json` sidecar. */ readonly timestamp: string; } /** Per-table before/after verification result for one step. */ export interface TableVerification { /** Table name. */ readonly table: string; /** Pre-step fingerprint. */ readonly before: TableFingerprint; /** Post-step fingerprint (`null` when the table was removed). */ readonly after: TableFingerprint | null; /** The declared (or defaulted) expected row-count delta. */ readonly expectedDelta: number; /** Whether the post count equals pre count + expected delta. */ readonly countOk: boolean; /** * Whether the content hash is unchanged. `null` when skipped (non-zero * declared delta, or the table is in `skipHash`). */ readonly hashOk: boolean | null; } /** Outcome of a single step. */ export type MigrationStepStatus = 'completed' | 'failed' | 'refused'; /** * Machine-readable per-step report (JSON-serializable). On a failed step this * object IS the rollback input: `backup` names the snapshot and * `tables[].before` the fingerprints {@link rollbackMigrationStep} re-proves. */ export interface MigrationStepReport { /** Step name. */ readonly step: string; /** Whether the step declared itself destructive. */ readonly destructive: boolean; /** `completed`, `failed` (verification or run error), or `refused`. */ readonly status: MigrationStepStatus; /** ISO-8601 step start. */ readonly startedAt: string; /** Wall time of the whole bracket (backup + run + verify). */ readonly durationMs: number; /** The pre-step backup receipt (`null` only when the backup itself failed). */ readonly backup: StepBackupReceipt | null; /** Per-table before/after verification. */ readonly tables: readonly TableVerification[]; /** Tables created by the step (not covered by before/after verification). */ readonly addedTables: readonly string[]; /** Tables dropped by the step. */ readonly removedTables: readonly string[]; /** Rows returned by `PRAGMA foreign_key_check` after the step (0 = clean). */ readonly foreignKeyViolations: number; /** * Extension-backed tables excluded from verification because they could not * be read on this connection (e.g. `vec0` virtual tables without the * sqlite-vec extension loaded). These are content-addressed caches, never * the source of truth; a step that makes one of them MISSING fails * verification (its removal would otherwise be invisible), and every other * read error still fails the step (fail-closed). */ readonly unreadableTables: readonly UnreadableTable[]; /** * Outcome of the step's write transaction (T13609): `committed` when the * step verified and its writes are live; `rolled-back` when the step was * refused, failed verification, threw, or its COMMIT failed — the failed * step is then NEVER visible in the live store; `none` when no transaction * was opened (the pre-step backup or BEGIN itself failed). */ readonly transaction: 'committed' | 'rolled-back' | 'none'; /** Human-readable failure reasons (empty when `completed`). */ readonly failures: readonly string[]; /** Error thrown by `run` (or by the pre-step backup), when that failed. */ readonly runError: string | null; } /** A table excluded from verification because it could not be read. */ export interface UnreadableTable { /** Table name. */ readonly table: string; /** Why it was unreadable (e.g. `no such module: vec0`). */ readonly reason: string; } /** Overall runner outcome. */ export type MigrationRunStatus = 'completed' | 'halted' | 'refused'; /** The runner's machine-readable report. */ export interface MigrationRunReport { /** * `completed` when every step verified; `halted` when a step failed * (verification, run error, or backup failure); `refused` when a * destructive step was refused. Halted/refused runs name the step. */ readonly status: MigrationRunStatus; /** Reports for every step that was attempted (including the failed one). */ readonly steps: readonly MigrationStepReport[]; /** The step that halted/refused the run (`null` when completed). */ readonly haltedStep: string | null; /** The first failure reason (`null` when completed). */ readonly failure: string | null; } /** Options accepted by {@link runMigrationSteps}. */ export interface MigrationHarnessOptions { /** Live native handle on the project store (caller-opened, chokepoint). */ readonly db: DatabaseSync; /** Absolute path of the project root being migrated. */ readonly projectRoot: string; /** * Invocation directory (the worktree guard of the restore path, T12680). * Pass the real cwd; tests pass the fixture root. */ readonly cwd: string; /** * Path of the store file behind `db` (locates the backup directory). * Defaults to the canonical project store path for `projectRoot`. */ readonly dbPath?: string; /** * Required for destructive steps, together with a fresh backup receipt. * The CLI wrapper maps `--force` here. */ readonly force?: boolean; /** Default content-hash sample size (per-step `sampleSize` wins). */ readonly sampleSize?: number; /** Called with each step's report as it finishes (progress + persistence). */ readonly onStepReport?: (report: MigrationStepReport) => void; } /** Default number of rows content-hash spot-checked per oversized table. */ export declare const DEFAULT_VERIFICATION_SAMPLE_SIZE = 200; /** * Capture an order-independent content fingerprint of one table. * * Tables with at most `sampleSize` rows (and `WITHOUT ROWID` tables, which * have no rowid to sample) are hashed in full; larger rowid tables are * spot-checked on `sampleSize` evenly-spaced rowids. Reads only. * * @param db - Live native handle. * @param table - Table name. * @param sampleSize - Full-scan cutoff / sample size (default 200). * @returns The fingerprint. * @task T13448 */ export declare function fingerprintTable(db: DatabaseSync, table: string, sampleSize?: number): TableFingerprint; /** * Run `PRAGMA foreign_key_check` and return the violation count. Zero is the * only passing value; the pragma reports independently of the * `foreign_keys` switch, so a migration that toggles FK enforcement mid-step * is still checked. Read-only. * * @param db - Live native handle. * @returns Number of FK violations. * @task T13448 */ export declare function countForeignKeyViolations(db: DatabaseSync): number; /** * Run migration steps inside the backup → BEGIN IMMEDIATE → migrate → verify * → COMMIT/ROLLBACK → report bracket (T13448, T13609). Each step's writes are * atomic and write-exclusive: a refused, failed or throwing step is rolled * back and never becomes visible in the live store, and no concurrent writer * can interleave between the before-fingerprints, the run and the verify. * Never throws for verification failures, refusals, or step errors: those are * reported and halt the run (AC1, AC4). Throws only for invalid invocation * (no steps). * * @param steps - The registered steps, in execution order. * @param opts - The store handle, project, force flag and hooks. * @returns The machine-readable run report. On `halted`/`refused`, the last * step report carries the backup receipt and before-fingerprints needed by * {@link rollbackMigrationStep} (the backstop for a commit later found bad). * @task T13448 * @task T13609 — transactional bracket; failed steps are never visible */ export declare function runMigrationSteps(steps: readonly MigrationStep[], opts: MigrationHarnessOptions): Promise; /** Parameters of {@link rollbackMigrationStep}. */ export interface MigrationRollbackParams { /** Absolute path of the project root whose store is restored. */ readonly projectRoot: string; /** Invocation directory (worktree guard; tests pass the fixture root). */ readonly cwd: string; /** The halted/failed step's name (echoed into the report). */ readonly step: string; /** The step's backup receipt (`stepReport.backup`). */ readonly backup: StepBackupReceipt; /** The step's pre-step fingerprints (`stepReport.tables.map(t => t.before)`). */ readonly before: readonly TableFingerprint[]; /** From a git worktree: allow overwriting the owning project's live store. */ readonly confirmOwnerStore?: boolean; /** Sample size used for re-fingerprinting (must match the run's). */ readonly sampleSize?: number; } /** Per-table rollback verification result. */ export interface RollbackTableResult { /** Table name. */ readonly table: string; /** The pre-step fingerprint the restore must reproduce. */ readonly expected: TableFingerprint; /** The fingerprint of the restored store (`null` when unreadable/skipped). */ readonly actual: TableFingerprint | null; /** Whether the restored row count matches the pre-step count. */ readonly countOk: boolean; /** Whether the restored content hash matches the pre-step hash. */ readonly hashOk: boolean; /** * `null` when the table was re-verified; otherwise the reason it was * skipped (extension-backed table unreadable on the verifying connection, * e.g. `no such module: vec0`). Skipped tables cannot fail the rollback. */ readonly skipped: string | null; } /** * Rollback report: what was restored and whether the restored store provably * matches the pre-step snapshot (counts and content hashes). */ export interface MigrationRollbackReport { /** The step that was rolled back. */ readonly step: string; /** Whether {@link restoreStoreSnapshot} placed the snapshot. */ readonly restored: boolean; /** `true` when restored AND every table re-matches its pre-step fingerprint. */ readonly verified: boolean; /** The `cleo restore backup --id …` undo of THIS restore (T13240), when kept. */ readonly undo: string | null; /** The pinned `pre-restore-*` backup id of the replaced (migrated) store. */ readonly keptBackupId: string | null; /** Per-table verification against the pre-step fingerprints. */ readonly tables: readonly RollbackTableResult[]; /** Human-readable failures (empty when `verified`). */ readonly failures: readonly string[]; } /** * Roll back one migration step: restore its pre-step snapshot through the * programmatic core of `cleo restore backup` ({@link restoreStoreSnapshot} — * verified copy, quiescence/live-writer refusal, the replaced store kept as * a pinned `pre-restore-*` backup), then re-prove the restored store against * the step's pre-step fingerprints. This is the tested rollback path of AC3. * * Note: the restore closes every in-process handle on the store (T13240 * quiescence) — discard any native handle captured before calling this and * re-open through the chokepoint afterwards. * * @param params - The step, its backup receipt and its before-fingerprints. * @returns The rollback report; `verified` is the assertion surface. * @task T13448 */ export declare function rollbackMigrationStep(params: MigrationRollbackParams): Promise; //# sourceMappingURL=migration-safety.d.ts.map