diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 6cc4b7e7dd..30504dc03f 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -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 diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 707a3fbdb7..2194bfe20d 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -511,7 +511,7 @@ test("a failed service stays registered and owns the selected port until stopped }, 30_000) function withDatabase(file: string, effect: Effect.Effect) { - 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) { diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index e6c36d75b8..4693cfa136 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -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()("@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: [], }) diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index c1fa349fcd..124b01d84f 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -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), ) } diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index a9e0f04660..71b5421e3c 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -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), ) } diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 326855ab51..d4d76c2a23 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -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( diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 4b43fb5477..e44a50e8dd 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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]], diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index 9e7e37e18e..811f7dd137 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -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()) }), }) diff --git a/packages/opencode/src/installation/database.ts b/packages/opencode/src/installation/database.ts new file mode 100644 index 0000000000..b6d00a5cf8 --- /dev/null +++ b/packages/opencode/src/installation/database.ts @@ -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`) +} diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index 88f1097f2d..ad55cb2371 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -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) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 8ebe230791..6c376beeaf 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -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 diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 2214ebf873..9ce482ff87 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -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 } diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 8fddbe3e2a..3e66ec9965 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -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 = { readonly port: Option.Option 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* (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, ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 7a1a1d82be..0b19e2a17f 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -51,25 +51,32 @@ const applicationServices = LayerNode.group([ SessionRestart.node, ]) -export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray = () => []) { +export interface Options { + readonly password?: string + readonly serviceURLs?: () => ReadonlyArray + 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 = {}) { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options) } function makeRoutes( auth: Layer.Layer, - serviceURLs: () => ReadonlyArray = () => [], + options: Pick, ) { 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( 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))),