diff --git a/packages/loopover-miner/lib/migrate-cli.js b/packages/loopover-miner/lib/migrate-cli.js index 9380d850c7..437c9dbdec 100644 --- a/packages/loopover-miner/lib/migrate-cli.js +++ b/packages/loopover-miner/lib/migrate-cli.js @@ -81,6 +81,18 @@ function migrateStore({ name, resolveDbPath, open }, env) { versionAfter, }; } catch (error) { + // applySchemaMigrations applies AND stamps each migration in its OWN transaction, so a failure part-way + // through a multi-migration sequence leaves the file at the LAST fully-applied version -- genuinely AHEAD + // of versionBefore. Re-read the real on-disk version instead of reporting a misleading "nothing changed". + // Guarded by its own try: the failure may itself be an unreadable/corrupt file (the same reason + // versionBefore can still be null here), in which case the pre-failure reading is all we can honestly + // report. + let versionAfter = versionBefore; + try { + versionAfter = peekSchemaVersion(dbPath); + } catch { + versionAfter = versionBefore; + } return { name, dbPath, @@ -88,7 +100,7 @@ function migrateStore({ name, resolveDbPath, open }, env) { status: "failed", detail: error instanceof Error ? error.message : String(error), versionBefore, - versionAfter: versionBefore, + versionAfter, }; } } diff --git a/test/unit/miner-migrate-cli.test.ts b/test/unit/miner-migrate-cli.test.ts index 0dfb069465..631caa8617 100644 --- a/test/unit/miner-migrate-cli.test.ts +++ b/test/unit/miner-migrate-cli.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { runMigrate, runMigrateChecks } from "../../packages/loopover-miner/lib/migrate-cli.js"; import { initPortfolioQueueStore, resolvePortfolioQueueDbPath } from "../../packages/loopover-miner/lib/portfolio-queue.js"; import { resolveEventLedgerDbPath } from "../../packages/loopover-miner/lib/event-ledger.js"; +import { applySchemaMigrations, BASELINE_SCHEMA_VERSION } from "../../packages/loopover-miner/lib/schema-version.js"; const roots: string[] = []; @@ -141,6 +142,59 @@ describe("loopover-miner migrate (#4871)", () => { ]); }); + it("REGRESSION: reports the REAL post-failure version when a migration fails part-way through a multi-migration sequence (#6767)", () => { + const env = tempEnv(); + const dbPath = join(dirname(resolvePortfolioQueueDbPath(env)), "partial-migration.sqlite3"); + mkdirSync(dirname(dbPath), { recursive: true }); + // Seed a real, openable file stamped at the baseline version -- this is what versionBefore reads. + const seed = new DatabaseSync(dbPath); + try { + applySchemaMigrations(seed, []); + } finally { + seed.close(); + } + + const results = runMigrateChecks(env, [ + { + name: "partial-migration", + resolveDbPath: () => dbPath, + open: () => { + const db = new DatabaseSync(dbPath); + try { + // applySchemaMigrations applies AND stamps each migration in its own transaction: the first one + // COMMITS (stamping BASELINE+1) and the second throws, so the file really is left at BASELINE+1. + applySchemaMigrations(db, [ + (migrationDb: DatabaseSync) => migrationDb.exec("CREATE TABLE first_ok (id INTEGER)"), + () => { + throw new Error("second migration boom"); + }, + ]); + } finally { + db.close(); + } + // Unreachable: applySchemaMigrations always throws for this fixture. Present only to satisfy the + // store contract's `open(dbPath) => { close() }` return type. + return { close: () => {} }; + }, + }, + ]); + + const result = results[0]; + expect(result).toMatchObject({ name: "partial-migration", ok: false, status: "failed" }); + expect(result?.versionBefore).toBe(BASELINE_SCHEMA_VERSION); + // The bug: the catch branch hardcoded `versionAfter: versionBefore`, reporting "nothing changed" even + // though the first migration had already committed to disk. + expect(result?.versionAfter).toBe(BASELINE_SCHEMA_VERSION + 1); + + // ...and the reported version is the one actually on disk, not an assumption. + const verifyDb = new DatabaseSync(dbPath, { readOnly: true }); + try { + expect(verifyDb.prepare("PRAGMA user_version").get()?.user_version).toBe(BASELINE_SCHEMA_VERSION + 1); + } finally { + verifyDb.close(); + } + }); + it("runMigrate prints human-readable text (exit 0) and machine JSON with --json, and exits 1 when a store fails", () => { const healthyEnv = tempEnv(); const log = vi.spyOn(console, "log").mockImplementation(() => {});