refactor(server): inject database options

This commit is contained in:
Dax Raad 2026-07-20 13:01:01 -04:00
commit f971aa0719
14 changed files with 93 additions and 52 deletions

View file

@ -65,6 +65,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
port: Option.fromNullishOr(port), port: Option.fromNullishOr(port),
password, password,
instanceID, instanceID,
database: {
path: process.env.OPENCODE_DB,
},
service: service:
serviceOptions === undefined serviceOptions === undefined
? undefined ? undefined

View file

@ -511,7 +511,7 @@ test("a failed service stays registered and owns the selected port until stopped
}, 30_000) }, 30_000)
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) { function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
return Effect.runPromise(effect.pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped)) return Effect.runPromise(effect.pipe(Effect.provide(Database.layer({ path: file })), Effect.scoped))
} }
function waitForExecutionStart(file: string, sessionID: SessionV2.ID) { function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {

View file

@ -1,10 +1,9 @@
export * as Database from "./database" export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer } from "#sqlite" import { sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Global } from "../global" import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path" import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration" import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version" import { InstallationChannel } from "../installation/version"
@ -17,6 +16,10 @@ export interface Interface {
db: DatabaseShape db: DatabaseShape
} }
export interface Options {
readonly path?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
const databaseLayer = Layer.effect( const databaseLayer = Layer.effect(
@ -36,28 +39,25 @@ const databaseLayer = Layer.effect(
}).pipe(Effect.orDie), }).pipe(Effect.orDie),
) )
export function layerFromPath(filename: string) { export function layer(options?: Options) {
return databaseLayer.pipe(Layer.provide(layer({ filename }))) return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path)
if (options?.path) return provide(join(Global.Path.data, options.path))
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return provide(join(Global.Path.data, "opencode.db"))
return provide(
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
)
})
} }
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
// Resolve the database path lazily so tests and embedders that set
// Flag.OPENCODE_DB after module evaluation still control the storage target.
export const node = makeGlobalNode({ export const node = makeGlobalNode({
service: Service, service: Service,
layer: Layer.suspend(() => layerFromPath(path())), layer: layer(),
deps: [], deps: [],
}) })

View file

@ -162,7 +162,7 @@ const nativeLayer = (config: Config) =>
}), }),
) )
const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect( const drizzleLayer = Layer.effect(
Sqlite.Drizzle, Sqlite.Drizzle,
@ -171,9 +171,9 @@ const drizzleLayer = Layer.effect(
}), }),
) )
export const layer = (config: Config) => { export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config) const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe( return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer), Layer.provide(Reactivity.layer),
) )
} }

View file

@ -157,7 +157,7 @@ const nativeLayer = (config: Config) =>
}), }),
) )
const sqliteLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect( const drizzleLayer = Layer.effect(
Sqlite.Drizzle, Sqlite.Drizzle,
@ -166,9 +166,9 @@ const drizzleLayer = Layer.effect(
}), }),
) )
export const layer = (config: Config) => { export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config) const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe( return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer), Layer.provide(Reactivity.layer),
) )
} }

View file

@ -324,7 +324,7 @@ describe("DatabaseMigration", () => {
test("serializes concurrent embedded initialization for one database path", async () => { test("serializes concurrent embedded initialization for one database path", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filename = path.join(tmp.path, "embedded.sqlite") const filename = path.join(tmp.path, "embedded.sqlite")
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)] const layers = [Database.layer({ path: filename }), Database.layer({ path: filename })]
await Effect.runPromise( await Effect.runPromise(
Effect.all( Effect.all(

View file

@ -460,7 +460,7 @@ describe("SessionV2.create", () => {
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
) )
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite")) const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build( const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]), LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
[[Database.node, targetDatabase]], [[Database.node, targetDatabase]],

View file

@ -1,6 +1,7 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { spawn } from "child_process" import { spawn } from "child_process"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { InstallationDatabase } from "@/installation/database"
import { Effect } from "effect" import { Effect } from "effect"
import { sql } from "drizzle-orm" import { sql } from "drizzle-orm"
import { effectCmd } from "../effect-cmd" import { effectCmd } from "../effect-cmd"
@ -35,7 +36,7 @@ const QueryCommand = effectCmd({
} }
return return
} }
const child = spawn("sqlite3", [Database.path()], { const child = spawn("sqlite3", [InstallationDatabase.path()], {
stdio: "inherit", stdio: "inherit",
}) })
yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve))) yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve)))
@ -47,7 +48,7 @@ const PathCommand = effectCmd({
describe: "print the database path", describe: "print the database path",
instance: false, instance: false,
handler: Effect.fn("Cli.db.path")(function* () { handler: Effect.fn("Cli.db.path")(function* () {
console.log(Database.path()) console.log(InstallationDatabase.path())
}), }),
}) })

View file

