diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index c9a7dd02b9d..b89a81e3eb8 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -23,6 +23,7 @@ export function applyOnly(db: Database, input: Migration[]) { yield* db.run( sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, ) + const skipMigrations = !!process.env.OPENCODE_SKIP_MIGRATIONS let completed = new Set( (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), ) @@ -43,12 +44,14 @@ export function applyOnly(db: Database, input: Migration[]) { ) } } + if (!skipMigrations) yield* repairKnownSchemaDrift(db, input, completed) for (const migration of input) { if (completed.has(migration.id)) continue + if (skipMigrations) continue yield* db.transaction((tx) => Effect.gen(function* () { - if (!process.env.OPENCODE_SKIP_MIGRATIONS) yield* migration.up(tx) + yield* migration.up(tx) yield* tx.run( sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, ) @@ -57,3 +60,15 @@ export function applyOnly(db: Database, input: Migration[]) { } }) } + +function repairKnownSchemaDrift(db: Database, input: Migration[], completed: Set) { + return Effect.gen(function* () { + const sessionMetadata = input.find((migration) => migration.id === "20260511173437_session-metadata") + if (!sessionMetadata) return + if (!completed.has(sessionMetadata.id)) return + if (!(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"session"}`))) return + + // Older Drizzle skip-migration runs could record this id without applying its SQL. + yield* db.transaction((tx) => sessionMetadata.up(tx)) + }) +} diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 52c1e920ad4..85e9d8907a3 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -426,6 +426,88 @@ describe("DatabaseMigration", () => { ) }) + test("runs session metadata migration after importing older drizzle state", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) + VALUES ('hash', 1, '20260511000411_data_migration_state', ${new Date().toISOString()}) + `) + + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + + expect( + (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session)`)).map((column) => column.name), + ).toContain("metadata") + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ + { id: "20260511000411_data_migration_state" }, + { id: "20260511173437_session-metadata" }, + ]) + }), + ) + }) + + test("repairs imported session metadata migration state when the column is missing", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run( + sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, + ) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) + VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()}) + `) + + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + + expect( + (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session)`)).map((column) => column.name), + ).toContain("metadata") + expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ + { id: "20260511173437_session-metadata" }, + ]) + }), + ) + }) + + test("does not record migrations as complete when migrations are skipped", async () => { + const previous = process.env.OPENCODE_SKIP_MIGRATIONS + try { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + + process.env.OPENCODE_SKIP_MIGRATIONS = "true" + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + + expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([]) + expect( + (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session)`)).map((column) => column.name), + ).not.toContain("metadata") + + delete process.env.OPENCODE_SKIP_MIGRATIONS + yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) + + expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }]) + expect( + (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session)`)).map((column) => column.name), + ).toContain("metadata") + }), + ) + } finally { + if (previous === undefined) delete process.env.OPENCODE_SKIP_MIGRATIONS + else process.env.OPENCODE_SKIP_MIGRATIONS = previous + } + }) + test("does not replay a migrated session metadata column", async () => { await run( Effect.gen(function* () {