Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/core/src/database/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,32 @@ export type Migration = {
up: (tx: Transaction) => Effect.Effect<void, unknown>
}

// 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<string>) {
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) =>
Expand Down
33 changes: 25 additions & 8 deletions packages/core/src/database/sqlite.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -53,33 +54,49 @@ 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<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, 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<Record<string, unknown>>)
} 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" }),
}),
)
}
})

const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<unknown[]>, 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<unknown[]>)
} 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" }),
}),
)
}
Expand Down
31 changes: 24 additions & 7 deletions packages/core/src/database/sqlite.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -53,34 +54,50 @@ 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<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, 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<Record<string, unknown>>)
} 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" }),
}),
)
}
})

const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, 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<ReadonlyArray<unknown>>,
)
} 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" }),
}),
)
}
Expand Down
41 changes: 41 additions & 0 deletions packages/core/test/database-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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* () {
Expand Down
Loading