fix(core): import credentials from previous channel database
This commit is contained in:
+1
@@ -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[]
|
||||
|
||||
@@ -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<DatabaseMigration.Migration["up"]>[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<string, unknown>
|
||||
|
||||
// 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<SourceRow, []>("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[]),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
|
||||
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* () {
|
||||
|
||||
Reference in New Issue
Block a user