fix(core): migrate sync database clients

This commit is contained in:
Dax Raad
2026-05-24 15:26:22 -04:00
parent aa8ad494b8
commit 9b812d9609
2 changed files with 90 additions and 15 deletions
+86 -11
View File
@@ -1,51 +1,80 @@
export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { sql, type SQLWrapper } from "drizzle-orm"
import { Effect } from "effect"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
type EffectDatabase = EffectDrizzleSqlite.EffectSQLiteDatabase
type Query = string | SQLWrapper
type MigrationEffect<A> = Effect.Effect<A, unknown, never>
export type Transaction = {
run: (query: Query) => MigrationEffect<unknown>
}
type SyncDatabase = {
run: (query: Query) => unknown
all: <A = unknown>(query: Query) => A[]
get: <A = unknown>(query: Query) => A | undefined
}
type SyncTransaction = {
run: (query: Query) => unknown
}
type Database = EffectDatabase | SyncDatabase
type Target = {
run: (query: Query) => MigrationEffect<unknown>
all: <A = unknown>(query: Query) => MigrationEffect<A[]>
get: <A = unknown>(query: Query) => MigrationEffect<A | undefined>
transaction: <A>(body: (tx: Transaction) => MigrationEffect<A>) => MigrationEffect<A>
}
export type Migration = {
id: string
up: (tx: Transaction) => Effect.Effect<void, unknown>
up: (tx: Transaction) => MigrationEffect<void>
}
export function apply(db: EffectDatabase): MigrationEffect<void>
export function apply(db: SyncDatabase): MigrationEffect<void>
export function apply(db: Database) {
return applyOnly(db, migrations)
return applyOnlyImpl(db, migrations)
}
export function applyOnly(db: EffectDatabase, input: Migration[]): MigrationEffect<void>
export function applyOnly(db: SyncDatabase, input: Migration[]): MigrationEffect<void>
export function applyOnly(db: Database, input: Migration[]) {
return applyOnlyImpl(db, input)
}
function applyOnlyImpl(db: Database, input: Migration[]) {
return Effect.gen(function* () {
yield* db.run(
const target = normalize(db)
yield* target.run(
sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
let completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
(yield* target.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
if (completed.size === 0) {
// Existing installs used Drizzle's migration journal. Seed the new
// journal once so TypeScript migrations don't replay old SQL.
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
yield* target.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
yield* db.run(sql`
yield* target.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
`)
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
(yield* target.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
}
}
for (const migration of input) {
if (completed.has(migration.id)) continue
yield* db.transaction((tx) =>
yield* target.transaction((tx) =>
Effect.gen(function* () {
yield* migration.up(tx)
yield* tx.run(
@@ -56,3 +85,49 @@ export function applyOnly(db: Database, input: Migration[]) {
}
})
}
function normalize(db: Database): Target {
if (isEffectDatabase(db)) return normalizeEffect(db)
return normalizeSync(db)
}
function normalizeEffect(db: EffectDatabase): Target {
return {
run: (query) => db.run(query).pipe(Effect.as(undefined)),
all: (query) => db.all(query),
get: (query) => db.get(query),
transaction: (body) => db.transaction((tx) => body(normalizeEffectTransaction(tx))),
}
}
function normalizeSync(db: SyncDatabase): Target {
const tx = normalizeSyncTransaction(db)
return {
run: tx.run,
all: (query) => Effect.try({ try: () => db.all(query), catch: (err) => err }),
get: (query) => Effect.try({ try: () => db.get(query), catch: (err) => err }),
transaction: (body) =>
Effect.gen(function* () {
yield* tx.run("BEGIN")
const result = yield* body(tx).pipe(Effect.catch((err) => tx.run("ROLLBACK").pipe(Effect.flatMap(() => Effect.fail(err)))))
yield* tx.run("COMMIT")
return result
}),
}
}
function normalizeEffectTransaction(tx: { run: (query: Query) => MigrationEffect<unknown> }): Transaction {
return {
run: (query) => tx.run(query),
}
}
function normalizeSyncTransaction(tx: SyncTransaction): Transaction {
return {
run: (query) => Effect.try({ try: () => tx.run(query), catch: (err) => err }),
}
}
function isEffectDatabase(db: Database): db is EffectDatabase {
return "raw" in db
}
+4 -4
View File
@@ -5,8 +5,9 @@ import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { EffectBridge } from "@/effect/bridge"
import { init } from "#db"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
export const NotFoundError = NamedError.create("NotFoundError", {
message: Schema.String,
@@ -30,8 +31,6 @@ export const Client = Object.assign(
const dbPath = getPath()
log.info("opening database", { path: dbPath })
Database.init()
const db = init(dbPath)
db.run("PRAGMA journal_mode = WAL")
@@ -40,6 +39,7 @@ export const Client = Object.assign(
db.run("PRAGMA cache_size = -64000")
db.run("PRAGMA foreign_keys = ON")
db.run("PRAGMA wal_checkpoint(PASSIVE)")
Effect.runSync(DatabaseMigration.apply(db))
client = db
loaded = true
@@ -56,7 +56,7 @@ export const Client = Object.assign(
export function close() {
if (!Client.loaded()) return
Client().$client.close()
client?.$client.close()
Client.reset()
}