diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 90dee8acbf3b..c51074bb2968 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -15,12 +15,32 @@ export type Migration = { up: (tx: Transaction) => Effect.Effect } +// Migration ids sorting past our newest known migration were applied by a +// newer build sharing this database. Unknown ids sorting before it are +// retired ids (e.g. 20260530232709_lovely_romulus) and stay allowed. +export function newer(ids: Iterable) { + const known = new Set(migrations.map((migration) => migration.id)) + const latest = migrations.reduce((max, migration) => (migration.id > max ? migration.id : max), "") + return Array.from(ids) + .filter((id) => !known.has(id) && id > latest) + .sort() +} + export function apply(db: Database) { return lock.withPermit( Effect.gen(function* () { 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")) { + const unknown = newer( + (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), + ) + if (unknown.length > 0) + yield* Effect.logWarning( + `database contains migrations from a newer version of opencode (${unknown[unknown.length - 1]}); queries may fail until this installation is updated`, + ) + } 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/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index e15f4c117e46..7142956a5952 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -13,6 +13,7 @@ import * as Client from "effect/unstable/sql/SqlClient" import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" +import { DatabaseMigration } from "./migration" import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -53,17 +54,33 @@ const make = (options: Config) => ? Statement.defaultTransforms(options.transformResultNames).array : undefined + // SQLITE_ERROR (errno 1) usually means the statement no longer matches + // the schema. When the migration table shows ids from a newer build, + // include that in the message so the failure explains itself instead of + // surfacing as an opaque "SQL logic error". + const message = (cause: unknown) => { + const base = "Failed to execute statement" + if ((cause as { errno?: number })?.errno !== 1) return base + try { + const rows = native.query("SELECT id FROM migration").all() as Array<{ id: string }> + const unknown = DatabaseMigration.newer(rows.map((row) => row.id)) + if (unknown.length > 0) + return `${base}: database was migrated by a newer version of opencode (${unknown[unknown.length - 1]}); update this installation` + } catch {} + return base + } + const run = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { - const statement = native.query(query) - // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 - statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) try { + const statement = native.query(query) + // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 + statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) return Effect.succeed((statement.all(...(params as any)) ?? []) as Array>) } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: message(cause), operation: "execute" }), }), ) } @@ -71,15 +88,15 @@ const make = (options: Config) => const runValues = (query: string, params: ReadonlyArray = []) => Effect.withFiber, SqlError>((fiber) => { - const statement = native.query(query) - // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 - statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) try { + const statement = native.query(query) + // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 + statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) return Effect.succeed((statement.values(...(params as any)) ?? []) as Array) } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: message(cause), operation: "execute" }), }), ) } diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 6eaecbee26ea..ff645ba30140 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -13,6 +13,7 @@ import * as Client from "effect/unstable/sql/SqlClient" import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" +import { DatabaseMigration } from "./migration" import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -53,16 +54,32 @@ const make = (options: Config) => ? Statement.defaultTransforms(options.transformResultNames).array : undefined + // SQLITE_ERROR (errcode 1) usually means the statement no longer matches + // the schema. When the migration table shows ids from a newer build, + // include that in the message so the failure explains itself instead of + // surfacing as an opaque "SQL logic error". + const message = (cause: unknown) => { + const base = "Failed to execute statement" + if ((cause as { errcode?: number })?.errcode !== 1) return base + try { + const rows = native.prepare("SELECT id FROM migration").all() as Array<{ id: string }> + const unknown = DatabaseMigration.newer(rows.map((row) => row.id)) + if (unknown.length > 0) + return `${base}: database was migrated by a newer version of opencode (${unknown[unknown.length - 1]}); update this installation` + } catch {} + return base + } + const run = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { - const statement = native.prepare(query) - statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) try { + const statement = native.prepare(query) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: message(cause), operation: "execute" }), }), ) } @@ -70,17 +87,17 @@ const make = (options: Config) => const runValues = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { - const statement = native.prepare(query) - statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) - statement.setReturnArrays(true) try { + const statement = native.prepare(query) + statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) + statement.setReturnArrays(true) return Effect.succeed( statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, ) } catch (cause) { return Effect.fail( new SqlError({ - reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }), + reason: classifySqliteError(cause, { message: message(cause), operation: "execute" }), }), ) } diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index b381cc7418a3..5ccf142fb1fb 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -8,6 +8,7 @@ import { Effect, Layer } from "effect" import { eq, inArray, sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import { migrations } from "@opencode-ai/core/database/migration.gen" +import { layer as coreSqlite } from "@opencode-ai/core/database/sqlite.bun" import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order" @@ -111,6 +112,46 @@ describe("DatabaseMigration", () => { ).rejects.toThrow("Database is not empty and has no session table") }) + test("still starts against a database migrated by a newer version", 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 ('99991231000000_from_the_future', 1)`) + yield* DatabaseMigration.apply(db) + }), + ) + }) + + test("newer() flags only unknown ids past the newest known migration", () => { + expect(DatabaseMigration.newer(["20260530232709_lovely_romulus"])).toEqual([]) + expect(DatabaseMigration.newer([migrations[0]!.id])).toEqual([]) + expect(DatabaseMigration.newer(["99991231000000_from_the_future"])).toEqual(["99991231000000_from_the_future"]) + }) + + test("explains schema errors caused by a database migrated by a newer version", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "newer.sqlite") + await Effect.runPromise( + 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)`) + // Reference a column this build expects but the "newer" schema no + // longer has — the same prepare-time failure a renamed or dropped + // column produces. + const exit = yield* Effect.exit(db.all(sql`SELECT column_dropped_by_newer_build FROM session`)) + expect(exit._tag).toBe("Failure") + const reason = ((exit as any).cause?.reasons ?? [])[0] + // Typed failure, not a defect: prepare errors used to escape as Die. + expect(reason?._tag).toBe("Fail") + const rendered = String(reason?.error) + JSON.stringify(reason?.error) + expect(rendered).toContain("newer version of opencode") + expect(rendered).toContain("99991231000000_from_the_future") + }).pipe(Effect.provide(coreSqlite({ filename })), Effect.scoped), + ) + }) + test("backfills existing Context Epoch rows to the build agent", async () => { await run( Effect.gen(function* () {