diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 42aebf02d..db2ddd10f 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -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[0]>[0] +type EffectDatabase = EffectDrizzleSqlite.EffectSQLiteDatabase +type Query = string | SQLWrapper +type MigrationEffect = Effect.Effect +export type Transaction = { + run: (query: Query) => MigrationEffect +} +type SyncDatabase = { + run: (query: Query) => unknown + all: (query: Query) => A[] + get: (query: Query) => A | undefined +} +type SyncTransaction = { + run: (query: Query) => unknown +} +type Database = EffectDatabase | SyncDatabase +type Target = { + run: (query: Query) => MigrationEffect + all: (query: Query) => MigrationEffect + get: (query: Query) => MigrationEffect + transaction: (body: (tx: Transaction) => MigrationEffect) => MigrationEffect +} export type Migration = { id: string - up: (tx: Transaction) => Effect.Effect + up: (tx: Transaction) => MigrationEffect } +export function apply(db: EffectDatabase): MigrationEffect +export function apply(db: SyncDatabase): MigrationEffect export function apply(db: Database) { - return applyOnly(db, migrations) + return applyOnlyImpl(db, migrations) } +export function applyOnly(db: EffectDatabase, input: Migration[]): MigrationEffect +export function applyOnly(db: SyncDatabase, input: Migration[]): MigrationEffect 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 }): 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 +} diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 3978192c3..a6e7c0079 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -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() }