diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 90dee8acbf3b..0b259ee527eb 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -21,6 +21,23 @@ export function apply(db: Database) { const tables = yield* db.all<{ name: string }>( sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, ) + if (tables.some((table) => table.name === "migration")) { + // A newer build may have migrated this database already. Running old + // SQL against a newer schema fails at query time with an opaque + // "SQL logic error", so refuse upfront. Unknown ids sorting before our + // newest migration are retired ids (e.g. 20260530232709_lovely_romulus) + // and stay allowed. + const known = new Set(migrations.map((migration) => migration.id)) + const latest = migrations.reduce((max, migration) => (migration.id > max ? migration.id : max), "") + const newer = (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)) + .map((row) => row.id) + .filter((id) => !known.has(id) && id > latest) + .sort() + if (newer.length > 0) + return yield* Effect.die( + `Database was migrated by a newer version of opencode (unknown migration: ${newer[newer.length - 1]}). Refusing to start against a newer schema; please update this installation.`, + ) + } if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations) if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table") yield* db.transaction((tx) => diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index b381cc7418a3..6c6ecbafde10 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -111,6 +111,30 @@ describe("DatabaseMigration", () => { ).rejects.toThrow("Database is not empty and has no session table") }) + test("refuses a database migrated by a newer version", async () => { + await expect( + run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('99991231000000_from_the_future', 1)`) + yield* DatabaseMigration.apply(db) + }), + ), + ).rejects.toThrow("newer version of opencode") + }) + + test("allows unknown migration ids older than the newest known migration", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) + yield* DatabaseMigration.apply(db) + }), + ) + }) + test("backfills existing Context Epoch rows to the build agent", async () => { await run( Effect.gen(function* () {