refactor(opencode): extract managed-runtime helper, prune adapter dead code

- Extract makeManagedRuntime() to src/effect/managed-runtime.ts so AppRuntime
  and BootstrapRuntime stop duplicating the lazy ManagedRuntime + dispose
  pattern, and document the shared-memoMap dispose ordering invariant.
- Add lazy.resetIf(expected) and use it in 3 compare-and-reset call sites
  (db.close, AppRuntime.dispose, disposeWebHandler).
- Drop dead `filename` option from EffectDrizzleSqlite MakeConfig.
- Drop redundant `patched` IIFE flag (patchClass is already idempotent).
- Add module-load assertion that Effect's protocol keys are present so a
  silent breakage on an Effect upgrade becomes a loud failure at import.
- Collapse share-next test `live()` into the wider `wired()` factory.
- Document lifecycle constraint in db-effect.ts and test/fixture/db.ts.
This commit is contained in:
Kit Langton
2026-05-02 23:23:32 -04:00
parent 322bb01257
commit 46996e5a67
7 changed files with 51 additions and 64 deletions
+3 -6
View File
@@ -1,5 +1,6 @@
import { Layer, ManagedRuntime } from "effect"
import { Layer } from "effect"
import { attach } from "./run-service"
import { makeManagedRuntime } from "./managed-runtime"
import * as Observability from "@opencode-ai/core/effect/observability"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -51,8 +52,6 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { lazy } from "@/util/lazy"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
@@ -128,7 +127,5 @@ export const AppRuntime: Runtime = {
runCallback(effect) {
return rt.runCallback(wrap(effect))
},
async dispose() {
await rt.dispose()
},
dispose: () => rt.dispose(),
}
@@ -10,7 +10,6 @@ import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { lazy } from "@/util/lazy"
import * as Observability from "@opencode-ai/core/effect/observability"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -27,20 +26,4 @@ export const BootstrapLayer = Layer.mergeAll(
Bus.defaultLayer,
).pipe(Layer.provide(Observability.layer))
const rt = lazy(() => ManagedRuntime.make(BootstrapLayer, { memoMap }))
type Runtime = Pick<ReturnType<typeof rt>, "runPromise" | "dispose">
export const BootstrapRuntime: Runtime = {
runPromise(effect, options) {
return rt().runPromise(effect, options)
},
async dispose() {
const current = rt.peek()
if (!current) return
try {
await current.dispose()
} finally {
if (rt.peek() === current) rt.reset()
}
},
}
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
@@ -3,6 +3,12 @@ import { Context, Effect, Layer } from "effect"
import type { EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite"
import * as StorageSchema from "@/storage/schema"
// Thin Effect Service over the module-global `Database.Client` lazy. The DB
// lifecycle is owned by `Database.open` / `Database.close`, not by this
// layer. Any runtime (see `effect/managed-runtime.ts`) that consumes this
// layer through the shared layer memoMap must be disposed before
// `Database.close()` so its memoized Service value does not outlive the
// underlying SQLite handle. See `test/fixture/db.ts:resetDatabase`.
export class Service extends Context.Service<Service, EffectSQLiteDatabase<typeof StorageSchema>>()(
"@opencode/DatabaseEffect",
) {}
+1 -1
View File
@@ -125,7 +125,7 @@ export const Client = lazy(open)
export function close(client = Client.peek()) {
if (!client) return
client.$client.close()
if (Client.peek() === client) Client.reset()
Client.resetIf(client)
}
export type TxOrDb = Transaction | Client
+6
View File
@@ -2,6 +2,12 @@ import { rm } from "fs/promises"
import { Database } from "@/storage/db"
import { disposeAllInstances } from "./fixture"
// Order matters and must stay serial: every runtime that transitively consumes
// `DatabaseEffect.layer` shares the global layer memoMap with the others, so
// each one's memoized Service value still references the live SQLite handle.
// We dispose every runtime/handler first, then close the DB. If a future
// module-scoped runtime is added that depends on the DB, register its
// dispose() here.
export async function resetDatabase() {
await disposeAllInstances().catch(() => undefined)
Database.close()
@@ -40,19 +40,6 @@ const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unkno
const none = HttpClient.make(() => Effect.die("unexpected http call"))
function live(client: HttpClient.HttpClient) {
const http = Layer.succeed(HttpClient.HttpClient, client)
return ShareNext.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))),
Layer.provide(Config.defaultLayer),
Layer.provide(http),
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(DatabaseEffect.layer),
)
}
function wired(client: HttpClient.HttpClient) {
const http = Layer.succeed(HttpClient.HttpClient, client)
return Layer.mergeAll(
@@ -107,7 +94,7 @@ describe("ShareNext", () => {
expect(req.baseUrl).toBe("https://legacy-share.example.com")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(live(none))),
).pipe(Effect.provide(wired(none))),
{ config: { enterprise: { url: "https://legacy-share.example.com" } } },
),
)
@@ -122,7 +109,7 @@ describe("ShareNext", () => {
expect(req.api.create).toBe("/api/share")
expect(req.headers).toEqual({})
}),
).pipe(Effect.provide(live(none))),
).pipe(Effect.provide(wired(none))),
),
)