Selaa lähdekoodia

refactor(server): inject database options

Dax Raad 3 viikkoa sitten
vanhempi
sitoutus
f971aa0719

+ 3 - 0
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

+ 1 - 1
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<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
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<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 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`)
+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`),
+    )
+  })
 }
 
-// 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
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),
   )
 }

+ 3 - 3
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),
   )
 }

+ 1 - 1
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(

+ 1 - 1
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]],

+ 3 - 2
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())
   }),
 })
 

+ 20 - 0
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`)
+}

+ 2 - 2
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)

+ 9 - 2
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

+ 0 - 3
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 }

+ 11 - 5
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<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
packages/server/src/routes.ts

@@ -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))),