refactor(server): inject database options

This commit is contained in:
Dax Raad
2026-07-20 15:18:39 -04:00
parent 61b7caa0b5
commit f971aa0719
14 changed files with 93 additions and 52 deletions
+3
View File
@@ -65,6 +65,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
port: Option.fromNullishOr(port),
password,
instanceID,
database: {
path: process.env.OPENCODE_DB,
},
service:
serviceOptions === undefined
? undefined
+1 -1
View File
@@ -511,7 +511,7 @@ test("a failed service stays registered and owns the selected port until stopped
}, 30_000)
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) {
+21 -21
View File
@@ -1,10 +1,9 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer } from "#sqlite"
import { sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
@@ -17,6 +16,10 @@ export interface Interface {
db: DatabaseShape
}
export interface Options {
readonly path?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
const databaseLayer = Layer.effect(
@@ -36,28 +39,25 @@ const databaseLayer = Layer.effect(
}).pipe(Effect.orDie),
)
export function layerFromPath(filename: string) {
return databaseLayer.pipe(Layer.provide(layer({ filename })))
export function layer(options?: Options) {
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({
service: Service,
layer: Layer.suspend(() => layerFromPath(path())),
layer: layer(),
deps: [],
})
+3 -3
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(
Sqlite.Drizzle,
@@ -171,9 +171,9 @@ const drizzleLayer = Layer.effect(
}),
)
export const layer = (config: Config) => {
export const sqliteLayer = (config: 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),
)
}
+3 -3
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(
Sqlite.Drizzle,
@@ -166,9 +166,9 @@ const drizzleLayer = Layer.effect(
}),
)
export const layer = (config: Config) => {
export const sqliteLayer = (config: 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),
)
}
@@ -324,7 +324,7 @@ describe("DatabaseMigration", () => {
test("serializes concurrent embedded initialization for one database path", async () => {
await using tmp = await tmpdir()
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(
Effect.all(
+1 -1
View File
@@ -460,7 +460,7 @@ describe("SessionV2.create", () => {
Effect.promise(() => tmpdir()),
(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(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
[[Database.node, targetDatabase]],
+3 -2
View File
@@ -1,6 +1,7 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "@opencode-ai/core/database/database"
import { InstallationDatabase } from "@/installation/database"
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { effectCmd } from "../effect-cmd"
@@ -35,7 +36,7 @@ const QueryCommand = effectCmd({
}
return
}
const child = spawn("sqlite3", [Database.path()], {
const child = spawn("sqlite3", [InstallationDatabase.path()], {
stdio: "inherit",
})
yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve)))
@@ -47,7 +48,7 @@ const PathCommand = effectCmd({
describe: "print the database path",
instance: false,
handler: Effect.fn("Cli.db.path")(function* () {
console.log(Database.path())
console.log(InstallationDatabase.path())
}),
})
@@ -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`)
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { rm } from "fs/promises"
import { Database } from "@opencode-ai/core/database/database"
import { InstallationDatabase } from "@/installation/database"
import { disposeAllInstances } from "./fixture"
export async function resetDatabase() {
await disposeAllInstances().catch(() => undefined)
const dbPath = Database.path()
const dbPath = InstallationDatabase.path()
await rm(dbPath, { force: true }).catch(() => undefined)
await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined)
await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined)
+9 -2
View File
@@ -1,12 +1,19 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { Database } from "@opencode-ai/core/database/database"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
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(
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,
)
const context = yield* runtime.contextEffect
-3
View File
@@ -1,14 +1,11 @@
import fs from "fs/promises"
import path from "path"
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 { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"
Flag.OPENCODE_DB = ":memory:"
const it = testEffect(Layer.empty)
type Sdk = typeof import("../src")
type Fixture = { readonly directory: string; readonly sdk: Sdk }
+11 -5
View File
@@ -1,6 +1,7 @@
export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { Database } from "@opencode-ai/core/database/database"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
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 password: string
readonly instanceID: string
readonly database?: Database.Options
readonly service?: {
readonly onListen: (
address: HttpServer.Address,
@@ -66,11 +68,15 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
const boot = Effect.gen(function* () {
const context = yield* Layer.buildWithScope(
createRoutes(options.password, () => {
const address = bound.server.address()
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)
createRoutes({
password: options.password,
serviceURLs: () => {
const address = bound.server.address()
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)),
applicationScope,
)
+15 -8
View File
@@ -51,25 +51,32 @@ const applicationServices = LayerNode.group([
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(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
options.password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(options.password) })
: ServerAuth.Config.layer,
serviceURLs,
options,
)
}
export function createEmbeddedRoutes() {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
export function createEmbeddedRoutes(options: Pick<Options, "database"> = {}) {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options)
}
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
serviceURLs: () => ReadonlyArray<string> = () => [],
options: Pick<Options, "database" | "serviceURLs">,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.layer(options.database)],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]
@@ -88,7 +95,7 @@ function makeRoutes<AuthError, AuthServices>(
const services = Layer.succeedContext(context)
const requestServices = Layer.merge(
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(
Layer.provide(handlers.pipe(Layer.provide(services))),