/** * Exodus-on-open — lazy, idempotent, parity-gated auto-migration of the legacy * multi-DB fleet into the consolidated dual-scope `cleo.db` on first open. * * ## Why this exists (data-continuity safety net · T11553) * * E6 routes every `getDb`/`getBrainDb`/etc read at the consolidated `cleo.db`. * On an *existing* install the consolidated DB is freshly migrated but **empty** * (0 base-table rows), while the user's real data still lives in the legacy * `tasks.db` / `brain.db` / `conduit.db` / `signaldock.db` fleet (e.g. 4465 * tasks). Without an auto-migration the E8/T11251 cutover would make every * user's data **invisible**. This module wires the existing `runExodusMigrate` * engine to run **once, automatically, on first open** — gated by the * `verifyMigration` (T11551) parity check so a partial or lossy migration NEVER * becomes the live source of truth. * * ## Trigger condition (AC1) * * On first `openDualScopeDb(scope)` the hook runs iff **both**: * 1. the consolidated `cleo.db` for that scope is EMPTY (the canonical first * base table has zero rows), AND * 2. at least one legacy source DB for that scope has rows. * * ## Idempotency (AC1) * * After a successful migration the consolidated DB is non-empty, so the * emptiness check short-circuits on every subsequent open — a second open is a * no-op. The check is also re-evaluated *inside* the single-flight lock * (double-checked locking) so the process that loses a concurrency race never * re-migrates. * * ## Parity gate + clean abort (AC2) * * After the copy, `verifyMigration` (T11551) compares row counts + canonical * digests + FK integrity + enum drift legacy↔consolidated. The data-continuity * gate is **row-count parity + zero migration-INTRODUCED FK orphans** (hash/enum * drift are the expected normalisation diagnostics, and pre-existing SOURCE FK * orphans are tolerated as zero-loss — see {@link isDataContinuityOk}). If a * genuine deficit/introduced-orphan is detected the hook **aborts the cutover**: * it rolls the half-migrated consolidated tables back to EMPTY (`DELETE FROM` * every user table — see {@link rollbackConsolidatedToEmpty}) so the legacy DBs * remain the source of truth, no half-migrated `cleo.db` is exposed, and there * are no silent `INSERT OR IGNORE` row drops. The file is never unlinked; the * chokepoint re-opens a fresh handle afterwards (the migrate engine closes the * handles it opened, so the rollback re-opens the scope before truncating it). * * ## Retry correctness — journal invalidation on abort (T11572) * * `runExodusMigrate` is resumable: it journals each table `done` and SKIPS * already-`done` tables on a re-run. On abort we truncate the consolidated rows * back to empty, so a stale `done` journal would make the next open re-trigger * (target empty), copy NOTHING (journal says done), re-verify the still-empty * target, and re-abort — a permanent loop. The abort path therefore also calls * `clearExodusJournal(plan.stagingDir)` so a post-abort retry RE-COPIES from * scratch. * * ## Concurrency safety (AC6 · reconcile with T11554 / R13-T11278) * * Two processes opening a brand-new empty `cleo.db` simultaneously must not both * migrate. A `proper-lockfile` single-flight lock on * `.exodus-on-open.lock` serialises the attempt; the loser re-checks * emptiness under the lock and bails. This is the same first-run-race concern * T11554 raises for schema bootstrap — both are solved by serialising the * first-open mutation. * * @module * @task T11553 (E6 · exodus-on-open) * @epic T11249 (E6) * @saga T11242 (SG-DB-SUBSTRATE-V2) * @see packages/core/src/store/exodus/migrate.ts — runExodusMigrate engine * @see packages/core/src/store/exodus/verify-migration.ts — verifyMigration parity gate (T11551) * @see packages/core/src/store/dual-scope-db.ts — the open chokepoint that calls this */ import type { DatabaseSync } from 'node:sqlite'; import type { VerifyMigrationResult } from '@cleocode/contracts'; import type { DualScope } from '../dual-scope-db.js'; /** * Decide whether a {@link verifyMigration} result clears the **data-continuity** * gate — the campaign-aligned zero-loss invariant (DHQ-045 · T11551). * * `verifyMigration().ok` is intentionally STRICTER than data-continuity: it also * requires `hashMatch` and zero enum-drift findings. After a CORRECT migration * those two will legitimately be `false`, because the migration NORMALISES the * data (legacy enum aliases like `'ACCEPTED'`→`'accepted'`, epoch→ISO * timestamps) — so the consolidated content digest differs from the un-normalised * source, and the source still reports its raw drift as a diagnostic. Treating * that as a parity failure would abort EVERY real migration. * * The zero-loss invariant the exodus campaign actually proves (and the one the * representative real-data parity test asserts) is: * * 1. every data-bearing base table copied with NO ROW DEFICIT * (`targetCount >= sourceCount` — you cannot LOSE a row you have MORE of), * AND * 2. NO referential orphans the migration INTRODUCED on the consolidated * target (`introducedForeignKeyViolations` empty). * * ## A row SURPLUS is NOT data loss (T11577) * * Data loss means rows are MISSING: `targetCount < sourceCount` (a DEFICIT). * A SURPLUS (`targetCount > sourceCount`) cannot be loss — every source row is * still present, plus extra. The canonical benign surplus is the migration's * OWN audit trail: `runExodusMigrate` opens the nexus registry, whose * `writeNexusAudit` (`nexus/registry.ts`) appends rows to `nexus_audit_log` * DURING the migrating open, so the consolidated `nexus_audit_log` legitimately * has a few MORE rows than the legacy source (e.g. 161923 → 161926). Gating on * exact `countMatch` (`source === target`) wrongly aborts the cutover on that * append. The gate therefore fails ONLY on a genuine DEFICIT; a surplus is * tolerated and logged as a WARN (with the table + delta) so it stays visible * and a double-copy on a non-append table could still be spotted by an operator. * Deficits are NEVER tolerated — that is the real data-loss class. * * ## Pre-existing source orphans are tolerated (T11572) * * A legacy source DB can already contain referential orphans (e.g. a * `tasks_task_relations` row pointing at a task that was deleted long before the * migration). Those rows copy through faithfully — that is ZERO loss, not a * migration defect — so they appear on BOTH sides and `verifyMigration` * classifies them as `preExistingForeignKeyViolations`. Gating on the *total* * orphan set (`foreignKeyViolations`) would permanently abort every real cutover * over a defect the data already had. The gate therefore fails ONLY on * `introducedForeignKeyViolations` — orphans present on the target that the * source did not have (i.e. the migration dropped a parent row). Pre-existing * orphans are logged as a WARN for a data-hygiene follow-up. * * Hash mismatch + source enum-drift are surfaced as WARN diagnostics (a true * content corruption would normally also show up as an introduced FK orphan or a * count deficit), but they do NOT, on their own, indicate data loss. * * @param result - The {@link VerifyMigrationResult} from `verifyMigration`. * @returns `true` when NO base table has a row DEFICIT (`targetCount < * sourceCount`) and the migration introduced no new FK orphans — i.e. the * cutover is safe. A surplus (`targetCount > sourceCount`) is tolerated. * * @task T11577 (deficit-only gate — tolerate benign migration-time surplus) */ export declare function isDataContinuityOk(result: VerifyMigrationResult): boolean; /** * Outcome of an exodus-on-open attempt, surfaced for tests + logging. */ export interface ExodusOnOpenResult { /** `'skipped'` — no trigger; `'migrated'` — parity-verified cutover; `'aborted'` — parity failed, legacy kept. */ readonly outcome: 'skipped' | 'migrated' | 'aborted'; /** Human-readable reason (skip cause, row counts, or abort error). */ readonly reason: string; /** Total rows copied (only meaningful for `'migrated'`). */ readonly rowsCopied?: number; } /** * Lazily migrate the legacy fleet into the consolidated `cleo.db` on first open. * * This is the thin guard wired into {@link openDualScopeDb}. It is invoked AFTER * the consolidated schema migrations have run (so the base tables exist) but * BEFORE the handle is returned to the caller. It is a no-op unless the trigger * condition (AC1) holds. * * Re-entrancy, concurrency, parity gating, and clean abort are all handled here * — see the module docs. The heavy lifting (copy, journal, backup, attach-leak * safety) is delegated to the existing {@link runExodusMigrate} engine; this * function adds only the *when* (lazy trigger) and the *safety envelope* * (single-flight + verify-or-rollback). * * On a parity-failure abort the migration's writes are rolled back IN PLACE on * the caller's live handle (see {@link rollbackConsolidatedToEmpty}) — the * handle is never closed and the file is never deleted, so the chokepoint caller * (`getBrainDb`/`ensureConduitDb`/…) keeps a valid, empty `cleo.db` and the * legacy DBs remain the source of truth. * * @param scope - The scope being opened (`'project'` | `'global'`). * @param dbPath - Absolute path to the consolidated `cleo.db` for `scope`. * @param nativeDb - The freshly-opened native handle (post-migration). This is * the SAME cached handle `runExodusMigrate` writes through, * so the rollback truncates it in place rather than deleting. * @param cwd - Working directory used to resolve the project root. * @returns The {@link ExodusOnOpenResult} describing what happened. * * @task T11553 (AC1, AC2, AC6) * @epic T11249 (E6) * @saga T11242 */ export declare function maybeRunExodusOnOpen(scope: DualScope, dbPath: string, nativeDb: DatabaseSync, cwd: string | undefined): Promise; /** * Test-only accessor: whether an exodus migration is currently in flight. Used * to assert the re-entrancy guard does not recurse. * * @internal */ export declare function _isExodusInProgress(): boolean; //# sourceMappingURL=on-open.d.ts.map