import { migrate } from "drizzle-orm/bun-sqlite/migrator" export * from "drizzle-orm" import { LocalContext } from "@/util/local-context" import { lazy } from "../util/lazy" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationChannel } from "@opencode-ai/core/installation/version" import { InstanceState } from "@/effect/instance-state" import { iife } from "@/util/iife" import * as StorageSchema from "@/storage/schema" import { init } from "#db" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined export const NotFoundError = NamedError.create( "NotFoundError", z.object({ message: z.string(), }), ) const log = Log.create({ service: "db" }) export function getChannelPath() { if (["latest", "beta", "prod"].includes(InstallationChannel) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) } export const Path = iife(() => { if (Flag.OPENCODE_DB) { if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB return path.join(Global.Path.data, Flag.OPENCODE_DB) } return getChannelPath() }) export type Client = ReturnType export type Transaction = Parameters[0]>[0] type Journal = { sql: string; timestamp: number; name: string }[] // Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use. const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void function applyMigrations(db: SQLiteBunDatabase, entries: Journal) { migrateFromJournal(db, entries) } function time(tag: string) { const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) if (!match) return 0 return Date.UTC( Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]), ) } function migrations(dir: string): Journal { const dirs = readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) const sql = dirs .map((name) => { const file = path.join(dir, name, "migration.sql") if (!existsSync(file)) return return { sql: readFileSync(file, "utf-8"), timestamp: time(name), name, } }) .filter(Boolean) as Journal return sql.sort((a, b) => a.timestamp - b.timestamp) } export function open() { log.info("opening database", { path: Path }) const db = init(Path, StorageSchema) db.run("PRAGMA journal_mode = WAL") db.run("PRAGMA synchronous = NORMAL") db.run("PRAGMA busy_timeout = 5000") db.run("PRAGMA cache_size = -64000") db.run("PRAGMA foreign_keys = ON") db.run("PRAGMA wal_checkpoint(PASSIVE)") // Apply schema migrations const entries = typeof OPENCODE_MIGRATIONS !== "undefined" ? OPENCODE_MIGRATIONS : migrations(path.join(import.meta.dirname, "../../migration")) if (entries.length > 0) { log.info("applying migrations", { count: entries.length, mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev", }) if (Flag.OPENCODE_SKIP_MIGRATIONS) { for (const item of entries) { item.sql = "select 1;" } } applyMigrations(db, entries) } return db } export const Client = lazy(open) export function close(client = Client.peek()) { if (!client) return client.$client.close() if (Client.peek() === client) Client.reset() } export type TxOrDb = Transaction | Client const ctx = LocalContext.create<{ tx: TxOrDb effects: (() => void | Promise)[] }>("database") export function use(callback: (trx: TxOrDb) => T): T { try { return callback(ctx.use().tx) } catch (err) { if (err instanceof LocalContext.NotFound) { const effects: (() => void | Promise)[] = [] const client = Client() const result = ctx.provide({ effects, tx: client }, () => callback(client)) for (const effect of effects) effect() return result } throw err } } export function effect(fn: () => any | Promise) { const bound = InstanceState.bind(fn) try { ctx.use().effects.push(bound) } catch { bound() } } type NotPromise = T extends Promise ? never : T export function transaction( callback: (tx: TxOrDb) => NotPromise, options?: { behavior?: "deferred" | "immediate" | "exclusive" }, ): NotPromise { try { return callback(ctx.use().tx) } catch (err) { if (err instanceof LocalContext.NotFound) { const effects: (() => void | Promise)[] = [] const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx))) const result = Client().transaction(txCallback, { behavior: options?.behavior }) for (const effect of effects) effect() return result as NotPromise } throw err } } export * as Database from "./db"