From 4a6f5635f9cd887fe159b351993dcd9037f008ec Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:43:37 +0000 Subject: [PATCH] fix(core): adopt consolidated migration aliases The V1-to-V2 consolidation replaced a shipped migration chain with a new ID, causing upgraded databases to replay already-applied schema changes. Treat the terminal pre-consolidation migration as an alias and record the canonical migration without rerunning its SQL. --- packages/core/src/database/migration.ts | 9 ++++++++ .../20260804233008_loose_psylocke.ts | 1 + packages/core/test/database-migration.test.ts | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 3c412f3fc4..c53ac9c2fc 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -13,6 +13,7 @@ const lock = Semaphore.makeUnsafe(1) export type Migration = { id: string + aliases?: string[] foreignKeys?: boolean up: (tx: Transaction) => Effect.Effect } @@ -77,6 +78,14 @@ export function applyOnly(db: Database, input: Migration[]) { for (const migration of input) { if (completed.has(migration.id)) continue + const alias = migration.aliases?.find((id) => completed.has(id)) + if (alias) { + yield* db.run( + sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, + ) + yield* Effect.logInfo("database migration adopted", { migration: migration.id, alias }) + continue + } const started = Date.now() yield* Effect.logInfo("database migration started", { migration: migration.id }) const apply = db.transaction((tx) => diff --git a/packages/core/src/database/migration/20260804233008_loose_psylocke.ts b/packages/core/src/database/migration/20260804233008_loose_psylocke.ts index e1c423fb45..a3fba9f932 100644 --- a/packages/core/src/database/migration/20260804233008_loose_psylocke.ts +++ b/packages/core/src/database/migration/20260804233008_loose_psylocke.ts @@ -3,6 +3,7 @@ import type { DatabaseMigration } from "../migration" const migration: DatabaseMigration.Migration = { id: "20260804233008_loose_psylocke", + aliases: ["20260730195856_optional_session_title"], up(tx) { return Effect.gen(function* () { yield* tx.run(` diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index f23cf0e8bb..17790ed071 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database" import { tmpdir } from "./fixture/tmpdir" import type { SqlClient } from "effect/unstable/sql/SqlClient" import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" +import consolidatedV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke" import { Global } from "@opencode-ai/util/global" const run = ( @@ -114,6 +115,28 @@ describe("DatabaseMigration", () => { ) }) + test("adopts the consolidated V2 migration after the superseded chain", 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 event (id text PRIMARY KEY, created integer DEFAULT 0 NOT NULL)`) + yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`) + yield* db.run(sql` + INSERT INTO migration (id, time_completed) + VALUES ('20260730195856_optional_session_title', 1) + `) + + yield* DatabaseMigration.applyOnly(db, [consolidatedV2Migration]) + + expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${consolidatedV2Migration.id}`)).toEqual({ + id: consolidatedV2Migration.id, + }) + expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`)).toBeUndefined() + }), + ) + }) + test("imports legacy JSON credentials without changing the source file or existing credentials", async () => { await using tmp = await tmpdir() const source = path.join(tmp.path, "auth.json")