diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index a6600f80c0a..a9104a0f2a4 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -66,12 +66,39 @@ 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 named = (yield* db.all<{ name: string }>( + sql`SELECT name FROM pragma_table_info('__drizzle_migrations')`, + )).some((column) => column.name === "name") + + if (named) { + 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 + `) + } + + if (!named) { + const entries = yield* db.all<{ created_at: number; prefix: string | null }>(sql` + SELECT created_at, strftime('%Y%m%d%H%M%S', created_at / 1000, 'unixepoch') AS prefix + FROM ${sql.identifier("__drizzle_migrations")} + WHERE created_at IS NOT NULL + `) + + for (const entry of entries) { + const migration = input.find((item) => item.id.startsWith(`${entry.prefix}_`)) + if (!migration) { + return yield* Effect.die( + new Error(`Legacy migration timestamp ${entry.created_at} does not match any known migration`), + ) + } + yield* db.run(sql` + INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed) + VALUES (${migration.id}, ${Date.now()}) + `) + } + } completed = new Set( (yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id), ) diff --git a/packages/core/src/database/migration/20260410174513_workspace-name.ts b/packages/core/src/database/migration/20260410174513_workspace-name.ts index c39a3612e31..5081f2df2ef 100644 --- a/packages/core/src/database/migration/20260410174513_workspace-name.ts +++ b/packages/core/src/database/migration/20260410174513_workspace-name.ts @@ -5,6 +5,9 @@ const migration: DatabaseMigration.Migration = { id: "20260410174513_workspace-name", up(tx) { return Effect.gen(function* () { + const columns = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`workspace\`)`) + const name = columns.some((column) => column.name === "name") ? "`name`" : "''" + yield* tx.run(`PRAGMA foreign_keys=OFF;`) yield* tx.run(` CREATE TABLE \`__new_workspace\` ( @@ -19,7 +22,7 @@ const migration: DatabaseMigration.Migration = { ); `) yield* tx.run( - `INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`, + `INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, ${name}, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`, ) yield* tx.run(`DROP TABLE \`workspace\`;`) yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index f10c146eaeb..e8e9378db2f 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 { sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import { migrations } from "@opencode-ai/core/database/migration.gen" +import workspaceNameMigration from "@opencode-ai/core/database/migration/20260410174513_workspace-name" import { Database } from "@opencode-ai/core/database/database" import { tmpdir } from "./fixture/tmpdir" import type { SqlClient } from "effect/unstable/sql/SqlClient" @@ -35,6 +36,68 @@ const run = ( const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { + test("defaults missing workspace names while preserving legacy workspace data", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql` + CREATE TABLE workspace ( + id text PRIMARY KEY, + type text NOT NULL, + branch text, + directory text, + extra text, + project_id text NOT NULL + ) + `) + yield* db.run(sql` + INSERT INTO workspace (id, type, branch, directory, extra, project_id) + VALUES ('wrk_legacy', 'remote', 'main', '/repo', '{}', 'proj_legacy') + `) + + yield* DatabaseMigration.applyOnly(db, [workspaceNameMigration]) + + expect(yield* db.get(sql`SELECT id, name, branch, directory, extra FROM workspace`)).toEqual({ + id: "wrk_legacy", + name: "", + branch: "main", + directory: "/repo", + extra: "{}", + }) + }), + ) + }) + + test("imports unnamed legacy Drizzle journal entries by their actual migration timestamps", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE __drizzle_migrations (id integer PRIMARY KEY, hash text, created_at integer)`) + yield* db.run(sql` + INSERT INTO __drizzle_migrations (hash, created_at) + VALUES ('', ${Date.UTC(2026, 3, 10, 17, 45, 13)}) + `) + + yield* DatabaseMigration.applyOnly(db, [workspaceNameMigration]) + + expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260410174513_workspace-name" }]) + }), + ) + }) + + test("rejects unknown legacy Drizzle journal timestamps instead of guessing completed migrations", async () => { + await expect( + run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE __drizzle_migrations (id integer PRIMARY KEY, hash text, created_at integer)`) + yield* db.run(sql`INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('', 1234567890000)`) + yield* DatabaseMigration.applyOnly(db, [workspaceNameMigration]) + }), + ), + ).rejects.toThrow("does not match any known migration") + }) + test("serializes concurrent embedded initialization for one database path", async () => { await using tmp = await tmpdir() const filename = path.join(tmp.path, "embedded.sqlite")