diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index a247e95b29..fce759e8b9 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -42,5 +42,6 @@ export const migrations = ( import("./migration/20260622202450_simplify_session_input"), import("./migration/20260804233008_loose_psylocke"), import("./migration/20260805200742_import_legacy_credentials"), + import("./migration/20260806200000_import_next_credentials"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260806200000_import_next_credentials.ts b/packages/core/src/database/migration/20260806200000_import_next_credentials.ts new file mode 100644 index 0000000000..1e83941765 --- /dev/null +++ b/packages/core/src/database/migration/20260806200000_import_next_credentials.ts @@ -0,0 +1,89 @@ +import path from "node:path" +import { existsSync } from "node:fs" +import { sql } from "drizzle-orm" +import { Effect, Option, Schema } from "effect" +import { Credential } from "@opencode-ai/schema/credential" +import { Global } from "@opencode-ai/util/global" +import type { DatabaseMigration } from "../migration" + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const decodeValue = Schema.decodeUnknownOption(Credential.Value) + +export default { + id: "20260806200000_import_next_credentials", + up(tx) { + return importNextCredentials(tx, path.join(Global.Path.data, "opencode-next.db")) + }, +} satisfies DatabaseMigration.Migration + +/** + * The next channel stored credentials in its own `opencode-next.db` before the + * channel databases were consolidated into `opencode.db`. The legacy import + * only reads V1 `auth.json`, so a credential that existed only in the previous + * channel database was silently dropped. Copy those rows over, keeping any + * credential the target database already has for the same integration. + */ +export function importNextCredentials(tx: Parameters[0], sourcePath: string) { + return Effect.gen(function* () { + if (!existsSync(sourcePath)) return + for (const row of yield* readSourceCredentials(sourcePath)) { + const integrationID = typeof row.integration_id === "string" && row.integration_id.length ? row.integration_id : undefined + if (!integrationID) continue + if (typeof row.value !== "string") continue + const json = Option.getOrUndefined(decodeJson(row.value)) + if (json === undefined || Option.isNone(decodeValue(json))) continue + if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue + const now = Date.now() + yield* tx.run(sql` + INSERT OR IGNORE INTO credential ( + id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated + ) VALUES ( + ${typeof row.id === "string" && row.id.length ? row.id : Credential.ID.create()}, + ${integrationID}, + ${typeof row.label === "string" && row.label.length ? row.label : "default"}, + ${row.value}, + ${typeof row.connector_id === "string" ? row.connector_id : null}, + ${typeof row.method_id === "string" ? row.method_id : null}, + ${typeof row.active === "number" ? row.active : null}, + ${typeof row.time_created === "number" ? row.time_created : now}, + ${typeof row.time_updated === "number" ? row.time_updated : now} + ) + `) + } + }) +} + +type SourceRow = Record + +// An unreadable or incompatible source database skips the import instead of +// failing the migration and blocking startup; the source is never modified. +function readSourceCredentials(sourcePath: string) { + return Effect.scoped( + Effect.gen(function* () { + const sqlite = yield* Effect.promise(() => import("bun:sqlite")) + const source = yield* Effect.acquireRelease( + Effect.try({ + try: () => new sqlite.Database(sourcePath, { readonly: true, strict: true }), + catch: (error) => error, + }), + (database) => Effect.sync(() => database.close()), + ) + return yield* Effect.try({ + try: () => { + const table = source + .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'") + .get() + if (!table) return [] as SourceRow[] + return source.query("SELECT * FROM credential").all() + }, + catch: (error) => error, + }) + }), + ).pipe( + Effect.catch((error) => + Effect.logWarning("Skipped incompatible opencode-next.db credentials", { path: sourcePath, error }).pipe( + Effect.as([] as SourceRow[]), + ), + ), + ) +} diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 5a58432d22..3746982cf8 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 { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" +import { importNextCredentials } from "@opencode-ai/core/database/migration/20260806200000_import_next_credentials" const run = (effect: Effect.Effect) => Effect.runPromise( @@ -164,6 +165,75 @@ describe("DatabaseMigration", () => { expect(await Bun.file(source).text()).toBe(content) }) + test("imports previous channel database credentials without replacing existing integrations", async () => { + await using tmp = await tmpdir() + const source = path.join(tmp.path, "opencode-next.db") + const { Database: Sqlite } = await import("bun:sqlite") + const sourceDb = new Sqlite(source, { strict: true }) + sourceDb.run(` + CREATE TABLE credential ( + id text PRIMARY KEY, integration_id text, label text NOT NULL, value text NOT NULL, + connector_id text, method_id text, active integer, time_created integer NOT NULL, time_updated integer NOT NULL + ) + `) + const insert = sourceDb.prepare( + "INSERT INTO credential (id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + insert.run("cred_next_opencode", "opencode", "anomaly", JSON.stringify({ type: "key", key: "zen-key" }), null, "console", null, 100, 200) + insert.run( + "cred_next_anthropic", + "anthropic", + "default", + JSON.stringify({ type: "oauth", methodID: "oauth", refresh: "next-refresh", access: "next-access", expires: 456 }), + null, + null, + null, + 100, + 200, + ) + insert.run("cred_next_invalid", "invalid", "default", "not-json", null, null, null, 100, 200) + sourceDb.close() + + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + const now = Date.now() + yield* db.run(sql` + INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) + VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now}) + `) + + yield* db.transaction((tx) => importNextCredentials(tx, source)) + // A second run is a no-op because the integration now has a credential. + yield* db.transaction((tx) => importNextCredentials(tx, source)) + // A missing source database is a no-op. + yield* db.transaction((tx) => importNextCredentials(tx, path.join(tmp.path, "missing.db"))) + + expect( + yield* db.all(sql`SELECT id, integration_id, label, value, method_id, time_created FROM credential ORDER BY integration_id`), + ).toEqual([ + { + id: "existing", + integration_id: "anthropic", + label: "Existing", + value: JSON.stringify({ type: "key", key: "current-key" }), + method_id: null, + time_created: now, + }, + { + id: "cred_next_opencode", + integration_id: "opencode", + label: "anomaly", + value: JSON.stringify({ type: "key", key: "zen-key" }), + method_id: "console", + time_created: 100, + }, + ]) + }), + ) + }) + test("rolls back a failed migration without recording it", async () => { await run( Effect.gen(function* () {