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
67 changes: 61 additions & 6 deletions packages/core/src/database/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ export function apply(db: Database) {
)
}

// Drizzle-kit derives a migration's filename prefix from the same timestamp
// it stores as `created_at` in the journal, so the prefix can be recovered
// from journals that predate the `name` column.
function timestampPrefix(millis: number) {
const date = new Date(millis)
const pad = (value: number) => value.toString().padStart(2, "0")
return [
date.getUTCFullYear(),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes()),
pad(date.getUTCSeconds()),
].join("")
}

export function applyOnly(db: Database, input: Migration[]) {
return Effect.gen(function* () {
yield* db.run(
Expand All @@ -54,12 +70,51 @@ export function applyOnly(db: Database, input: Migration[]) {
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
const columns = yield* db.all<{ name: string }>(
sql`SELECT name FROM pragma_table_info(${"__drizzle_migrations"})`,
)
if (columns.some((column) => column.name === "name")) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
} else {
// Journals written by drizzle-orm's stock migrator (before the
// `name` column existed) only carry `created_at`. Map it back to
// the migration id's timestamp prefix, mirroring the v0 -> v1
// journal upgrade in effect-drizzle-sqlite, instead of crashing
// with "no such column: name".
const ids = new Map<string, string>()
for (const migration of input) {
const prefix = migration.id.split("_")[0]
if (prefix) ids.set(prefix, migration.id)
}
const rows = yield* db.all<{ created_at: number | string }>(
sql`SELECT created_at FROM ${sql.identifier("__drizzle_migrations")} WHERE created_at IS NOT NULL`,
)
const matched: string[] = []
const unmatched: (number | string)[] = []
for (const row of rows) {
const stringified = String(row.created_at)
const millis = Number(stringified.substring(0, stringified.length - 3) + "000")
const id = Number.isFinite(millis) ? ids.get(timestampPrefix(millis)) : undefined
if (id) matched.push(id)
else unmatched.push(row.created_at)
}
if (unmatched.length > 0) {
yield* Effect.die(
`Found ${unmatched.length} drizzle journal entries (created_at: ${unmatched.join(", ")}) that do not match any known migration. The database was likely created by a different version of opencode.`,
)
} else {
yield* Effect.forEach(matched, (id) =>
db.run(
sql`INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${id}, ${Date.now()})`,
),
)
}
}
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/database-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,40 @@ describe("DatabaseMigration", () => {
)
})

test("imports legacy drizzle journals without a name column", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
yield* db.run(
sql`CREATE TABLE __drizzle_migrations (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)`,
)
yield* db.run(
sql`INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('', ${Date.UTC(2026, 4, 11, 17, 34, 37)})`,
)

yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])

expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
}),
)
})

test("fails with a clear error for unmatched legacy drizzle journal entries", async () => {
await expect(
run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE __drizzle_migrations (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)`,
)
yield* db.run(sql`INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('', 1234567890000)`)
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
}),
),
).rejects.toThrow("do not match any known migration")
})

test("does not replay a migrated session metadata column", async () => {
await run(
Effect.gen(function* () {
Expand Down
Loading