@ -0,0 +1,20 @@
export * as InstallationDatabase from "./database"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { isAbsolute, join } from "node:path"
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}

View file

@ -1,10 +1,10 @@
import { rm } from "fs/promises" import { rm } from "fs/promises"
import { Database } from "@opencode-ai/core/database/database" import { InstallationDatabase } from "@/installation/database"
import { disposeAllInstances } from "./fixture" import { disposeAllInstances } from "./fixture"
export async function resetDatabase() { export async function resetDatabase() {
await disposeAllInstances().catch(() => undefined) await disposeAllInstances().catch(() => undefined)
const dbPath = Database.path() const dbPath = InstallationDatabase.path()
await rm(dbPath, { force: true }).catch(() => undefined) await rm(dbPath, { force: true }).catch(() => undefined)
await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined) await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined)
await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined) await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined)

View file

@ -1,12 +1,19 @@
import { OpenCode } from "@opencode-ai/client/effect" import { OpenCode } from "@opencode-ai/client/effect"
import { Database } from "@opencode-ai/core/database/database"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Context, Effect, Layer, ManagedRuntime } from "effect" import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* () { export const create = Effect.fn("OpenCode.create")(function* (options: { readonly database?: Database.Options } = {}) {
const runtime = yield* Effect.acquireRelease( const runtime = yield* Effect.acquireRelease(
Effect.sync(() => ManagedRuntime.make(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices)))), Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes({ database: { path: ":memory:", ...options.database } }).pipe(
Layer.provide(HttpServer.layerServices),
),
),
),
(runtime) => runtime.disposeEffect, (runtime) => runtime.disposeEffect,
) )
const context = yield* runtime.contextEffect const context = yield* runtime.contextEffect

View file

@ -1,14 +1,11 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect" import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { testEffect } from "../../core/test/lib/effect" import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir" import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src" import type { OpenCodeEvent } from "../src"
Flag.OPENCODE_DB = ":memory:"
const it = testEffect(Layer.empty) const it = testEffect(Layer.empty)
type Sdk = typeof import("../src") type Sdk = typeof import("../src")
type Fixture = { readonly directory: string; readonly sdk: Sdk } type Fixture = { readonly directory: string; readonly sdk: Sdk }

View file

@ -1,6 +1,7 @@
export * as ServerProcess from "./process" export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { Database } from "@opencode-ai/core/database/database"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
@ -20,6 +21,7 @@ export type Options<E = never, R = never> = {
readonly port: Option.Option<number> readonly port: Option.Option<number>
readonly password: string readonly password: string
readonly instanceID: string readonly instanceID: string
readonly database?: Database.Options
readonly service?: { readonly service?: {
readonly onListen: ( readonly onListen: (
address: HttpServer.Address, address: HttpServer.Address,
@ -66,11 +68,15 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
const boot = Effect.gen(function* () { const boot = Effect.gen(function* () {
const context = yield* Layer.buildWithScope( const context = yield* Layer.buildWithScope(
createRoutes(options.password, () => { createRoutes({
const address = bound.server.address() password: options.password,
if (address === null || typeof address === "string") return [] serviceURLs: () => {
const host = address.family === "IPv6" ? `[${address.address}]` : address.address const address = bound.server.address()
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname) if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname)
},
database: options.database,
}).pipe(Layer.provide(NodeHttpServer.layerHttpServices)), }).pipe(Layer.provide(NodeHttpServer.layerHttpServices)),
applicationScope, applicationScope,
) )

View file

@ -51,25 +51,32 @@ const applicationServices = LayerNode.group([
SessionRestart.node, SessionRestart.node,
]) ])
export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray<string> = () => []) { export interface Options {
readonly password?: string
readonly serviceURLs?: () => ReadonlyArray<string>
readonly database?: Database.Options
}
export function createRoutes(options: Options = {}) {
return makeRoutes( return makeRoutes(
password options.password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(options.password) })
: ServerAuth.Config.layer, : ServerAuth.Config.layer,
serviceURLs, options,
) )
} }
export function createEmbeddedRoutes() { export function createEmbeddedRoutes(options: Pick<Options, "database"> = {}) {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options)
} }
function makeRoutes<AuthError, AuthServices>( function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>, auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
serviceURLs: () => ReadonlyArray<string> = () => [], options: Pick<Options, "database" | "serviceURLs">,
) { ) {
const pluginRuntimeCell = PluginRuntime.makeCell() const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [ const replacements: LayerNode.Replacements = [
[Database.node, Database.layer(options.database)],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
] ]
@ -88,7 +95,7 @@ function makeRoutes<AuthError, AuthServices>(
const services = Layer.succeedContext(context) const services = Layer.succeedContext(context)
const requestServices = Layer.merge( const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
ServerInfo.layer(serviceURLs), ServerInfo.layer(options.serviceURLs ?? (() => [])),
) )
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))), Layer.provide(handlers.pipe(Layer.provide(services))),