From 89efce865d0965aa7291367b6cb1376d8970f029 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:06:24 -0400 Subject: [PATCH 01/13] feat(effect-drizzle-sqlite): add sqlite adapter --- bun.lock | 15 ++ packages/effect-drizzle-sqlite/package.json | 24 ++ packages/effect-drizzle-sqlite/src/index.ts | 228 ++++++++++++++++++ .../effect-drizzle-sqlite/test/sqlite.test.ts | 143 +++++++++++ packages/effect-drizzle-sqlite/tsconfig.json | 15 ++ 5 files changed, 425 insertions(+) create mode 100644 packages/effect-drizzle-sqlite/package.json create mode 100644 packages/effect-drizzle-sqlite/src/index.ts create mode 100644 packages/effect-drizzle-sqlite/test/sqlite.test.ts create mode 100644 packages/effect-drizzle-sqlite/tsconfig.json diff --git a/bun.lock b/bun.lock index 25068f3d9a..47c22ee220 100644 --- a/bun.lock +++ b/bun.lock @@ -307,6 +307,19 @@ "@lydell/node-pty-win32-x64": "1.2.0-beta.10", }, }, + "packages/effect-drizzle-sqlite": { + "name": "@opencode-ai/effect-drizzle-sqlite", + "version": "0.0.0", + "dependencies": { + "drizzle-orm": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/enterprise": { "name": "@opencode-ai/enterprise", "version": "1.14.33", @@ -1572,6 +1585,8 @@ "@opencode-ai/desktop-electron": ["@opencode-ai/desktop-electron@workspace:packages/desktop-electron"], + "@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"], + "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json new file mode 100644 index 0000000000..fa051bad04 --- /dev/null +++ b/packages/effect-drizzle-sqlite/package.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/effect-drizzle-sqlite", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "test": "bun test", + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "drizzle-orm": "catalog:", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts new file mode 100644 index 0000000000..5874a18180 --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -0,0 +1,228 @@ +import { Database } from "bun:sqlite" +import { drizzle as drizzleBun, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" +import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations" +import { SQLiteCountBuilder } from "drizzle-orm/sqlite-core/query-builders/count" +import { SQLiteDeleteBase } from "drizzle-orm/sqlite-core/query-builders/delete" +import { SQLiteInsertBase } from "drizzle-orm/sqlite-core/query-builders/insert" +import { SQLiteRelationalQuery, SQLiteSyncRelationalQuery } from "drizzle-orm/sqlite-core/query-builders/_query" +import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select" +import { SQLiteUpdateBase } from "drizzle-orm/sqlite-core/query-builders/update" +import type { SQLiteTransaction, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session" +import { SQLitePreparedQuery } from "drizzle-orm/sqlite-core/session" +import type { DrizzleConfig } from "drizzle-orm/utils" +import { Cause, Effect, Exit, Schema } from "effect" +import { pipeArguments } from "effect/Pipeable" + +export class EffectDrizzleQueryError extends Schema.TaggedErrorClass()( + "EffectDrizzleQueryError", + { + query: Schema.String, + params: Schema.Array(Schema.Unknown), + cause: Schema.Unknown, + }, +) { + override get message() { + return `Failed query: ${this.query}\nparams: ${JSON.stringify(this.params)}` + } +} + +export type EffectSQLiteDatabase< + TSchema extends Record = Record, + TRelations extends AnyRelations = EmptyRelations, +> = SQLiteBunDatabase & { + readonly $client: Database + readonly withTransaction: ( + transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => Effect.Effect, + config?: SQLiteTransactionConfig, + ) => Effect.Effect +} + +export type MakeConfig< + TSchema extends Record = Record, + TRelations extends AnyRelations = EmptyRelations, +> = DrizzleConfig & { + readonly client?: Database + readonly filename?: string +} + +type EffectLikeQuery = { + readonly asEffect?: () => Effect.Effect + readonly toSQL?: () => { readonly sql: string; readonly params?: readonly unknown[] } +} + +type PreparedLike = EffectLikeQuery & { + readonly execute: () => unknown + readonly getQuery?: () => { readonly sql: string; readonly params?: readonly unknown[] } +} + +type SelectLike = EffectLikeQuery & { + readonly all: () => A +} + +type MutationLike = EffectLikeQuery & { + readonly all: () => A + readonly run: () => A + readonly config?: { readonly returning?: unknown } +} + +type CountLike = EffectLikeQuery & PromiseLike + +class TransactionFailure extends Error { + constructor(readonly effectCause: Cause.Cause) { + super("Effect transaction failed") + } +} + +const EffectTypeId = "~effect/Effect" +const EffectIdentifier = `${EffectTypeId}/identifier` +const EffectEvaluate = `${EffectTypeId}/evaluate` + +const effectVariance = { + _A: (value: unknown) => value, + _E: (value: unknown) => value, + _R: (value: unknown) => value, +} + +const queryInfo = (query: EffectLikeQuery | PreparedLike) => { + const info = "getQuery" in query && typeof query.getQuery === "function" ? query.getQuery() : query.toSQL?.() + return { + query: info?.sql ?? "", + params: [...(info?.params ?? [])], + } +} + +const queryError = (query: EffectLikeQuery | PreparedLike, cause: unknown) => + new EffectDrizzleQueryError({ + ...queryInfo(query), + cause, + }) + +const fromSync = (query: EffectLikeQuery, run: () => A) => + Effect.try({ + try: run, + catch: (cause) => queryError(query, cause), + }) + +const fromMutation = (query: MutationLike) => fromSync(query, () => (query.config?.returning ? query.all() : query.run())) + +const fromExecuteResult = (result: unknown) => { + if (result && typeof result === "object" && "sync" in result && typeof result.sync === "function") { + return result.sync() + } + return result +} + +const queryEffectProto = { + [EffectTypeId]: effectVariance, + pipe() { + return pipeArguments(this, arguments) + }, + [Symbol.iterator]() { + let done = false + const self = this + return { + next(value: unknown) { + if (done) return { done: true, value } + done = true + return { done: false, value: self } + }, + [Symbol.iterator]() { + return this + }, + } + }, + [EffectIdentifier]: "DrizzleSqliteQuery", + [EffectEvaluate](this: EffectLikeQuery) { + return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()") + }, +} + +const patchClass = (ctor: { readonly prototype: object }, asEffect: (self: A) => Effect.Effect) => { + if (Object.prototype.hasOwnProperty.call(ctor.prototype, "asEffect")) return + Object.assign(ctor.prototype, queryEffectProto, { + asEffect(this: A) { + return asEffect(this) + }, + }) +} + +const patchQueryBuilders = (() => { + let patched = false + return () => { + if (patched) return + patched = true + + patchClass(SQLitePreparedQuery, (query: PreparedLike) => fromSync(query, () => fromExecuteResult(query.execute()))) + patchClass(SQLiteSelectBase, (query: SelectLike) => fromSync(query, () => query.all())) + patchClass(SQLiteInsertBase, fromMutation) + patchClass(SQLiteUpdateBase, fromMutation) + patchClass(SQLiteDeleteBase, fromMutation) + patchClass(SQLiteRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) => + fromSync(query, () => query.executeRaw()), + ) + patchClass(SQLiteSyncRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) => + fromSync(query, () => query.executeRaw()), + ) + patchClass(SQLiteCountBuilder, (query: CountLike) => + Effect.tryPromise({ + try: () => Promise.resolve(query), + catch: (cause) => queryError(query, cause), + }), + ) + } +})() + +const attachTransaction = < + TSchema extends Record = Record, + TRelations extends AnyRelations = EmptyRelations, +>(db: SQLiteBunDatabase & { readonly $client: Database }): EffectSQLiteDatabase => { + const runTransaction = db.transaction.bind(db) as ( + transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => unknown, + config?: SQLiteTransactionConfig, + ) => unknown + + return Object.assign(db, { + withTransaction: ( + transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => Effect.Effect, + config?: SQLiteTransactionConfig, + ) => + Effect.sync( + () => + runTransaction( + (tx) => + Exit.match(Effect.runSyncExit(transaction(tx)), { + onSuccess: (value) => value, + onFailure: (cause) => { + throw new TransactionFailure(cause) + }, + }), + config, + ) as A, + ).pipe( + Effect.catchDefect((defect) => + defect instanceof TransactionFailure ? Effect.failCause(defect.effectCause as Cause.Cause) : Effect.die(defect), + ), + ), + }) as EffectSQLiteDatabase +} + +export const make = < + TSchema extends Record = Record, + TRelations extends AnyRelations = EmptyRelations, +>(config: MakeConfig = {}): EffectSQLiteDatabase => { + patchQueryBuilders() + return attachTransaction( + drizzleBun({ + ...config, + client: config.client ?? new Database(config.filename ?? ":memory:"), + }), + ) +} + +export const drizzle = make + +declare module "drizzle-orm/query-promise" { + interface QueryPromise extends Effect.Effect { + asEffect(): Effect.Effect + } +} diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts new file mode 100644 index 0000000000..8901d6ab36 --- /dev/null +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { eq } from "drizzle-orm" +import { relations } from "drizzle-orm/_relations" +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Cause, Effect, Exit } from "effect" +import { EffectDrizzleQueryError, make, type EffectSQLiteDatabase } from "../src" + +const users = sqliteTable("users", { + id: integer().primaryKey(), + name: text().notNull(), +}) + +const posts = sqliteTable("posts", { + id: integer().primaryKey(), + user_id: integer() + .notNull() + .references(() => users.id), + title: text().notNull(), +}) + +const usersRelations = relations(users, ({ many }) => ({ + posts: many(posts), +})) + +const postsRelations = relations(posts, ({ one }) => ({ + user: one(users, { + fields: [posts.user_id], + references: [users.id], + }), +})) + +const schema = { users, posts, usersRelations, postsRelations } + +let db: EffectSQLiteDatabase + +const testEffect = (name: string, effect: () => Effect.Effect) => test(name, () => Effect.runPromise(effect())) + +beforeEach(() => { + db = make({ schema }) + db.$client.run("PRAGMA foreign_keys = ON") + db.$client.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)") + db.$client.run( + "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL)", + ) +}) + +afterEach(() => { + db.$client.close() +}) + +describe("effect drizzle sqlite", () => { + testEffect("makes select/insert/update/delete query builders yieldable Effects", () => + Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + yield* db.insert(users).values({ id: 2, name: "Grace" }) + + const selected = yield* db.select().from(users).orderBy(users.id) + expect(selected).toEqual([ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ]) + + const updated = yield* db.update(users).set({ name: "Lovelace" }).where(eq(users.id, 1)).returning() + expect(updated).toEqual([{ id: 1, name: "Lovelace" }]) + + const deleted = yield* db.delete(users).where(eq(users.id, 2)).returning({ id: users.id }) + expect(deleted).toEqual([{ id: 2 }]) + + expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Lovelace" }]) + }), + ) + + testEffect("supports direct Effect combinators on queries", () => + Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + + expect( + yield* (db.select().from(users) as Effect.Effect, EffectDrizzleQueryError>).pipe( + Effect.map((rows) => rows.map((row) => row.name)), + ), + ).toEqual(["Ada"]) + }), + ) + + testEffect("supports relational query builders", () => + Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + yield* db.insert(posts).values({ id: 1, user_id: 1, title: "Notes" }) + expect( + yield* db._query.users.findMany({ + with: { + posts: true, + }, + }), + ).toEqual([ + { + id: 1, + name: "Ada", + posts: [{ id: 1, user_id: 1, title: "Notes" }], + }, + ]) + }), + ) + + testEffect("runs synchronous Effect programs inside transactions", () => + Effect.gen(function* () { + yield* db.withTransaction((tx) => + Effect.gen(function* () { + yield* tx.insert(users).values({ id: 1, name: "Ada" }) + return yield* tx.select().from(users) + }), + ) + + expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }]) + + const exit = yield* Effect.exit( + db.withTransaction((tx) => + Effect.gen(function* () { + yield* tx.insert(users).values({ id: 2, name: "Grace" }) + return yield* Effect.fail("rollback") + }), + ), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* db.select().from(users).orderBy(users.id)).toEqual([{ id: 1, name: "Ada" }]) + }), + ) + + testEffect("wraps query failures with query text and parameters", () => + Effect.gen(function* () { + const exit = yield* Effect.exit(db.insert(posts).values({ id: 1, user_id: 404, title: "Missing" })) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = exit.cause.reasons.filter(Cause.isFailReason)[0]?.error + expect(error).toBeInstanceOf(EffectDrizzleQueryError) + expect((error as EffectDrizzleQueryError).query).toContain("insert into") + expect((error as EffectDrizzleQueryError).params).toEqual([1, 404, "Missing"]) + } + }), + ) +}) diff --git a/packages/effect-drizzle-sqlite/tsconfig.json b/packages/effect-drizzle-sqlite/tsconfig.json new file mode 100644 index 0000000000..7e13458481 --- /dev/null +++ b/packages/effect-drizzle-sqlite/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "types": ["bun"], + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} From e4ae265d8f64b454ecb0bf1ab0ebeb0cf228e9fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:18:44 -0400 Subject: [PATCH 02/13] fix(effect-drizzle-sqlite): support pipeable transactions --- packages/effect-drizzle-sqlite/src/index.ts | 69 +++++++++++-------- .../effect-drizzle-sqlite/test/sqlite.test.ts | 38 ++++++---- 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts index 5874a18180..9e0be1a489 100644 --- a/packages/effect-drizzle-sqlite/src/index.ts +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -31,10 +31,10 @@ export type EffectSQLiteDatabase< TRelations extends AnyRelations = EmptyRelations, > = SQLiteBunDatabase & { readonly $client: Database - readonly withTransaction: ( - transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => Effect.Effect, + readonly withTransaction: ( + effect: Effect.Effect, config?: SQLiteTransactionConfig, - ) => Effect.Effect + ) => Effect.Effect } export type MakeConfig< @@ -176,33 +176,48 @@ const attachTransaction = < TSchema extends Record = Record, TRelations extends AnyRelations = EmptyRelations, >(db: SQLiteBunDatabase & { readonly $client: Database }): EffectSQLiteDatabase => { - const runTransaction = db.transaction.bind(db) as ( - transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => unknown, - config?: SQLiteTransactionConfig, - ) => unknown - - return Object.assign(db, { - withTransaction: ( - transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => Effect.Effect, + const txStack: Array> = [] + const current = () => txStack.at(-1) ?? db + const runTransaction = (target: SQLiteBunDatabase | SQLiteTransaction<"sync", void, TSchema, TRelations>) => + target.transaction.bind(target) as ( + transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => unknown, config?: SQLiteTransactionConfig, - ) => - Effect.sync( - () => - runTransaction( - (tx) => - Exit.match(Effect.runSyncExit(transaction(tx)), { - onSuccess: (value) => value, - onFailure: (cause) => { - throw new TransactionFailure(cause) - }, - }), - config, - ) as A, - ).pipe( - Effect.catchDefect((defect) => - defect instanceof TransactionFailure ? Effect.failCause(defect.effectCause as Cause.Cause) : Effect.die(defect), + ) => unknown + + const withTransaction = ( + effect: Effect.Effect, + config?: SQLiteTransactionConfig, + ): Effect.Effect => + Effect.context().pipe( + Effect.flatMap((context) => + Effect.sync( + () => + runTransaction(current())((tx) => { + txStack.push(tx) + try { + const exit = Effect.runSyncExit(Effect.provideContext(effect, context)) + if (Exit.isSuccess(exit)) return exit.value + throw new TransactionFailure(exit.cause) + } finally { + txStack.pop() + } + }, config) as A, + ).pipe( + Effect.catchDefect((defect) => + defect instanceof TransactionFailure ? Effect.failCause(defect.effectCause as Cause.Cause) : Effect.die(defect), + ), ), ), + ) + + return new Proxy(db, { + get(_target, property) { + if (property === "withTransaction") return withTransaction + if (property === "$client") return db.$client + + const value = Reflect.get(current(), property) + return typeof value === "function" ? value.bind(current()) : value + }, }) as EffectSQLiteDatabase } diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts index 8901d6ab36..2f18329031 100644 --- a/packages/effect-drizzle-sqlite/test/sqlite.test.ts +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -104,22 +104,18 @@ describe("effect drizzle sqlite", () => { testEffect("runs synchronous Effect programs inside transactions", () => Effect.gen(function* () { - yield* db.withTransaction((tx) => - Effect.gen(function* () { - yield* tx.insert(users).values({ id: 1, name: "Ada" }) - return yield* tx.select().from(users) - }), - ) + yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + return yield* db.select().from(users) + }).pipe(db.withTransaction) expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }]) const exit = yield* Effect.exit( - db.withTransaction((tx) => - Effect.gen(function* () { - yield* tx.insert(users).values({ id: 2, name: "Grace" }) - return yield* Effect.fail("rollback") - }), - ), + Effect.gen(function* () { + yield* db.insert(users).values({ id: 2, name: "Grace" }) + return yield* Effect.fail("rollback") + }).pipe(db.withTransaction), ) expect(Exit.isFailure(exit)).toBe(true) @@ -127,6 +123,24 @@ describe("effect drizzle sqlite", () => { }), ) + testEffect("supports pipeable transactions using the same database service", () => + Effect.gen(function* () { + const exit = yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + return yield* Effect.fail("rollback") + }).pipe(db.withTransaction, Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* db.select().from(users)).toEqual([]) + + yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 2, name: "Grace" }) + }).pipe(db.withTransaction) + + expect(yield* db.select().from(users)).toEqual([{ id: 2, name: "Grace" }]) + }), + ) + testEffect("wraps query failures with query text and parameters", () => Effect.gen(function* () { const exit = yield* Effect.exit(db.insert(posts).values({ id: 1, user_id: 404, title: "Missing" })) From efcbc153ee9d86b1908a6eb940b9264eddf6a3ad Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:27:53 -0400 Subject: [PATCH 03/13] test(effect-drizzle-sqlite): cover transaction edge cases --- packages/effect-drizzle-sqlite/src/index.ts | 15 +++- .../effect-drizzle-sqlite/test/sqlite.test.ts | 79 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts index 9e0be1a489..868dc23274 100644 --- a/packages/effect-drizzle-sqlite/src/index.ts +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -7,7 +7,7 @@ import { SQLiteInsertBase } from "drizzle-orm/sqlite-core/query-builders/insert" import { SQLiteRelationalQuery, SQLiteSyncRelationalQuery } from "drizzle-orm/sqlite-core/query-builders/_query" import { SQLiteSelectBase } from "drizzle-orm/sqlite-core/query-builders/select" import { SQLiteUpdateBase } from "drizzle-orm/sqlite-core/query-builders/update" -import type { SQLiteTransaction, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session" +import type { PreparedQueryConfig, SQLiteSession, SQLiteTransaction, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session" import { SQLitePreparedQuery } from "drizzle-orm/sqlite-core/session" import type { DrizzleConfig } from "drizzle-orm/utils" import { Cause, Effect, Exit, Schema } from "effect" @@ -241,3 +241,16 @@ declare module "drizzle-orm/query-promise" { asEffect(): Effect.Effect } } + +declare module "drizzle-orm/sqlite-core/session" { + interface SQLitePreparedQuery extends Effect.Effect { + asEffect(): Effect.Effect + } +} + +declare module "drizzle-orm/sqlite-core/query-builders/count" { + interface SQLiteCountBuilder> + extends Effect.Effect { + asEffect(): Effect.Effect + } +} diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts index 2f18329031..6e26bca2ba 100644 --- a/packages/effect-drizzle-sqlite/test/sqlite.test.ts +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" import { eq } from "drizzle-orm" import { relations } from "drizzle-orm/_relations" +import { drizzle as drizzleBun } from "drizzle-orm/bun-sqlite" import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" import { Cause, Effect, Exit } from "effect" import { EffectDrizzleQueryError, make, type EffectSQLiteDatabase } from "../src" @@ -49,6 +51,21 @@ afterEach(() => { }) describe("effect drizzle sqlite", () => { + test("keeps normal Drizzle Bun SQLite clients usable after patching", async () => { + const sqlite = new Database(":memory:") + try { + const normal = drizzleBun({ client: sqlite }) + sqlite.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)") + + normal.insert(users).values({ id: 1, name: "Ada" }).run() + + expect(normal.select().from(users).all()).toEqual([{ id: 1, name: "Ada" }]) + expect(await normal.select().from(users)).toEqual([{ id: 1, name: "Ada" }]) + } finally { + sqlite.close() + } + }) + testEffect("makes select/insert/update/delete query builders yieldable Effects", () => Effect.gen(function* () { yield* db.insert(users).values({ id: 1, name: "Ada" }) @@ -141,6 +158,68 @@ describe("effect drizzle sqlite", () => { }), ) + testEffect("supports count builders and prepared queries", () => + Effect.gen(function* () { + yield* db.insert(users).values([ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ]) + + expect(yield* db.$count(users)).toBe(2) + + const prepared = db.select().from(users).orderBy(users.id).prepare() + expect(yield* prepared).toEqual([ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ]) + }), + ) + + testEffect("nested pipeable transactions commit or roll back with the outer transaction", () => + Effect.gen(function* () { + yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 2, name: "Grace" }) + }).pipe(db.withTransaction) + }).pipe(db.withTransaction) + + expect(yield* db.select().from(users).orderBy(users.id)).toEqual([ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ]) + + const exit = yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 3, name: "Katherine" }) + yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 4, name: "Dorothy" }) + return yield* Effect.fail("inner rollback") + }).pipe(db.withTransaction) + }).pipe(db.withTransaction, Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* db.select().from(users).orderBy(users.id)).toEqual([ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" }, + ]) + }), + ) + + testEffect("defects inside transactions roll back and stay defects", () => + Effect.gen(function* () { + const exit = yield* Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + return yield* Effect.die("boom") + }).pipe(db.withTransaction, Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(exit.cause.reasons.some(Cause.isDieReason)).toBe(true) + } + expect(yield* db.select().from(users)).toEqual([]) + }), + ) + testEffect("wraps query failures with query text and parameters", () => Effect.gen(function* () { const exit = yield* Effect.exit(db.insert(posts).values({ id: 1, user_id: 404, title: "Missing" })) From f6f6cd0515e3d61ac839a429c3a8d2a5799974d2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:32:10 -0400 Subject: [PATCH 04/13] fix(effect-drizzle-sqlite): simplify sqlite adapter --- packages/effect-drizzle-sqlite/src/index.ts | 24 ++++++++++--------- .../effect-drizzle-sqlite/test/sqlite.test.ts | 1 + 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts index 868dc23274..33293b11d6 100644 --- a/packages/effect-drizzle-sqlite/src/index.ts +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -65,7 +65,10 @@ type MutationLike = EffectLikeQuery & { readonly config?: { readonly returning?: unknown } } -type CountLike = EffectLikeQuery & PromiseLike +type CountLike = EffectLikeQuery & { + readonly session: { readonly values: (sql: unknown) => unknown[][] } + readonly sql: unknown +} class TransactionFailure extends Error { constructor(readonly effectCause: Cause.Cause) { @@ -105,6 +108,8 @@ const fromSync = (query: EffectLikeQuery, run: () => A) => const fromMutation = (query: MutationLike) => fromSync(query, () => (query.config?.returning ? query.all() : query.run())) +const fromCount = (query: CountLike) => fromSync(query, () => Number(query.session.values(query.sql)[0]?.[0] ?? 0)) + const fromExecuteResult = (result: unknown) => { if (result && typeof result === "object" && "sync" in result && typeof result.sync === "function") { return result.sync() @@ -163,12 +168,7 @@ const patchQueryBuilders = (() => { patchClass(SQLiteSyncRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) => fromSync(query, () => query.executeRaw()), ) - patchClass(SQLiteCountBuilder, (query: CountLike) => - Effect.tryPromise({ - try: () => Promise.resolve(query), - catch: (cause) => queryError(query, cause), - }), - ) + patchClass(SQLiteCountBuilder, fromCount) } })() @@ -215,8 +215,9 @@ const attachTransaction = < if (property === "withTransaction") return withTransaction if (property === "$client") return db.$client - const value = Reflect.get(current(), property) - return typeof value === "function" ? value.bind(current()) : value + const target = current() + const value = Reflect.get(target, property) + return typeof value === "function" ? value.bind(target) : value }, }) as EffectSQLiteDatabase } @@ -226,10 +227,11 @@ export const make = < TRelations extends AnyRelations = EmptyRelations, >(config: MakeConfig = {}): EffectSQLiteDatabase => { patchQueryBuilders() + const { client, filename, ...drizzleConfig } = config return attachTransaction( drizzleBun({ - ...config, - client: config.client ?? new Database(config.filename ?? ":memory:"), + ...drizzleConfig, + client: client ?? new Database(filename ?? ":memory:"), }), ) } diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts index 6e26bca2ba..5c02c18c5f 100644 --- a/packages/effect-drizzle-sqlite/test/sqlite.test.ts +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -152,6 +152,7 @@ describe("effect drizzle sqlite", () => { yield* Effect.gen(function* () { yield* db.insert(users).values({ id: 2, name: "Grace" }) + expect(yield* db.$count(users)).toBe(1) }).pipe(db.withTransaction) expect(yield* db.select().from(users)).toEqual([{ id: 2, name: "Grace" }]) From 4faa6c64d6ecd53bdc958bc36538a433f0f43193 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:43:41 -0400 Subject: [PATCH 05/13] feat(opencode): pilot effect sqlite database service --- bun.lock | 1 + packages/opencode/package.json | 1 + packages/opencode/src/permission/index.ts | 15 +++--- packages/opencode/src/session/todo.ts | 48 +++++++++---------- packages/opencode/src/share/share-next.ts | 37 +++++++------- packages/opencode/src/storage/db-effect.ts | 12 +++++ .../opencode/test/permission/next.test.ts | 7 ++- packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + .../opencode/test/share/share-next.test.ts | 3 ++ 10 files changed, 78 insertions(+), 50 deletions(-) create mode 100644 packages/opencode/src/storage/db-effect.ts diff --git a/bun.lock b/bun.lock index 47c22ee220..2b2905ec7c 100644 --- a/bun.lock +++ b/bun.lock @@ -409,6 +409,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8c5aa34998..995b7db52c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -110,6 +110,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d93670709e..d0baa9b042 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -5,7 +5,7 @@ import { InstanceState } from "@/effect/instance-state" import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" -import { Database } from "@/storage/db" +import { DatabaseEffect } from "@/storage/db-effect" import { eq } from "drizzle-orm" import { zod } from "@/util/effect-zod" import * as Log from "@opencode-ai/core/util/log" @@ -153,14 +153,17 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service + const db = yield* DatabaseEffect.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), - ) + const rows = yield* db + .select() + .from(PermissionTable) + .where(eq(PermissionTable.project_id, ctx.project.id)) + .pipe(Effect.orDie) const state = { pending: new Map(), - approved: row?.data ?? [], + approved: rows[0]?.data ?? [], } yield* Effect.addFinalizer(() => @@ -319,6 +322,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set { return result } -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer: Layer.Layer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer)) export * as Permission from "." diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 32a8370464..d828bd9dbe 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -5,7 +5,7 @@ import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { Database } from "@/storage/db" +import { DatabaseEffect } from "@/storage/db-effect" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" import { TodoTable } from "./session.sql" @@ -42,34 +42,34 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service + const db = yield* DatabaseEffect.Service const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) { - yield* Effect.sync(() => - Database.transaction((db) => { - db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run() - if (input.todos.length === 0) return - db.insert(TodoTable) - .values( - input.todos.map((todo, position) => ({ - session_id: input.sessionID, - content: todo.content, - status: todo.status, - priority: todo.priority, - position, - })), - ) - .run() - }), - ) + yield* Effect.gen(function* () { + yield* db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)) + if (input.todos.length === 0) return + yield* db.insert(TodoTable).values( + input.todos.map((todo, position) => ({ + session_id: input.sessionID, + content: todo.content, + status: todo.status, + priority: todo.priority, + position, + })), + ) + }).pipe(db.withTransaction, Effect.orDie) + yield* bus.publish(Event.Updated, input) }) const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) { - const rows = yield* Effect.sync(() => - Database.use((db) => - db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(), - ), - ) + const rows = yield* db + .select() + .from(TodoTable) + .where(eq(TodoTable.session_id, sessionID)) + .orderBy(asc(TodoTable.position)) + .pipe(Effect.orDie) + return rows.map((row) => ({ content: row.content, status: row.status, @@ -81,6 +81,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer: Layer.Layer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(DatabaseEffect.layer)) export * as Todo from "./todo" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 384027436f..754dd817db 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -9,7 +9,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" -import { Database } from "@/storage/db" +import { DatabaseEffect } from "@/storage/db-effect" import { eq } from "drizzle-orm" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" @@ -76,9 +76,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ShareNext") {} -const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - function api(resource: string): Api { return { create: `/api/${resource}`, @@ -116,6 +113,7 @@ export const layer = Layer.effect( const httpOk = HttpClient.filterStatusOk(http) const provider = yield* Provider.Service const session = yield* Session.Service + const db = yield* DatabaseEffect.Service function sync(sessionID: SessionID, data: Data[]): Effect.Effect { return Effect.gen(function* () { @@ -226,9 +224,12 @@ export const layer = Layer.effect( }) const get = Effect.fnUntraced(function* (sessionID: SessionID) { - const row = yield* db((db) => - db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(), - ) + const rows = yield* db + .select() + .from(SessionShareTable) + .where(eq(SessionShareTable.session_id, sessionID)) + .pipe(Effect.orDie) + const row = rows[0] if (!row) return return { id: row.id, secret: row.secret, url: row.url } satisfies Share }) @@ -314,16 +315,13 @@ export const layer = Layer.effect( Effect.flatMap((r) => httpOk.execute(r)), Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)), ) - yield* db((db) => - db - .insert(SessionShareTable) - .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) - .onConflictDoUpdate({ - target: SessionShareTable.session_id, - set: { id: result.id, secret: result.secret, url: result.url }, - }) - .run(), - ) + yield* db + .insert(SessionShareTable) + .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) + .onConflictDoUpdate({ + target: SessionShareTable.session_id, + set: { id: result.id, secret: result.secret, url: result.url }, + }) const s = yield* InstanceState.get(state) s.shared.set(sessionID, result) yield* full(sessionID).pipe( @@ -355,7 +353,7 @@ export const layer = Layer.effect( Effect.flatMap((r) => httpOk.execute(r)), ) - yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) + yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)) s.shared.delete(sessionID) s.queue.delete(sessionID) }) @@ -364,13 +362,14 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( +export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Bus.layer), Layer.provide(Account.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(DatabaseEffect.layer), ) export * as ShareNext from "./share-next" diff --git a/packages/opencode/src/storage/db-effect.ts b/packages/opencode/src/storage/db-effect.ts new file mode 100644 index 0000000000..8cbf889279 --- /dev/null +++ b/packages/opencode/src/storage/db-effect.ts @@ -0,0 +1,12 @@ +import { Database } from "@/storage/db" +import * as StorageSchema from "@/storage/schema" +import { Context, Layer } from "effect" +import { drizzle, type EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite" + +const schema = { ...StorageSchema } + +export class Service extends Context.Service>()("@opencode/DatabaseEffect") {} + +export const layer = Layer.sync(Service, () => drizzle({ client: Database.Client().$client, schema })) + +export * as DatabaseEffect from "./db-effect" diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 1c3d6fc563..7a268f1473 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -8,6 +8,7 @@ import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" +import { DatabaseEffect } from "../../src/storage/db-effect" import { disposeAllInstances, provideInstance, @@ -19,7 +20,11 @@ import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" const bus = Bus.layer -const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(bus), Layer.provide(DatabaseEffect.layer)), + bus, + CrossSpawnSpawner.defaultLayer, +) const it = testEffect(env) afterEach(async () => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index a602c0c8d7..d6425e0811 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -39,6 +39,7 @@ import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" +import { DatabaseEffect } from "@/storage/db-effect" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Database from "../../src/storage/db" @@ -170,6 +171,7 @@ function makeHttp() { lsp, mcp, AppFileSystem.defaultLayer, + DatabaseEffect.layer, status, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index ab5a3ab7ed..2b83c62517 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -51,6 +51,7 @@ import { SessionStatus } from "../../src/session/status" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" +import { DatabaseEffect } from "@/storage/db-effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" @@ -120,6 +121,7 @@ function makeHttp() { lsp, mcp, AppFileSystem.defaultLayer, + DatabaseEffect.layer, status, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 14ecff7452..e630de6a61 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -15,6 +15,7 @@ import type { SessionID } from "../../src/session/schema" import { ShareNext } from "@/share/share-next" import { SessionShareTable } from "../../src/share/share.sql" import { Database } from "@/storage/db" +import { DatabaseEffect } from "@/storage/db-effect" import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" @@ -48,6 +49,7 @@ function live(client: HttpClient.HttpClient) { Layer.provide(http), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(DatabaseEffect.layer), ) } @@ -66,6 +68,7 @@ function wired(client: HttpClient.HttpClient) { Layer.provide(Config.defaultLayer), Layer.provide(http), Layer.provide(Provider.defaultLayer), + Layer.provide(DatabaseEffect.layer), ) } From 8e2c15214ed306ab7662e1f14ea3676a19d534a8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:57:34 -0400 Subject: [PATCH 06/13] fix(opencode): refresh effect sqlite client after reset --- packages/opencode/src/storage/db-effect.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/storage/db-effect.ts b/packages/opencode/src/storage/db-effect.ts index 8cbf889279..9eb223d7d8 100644 --- a/packages/opencode/src/storage/db-effect.ts +++ b/packages/opencode/src/storage/db-effect.ts @@ -7,6 +7,18 @@ const schema = { ...StorageSchema } export class Service extends Context.Service>()("@opencode/DatabaseEffect") {} -export const layer = Layer.sync(Service, () => drizzle({ client: Database.Client().$client, schema })) +export const layer = Layer.sync(Service, () => { + let current: EffectSQLiteDatabase | undefined + + return new Proxy({} as EffectSQLiteDatabase, { + get(_target, property) { + const client = Database.Client().$client + if (current?.$client !== client) current = drizzle({ client, schema }) + + const value = Reflect.get(current, property) + return typeof value === "function" ? value.bind(current) : value + }, + }) +}) export * as DatabaseEffect from "./db-effect" From aaa42cca07e2e905cf3c197cb0b663b92cedb200 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 11:03:23 -0400 Subject: [PATCH 07/13] fix(opencode): own effect sqlite lifecycle in layers --- packages/opencode/src/effect/app-runtime.ts | 5 +- .../opencode/src/effect/bootstrap-runtime.ts | 19 ++- packages/opencode/src/storage/db-effect.ts | 25 ++-- packages/opencode/src/storage/db.ts | 47 ++++++-- packages/opencode/src/util/lazy.ts | 2 +- .../opencode/test/share/share-next.test.ts | 111 +++++++++--------- 6 files changed, 129 insertions(+), 80 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index e8c8025ea3..02bf7a19a3 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -52,6 +52,7 @@ 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, @@ -127,5 +128,7 @@ export const AppRuntime: Runtime = { runCallback(effect) { return rt.runCallback(wrap(effect)) }, - dispose: () => rt.dispose(), + async dispose() { + await rt.dispose() + }, } diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 7f18538523..f5fa475364 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -10,6 +10,7 @@ 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" @@ -26,4 +27,20 @@ export const BootstrapLayer = Layer.mergeAll( Bus.defaultLayer, ).pipe(Layer.provide(Observability.layer)) -export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) +const rt = lazy(() => ManagedRuntime.make(BootstrapLayer, { memoMap })) +type Runtime = Pick, "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() + } + }, +} diff --git a/packages/opencode/src/storage/db-effect.ts b/packages/opencode/src/storage/db-effect.ts index 9eb223d7d8..bd24bc2d41 100644 --- a/packages/opencode/src/storage/db-effect.ts +++ b/packages/opencode/src/storage/db-effect.ts @@ -1,24 +1,21 @@ import { Database } from "@/storage/db" import * as StorageSchema from "@/storage/schema" -import { Context, Layer } from "effect" +import { Context, Effect, Layer } from "effect" import { drizzle, type EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite" const schema = { ...StorageSchema } export class Service extends Context.Service>()("@opencode/DatabaseEffect") {} -export const layer = Layer.sync(Service, () => { - let current: EffectSQLiteDatabase | undefined - - return new Proxy({} as EffectSQLiteDatabase, { - get(_target, property) { - const client = Database.Client().$client - if (current?.$client !== client) current = drizzle({ client, schema }) - - const value = Reflect.get(current, property) - return typeof value === "function" ? value.bind(current) : value - }, - }) -}) +export const layer = Layer.effect( + Service, + Effect.acquireRelease( + Effect.sync(() => { + const lease = Database.acquire() + return { lease, db: drizzle({ client: lease.client.$client, schema }) } + }), + (value) => Effect.sync(() => value.lease.release()), + ).pipe(Effect.map((value) => value.db)), +) export * as DatabaseEffect from "./db-effect" diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 06cb99f97f..dc686203ca 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -44,7 +44,7 @@ export const Path = iife(() => { export type Transaction = SQLiteTransaction<"sync", void> -type Client = SQLiteBunDatabase +export type Client = SQLiteBunDatabase type Journal = { sql: string; timestamp: number; name: string }[] @@ -88,7 +88,7 @@ function migrations(dir: string): Journal { return sql.sort((a, b) => a.timestamp - b.timestamp) } -export const Client = lazy(() => { +export function open() { log.info("opening database", { path: Path }) const db = init(Path) @@ -119,12 +119,42 @@ export const Client = lazy(() => { } return db -}) +} -export function close() { - if (!Client.loaded()) return - Client().$client.close() - Client.reset() +export const Client = lazy(open) + +let layerRefs = 0 +let layerOwner: Client | undefined + +export function acquire() { + const owner = Client.peek() === undefined + const client = Client() + if (owner) layerOwner = client + layerRefs++ + + let released = false + return { + client, + release() { + if (released) return + released = true + layerRefs-- + if (layerRefs === 0 && layerOwner === client) { + layerOwner = undefined + close(client) + } + }, + } +} + +export function close(client = Client.peek()) { + if (!client) return + client.$client.close() + if (Client.peek() === client) { + layerRefs = 0 + layerOwner = undefined + Client.reset() + } } export type TxOrDb = Transaction | Client @@ -140,7 +170,8 @@ export function use(callback: (trx: TxOrDb) => T): T { } catch (err) { if (err instanceof LocalContext.NotFound) { const effects: (() => void | Promise)[] = [] - const result = ctx.provide({ effects, tx: Client() }, () => callback(Client())) + const client = Client() + const result = ctx.provide({ effects, tx: client }, () => callback(client)) for (const effect of effects) effect() return result } diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index d9abf18a52..13a2ba09a2 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -15,6 +15,6 @@ export function lazy(fn: () => T) { } result.loaded = () => loaded - + result.peek = () => (loaded ? value : undefined) return result } diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index e630de6a61..1fa528498c 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -131,7 +131,7 @@ describe("ShareNext", () => { Effect.gen(function* () { yield* seed("https://control.example.com", "org-1") - const req = yield* ShareNext.Service.use((svc) => svc.request()).pipe(Effect.provide(live(none))) + const req = yield* ShareNext.Service.use((svc) => svc.request()) expect(req.api.create).toBe("/api/shares") expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync") @@ -142,33 +142,33 @@ describe("ShareNext", () => { authorization: "Bearer st_test_token", "x-org-id": "org-1", }) - }), + }).pipe(Effect.provide(wired(none))), ), ) it.live("create posts share, persists it, and returns the result", () => provideTmpdirInstance( - () => - Effect.gen(function* () { - const session = yield* Session.Service.use((svc) => svc.create({ title: "test" })) - const seen: HttpClientRequest.HttpClientRequest[] = [] - const client = HttpClient.make((req) => { - seen.push(req) - if (req.url.endsWith("/api/share")) { - return Effect.succeed( - json(req, { - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }), - ) - } - return Effect.succeed(json(req, { ok: true })) - }) + () => { + const seen: HttpClientRequest.HttpClientRequest[] = [] + const client = HttpClient.make((req) => { + seen.push(req) + if (req.url.endsWith("/api/share")) { + return Effect.succeed( + json(req, { + id: "shr_abc", + url: "https://legacy-share.example.com/share/abc", + secret: "sec_123", + }), + ) + } + return Effect.succeed(json(req, { ok: true })) + }) - const result = yield* ShareNext.Service.use((svc) => svc.create(session.id)).pipe( - Effect.provide(live(client)), - ) + return Effect.gen(function* () { + const sessions = yield* Session.Service + const shareNext = yield* ShareNext.Service + const session = yield* sessions.create({ title: "test" }) + const result = yield* shareNext.create(session.id) expect(result.id).toBe("shr_abc") expect(result.url).toBe("https://legacy-share.example.com/share/abc") @@ -182,60 +182,61 @@ describe("ShareNext", () => { expect(seen).toHaveLength(1) expect(seen[0].method).toBe("POST") expect(seen[0].url).toBe("https://legacy-share.example.com/api/share") - }), + }).pipe(Effect.provide(wired(client))) + }, { config: { enterprise: { url: "https://legacy-share.example.com" } } }, ), ) it.live("remove deletes the persisted share and calls the delete endpoint", () => provideTmpdirInstance( - () => - Effect.gen(function* () { - const session = yield* Session.Service.use((svc) => svc.create({ title: "test" })) - const seen: HttpClientRequest.HttpClientRequest[] = [] - const client = HttpClient.make((req) => { - seen.push(req) - if (req.method === "POST") { - return Effect.succeed( - json(req, { - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }), - ) - } - return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 }))) - }) - - yield* Effect.gen(function* () { - yield* ShareNext.Service.use((svc) => svc.create(session.id)) - yield* ShareNext.Service.use((svc) => svc.remove(session.id)) - }).pipe(Effect.provide(live(client))) + () => { + const seen: HttpClientRequest.HttpClientRequest[] = [] + const client = HttpClient.make((req) => { + seen.push(req) + if (req.method === "POST") { + return Effect.succeed( + json(req, { + id: "shr_abc", + url: "https://legacy-share.example.com/share/abc", + secret: "sec_123", + }), + ) + } + return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 }))) + }) + return Effect.gen(function* () { + const sessions = yield* Session.Service + const shareNext = yield* ShareNext.Service + const session = yield* sessions.create({ title: "test" }) + yield* shareNext.create(session.id) + yield* shareNext.remove(session.id) expect(share(session.id)).toBeUndefined() expect(seen.map((req) => [req.method, req.url])).toEqual([ ["POST", "https://legacy-share.example.com/api/share"], ["DELETE", "https://legacy-share.example.com/api/share/shr_abc"], ]) - }), + }).pipe(Effect.provide(wired(client))) + }, { config: { enterprise: { url: "https://legacy-share.example.com" } } }, ), ) it.live("create fails on a non-ok response and does not persist a share", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const session = yield* Session.Service.use((svc) => svc.create({ title: "test" })) - const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500))) + provideTmpdirInstance(() => { + const client = HttpClient.make((req) => Effect.succeed(json(req, { error: "bad" }, 500))) - const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(session.id))).pipe( - Effect.provide(live(client)), - ) + return Effect.gen(function* () { + const sessions = yield* Session.Service + const shareNext = yield* ShareNext.Service + const session = yield* sessions.create({ title: "test" }) + const exit = yield* Effect.exit(shareNext.create(session.id)) expect(Exit.isFailure(exit)).toBe(true) expect(share(session.id)).toBeUndefined() - }), - ), + }).pipe(Effect.provide(wired(client))) + }), ) it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () => From 322bb012575abbecb4ca7cb3cc9f92c148dd989c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 14:42:59 -0400 Subject: [PATCH 08/13] refactor(opencode): unify drizzle client through effect adapter The Effect adapter is now the only Drizzle wrapper over the bun:sqlite handle. Database.Client owns the lifecycle, DatabaseEffect.Service just exposes that handle. Removes acquire/release ref counting that was ceremony around the same module-level singleton. --- packages/opencode/src/storage/db-effect.ts | 21 ++++-------- packages/opencode/src/storage/db.bun.ts | 8 ++--- packages/opencode/src/storage/db.node.ts | 6 ++-- packages/opencode/src/storage/db.ts | 39 +++------------------- 4 files changed, 16 insertions(+), 58 deletions(-) diff --git a/packages/opencode/src/storage/db-effect.ts b/packages/opencode/src/storage/db-effect.ts index bd24bc2d41..2cdf21ff2d 100644 --- a/packages/opencode/src/storage/db-effect.ts +++ b/packages/opencode/src/storage/db-effect.ts @@ -1,21 +1,12 @@ import { Database } from "@/storage/db" -import * as StorageSchema from "@/storage/schema" import { Context, Effect, Layer } from "effect" -import { drizzle, type EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite" +import type { EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite" +import * as StorageSchema from "@/storage/schema" -const schema = { ...StorageSchema } +export class Service extends Context.Service>()( + "@opencode/DatabaseEffect", +) {} -export class Service extends Context.Service>()("@opencode/DatabaseEffect") {} - -export const layer = Layer.effect( - Service, - Effect.acquireRelease( - Effect.sync(() => { - const lease = Database.acquire() - return { lease, db: drizzle({ client: lease.client.$client, schema }) } - }), - (value) => Effect.sync(() => value.lease.release()), - ).pipe(Effect.map((value) => value.db)), -) +export const layer = Layer.effect(Service, Effect.sync(Database.Client)) export * as DatabaseEffect from "./db-effect" diff --git a/packages/opencode/src/storage/db.bun.ts b/packages/opencode/src/storage/db.bun.ts index fa6190925a..dfea27aa85 100644 --- a/packages/opencode/src/storage/db.bun.ts +++ b/packages/opencode/src/storage/db.bun.ts @@ -1,8 +1,6 @@ import { Database } from "bun:sqlite" -import { drizzle } from "drizzle-orm/bun-sqlite" +import { drizzle } from "@opencode-ai/effect-drizzle-sqlite" -export function init(path: string) { - const sqlite = new Database(path, { create: true }) - const db = drizzle({ client: sqlite }) - return db +export function init>(path: string, schema: TSchema) { + return drizzle({ client: new Database(path, { create: true }), schema }) } diff --git a/packages/opencode/src/storage/db.node.ts b/packages/opencode/src/storage/db.node.ts index 0dba8dcef3..8c6c67a130 100644 --- a/packages/opencode/src/storage/db.node.ts +++ b/packages/opencode/src/storage/db.node.ts @@ -1,8 +1,6 @@ import { DatabaseSync } from "node:sqlite" import { drizzle } from "drizzle-orm/node-sqlite" -export function init(path: string) { - const sqlite = new DatabaseSync(path) - const db = drizzle({ client: sqlite }) - return db +export function init>(path: string, schema: TSchema) { + return drizzle({ client: new DatabaseSync(path), schema }) } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index dc686203ca..daace68c2d 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -1,6 +1,4 @@ -import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" -import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" export * from "drizzle-orm" import { LocalContext } from "@/util/local-context" import { lazy } from "../util/lazy" @@ -14,6 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationChannel } from "@opencode-ai/core/installation/version" import { InstanceState } from "@/effect/instance-state" import { iife } from "@/util/iife" +import * as StorageSchema from "@/storage/schema" import { init } from "#db" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined @@ -42,9 +41,9 @@ export const Path = iife(() => { return getChannelPath() }) -export type Transaction = SQLiteTransaction<"sync", void> +export type Client = ReturnType -export type Client = SQLiteBunDatabase +export type Transaction = Parameters[0]>[0] type Journal = { sql: string; timestamp: number; name: string }[] @@ -91,7 +90,7 @@ function migrations(dir: string): Journal { export function open() { log.info("opening database", { path: Path }) - const db = init(Path) + const db = init(Path, StorageSchema) db.run("PRAGMA journal_mode = WAL") db.run("PRAGMA synchronous = NORMAL") @@ -123,38 +122,10 @@ export function open() { export const Client = lazy(open) -let layerRefs = 0 -let layerOwner: Client | undefined - -export function acquire() { - const owner = Client.peek() === undefined - const client = Client() - if (owner) layerOwner = client - layerRefs++ - - let released = false - return { - client, - release() { - if (released) return - released = true - layerRefs-- - if (layerRefs === 0 && layerOwner === client) { - layerOwner = undefined - close(client) - } - }, - } -} - export function close(client = Client.peek()) { if (!client) return client.$client.close() - if (Client.peek() === client) { - layerRefs = 0 - layerOwner = undefined - Client.reset() - } + if (Client.peek() === client) Client.reset() } export type TxOrDb = Transaction | Client From 46996e5a67a0a8467eae114deaf24f681319d5ff Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 16:40:34 -0400 Subject: [PATCH 09/13] 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. --- packages/effect-drizzle-sqlite/src/index.ts | 56 +++++++++++-------- packages/opencode/src/effect/app-runtime.ts | 9 +-- .../opencode/src/effect/bootstrap-runtime.ts | 19 +------ packages/opencode/src/storage/db-effect.ts | 6 ++ packages/opencode/src/storage/db.ts | 2 +- packages/opencode/test/fixture/db.ts | 6 ++ .../opencode/test/share/share-next.test.ts | 17 +----- 7 files changed, 51 insertions(+), 64 deletions(-) diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts index 33293b11d6..0ba5e92134 100644 --- a/packages/effect-drizzle-sqlite/src/index.ts +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -42,7 +42,6 @@ export type MakeConfig< TRelations extends AnyRelations = EmptyRelations, > = DrizzleConfig & { readonly client?: Database - readonly filename?: string } type EffectLikeQuery = { @@ -76,10 +75,23 @@ class TransactionFailure extends Error { } } +// These keys are Effect runtime internals (effect/internal/core.ts). They are +// not exported from the `effect` public API. We rely on them to make Drizzle +// query builders directly yieldable. If a future Effect version renames or +// removes them, the module-load assertion below fails loudly instead of +// failing silently with "Effect.evaluate: Not implemented" defects deep in +// the fiber executor. const EffectTypeId = "~effect/Effect" const EffectIdentifier = `${EffectTypeId}/identifier` const EffectEvaluate = `${EffectTypeId}/evaluate` +if (!(Effect.succeed(0) as unknown as Record)[EffectTypeId]) { + throw new Error( + "@opencode-ai/effect-drizzle-sqlite: Effect protocol keys are missing on Effect.succeed(0). " + + "The installed `effect` version is incompatible with this adapter.", + ) +} + const effectVariance = { _A: (value: unknown) => value, _E: (value: unknown) => value, @@ -151,26 +163,23 @@ const patchClass = (ctor: { readonly prototype: object }, asEffect: (self: A) }) } -const patchQueryBuilders = (() => { - let patched = false - return () => { - if (patched) return - patched = true - - patchClass(SQLitePreparedQuery, (query: PreparedLike) => fromSync(query, () => fromExecuteResult(query.execute()))) - patchClass(SQLiteSelectBase, (query: SelectLike) => fromSync(query, () => query.all())) - patchClass(SQLiteInsertBase, fromMutation) - patchClass(SQLiteUpdateBase, fromMutation) - patchClass(SQLiteDeleteBase, fromMutation) - patchClass(SQLiteRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) => - fromSync(query, () => query.executeRaw()), - ) - patchClass(SQLiteSyncRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) => - fromSync(query, () => query.executeRaw()), - ) - patchClass(SQLiteCountBuilder, fromCount) - } -})() +// `patchClass` is idempotent via `hasOwnProperty` check, so calling this +// repeatedly is cheap. Patches are applied to Drizzle prototypes globally and +// survive any Database close/reopen cycle. +const patchQueryBuilders = () => { + patchClass(SQLitePreparedQuery, (query: PreparedLike) => fromSync(query, () => fromExecuteResult(query.execute()))) + patchClass(SQLiteSelectBase, (query: SelectLike) => fromSync(query, () => query.all())) + patchClass(SQLiteInsertBase, fromMutation) + patchClass(SQLiteUpdateBase, fromMutation) + patchClass(SQLiteDeleteBase, fromMutation) + patchClass(SQLiteRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) => + fromSync(query, () => query.executeRaw()), + ) + patchClass(SQLiteSyncRelationalQuery, (query: EffectLikeQuery & { readonly executeRaw: () => unknown }) => + fromSync(query, () => query.executeRaw()), + ) + patchClass(SQLiteCountBuilder, fromCount) +} const attachTransaction = < TSchema extends Record = Record, @@ -227,11 +236,10 @@ export const make = < TRelations extends AnyRelations = EmptyRelations, >(config: MakeConfig = {}): EffectSQLiteDatabase => { patchQueryBuilders() - const { client, filename, ...drizzleConfig } = config return attachTransaction( drizzleBun({ - ...drizzleConfig, - client: client ?? new Database(filename ?? ":memory:"), + ...config, + client: config.client ?? new Database(":memory:"), }), ) } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 02bf7a19a3..50e3ac5ed9 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -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(), } diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index f5fa475364..7f18538523 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -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, "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 }) diff --git a/packages/opencode/src/storage/db-effect.ts b/packages/opencode/src/storage/db-effect.ts index 2cdf21ff2d..97afcd3b5d 100644 --- a/packages/opencode/src/storage/db-effect.ts +++ b/packages/opencode/src/storage/db-effect.ts @@ -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>()( "@opencode/DatabaseEffect", ) {} diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index daace68c2d..e06b1955ce 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -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 diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index 07b42d9946..c3415f913b 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -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() diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 1fa528498c..f5e005f1e9 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -40,19 +40,6 @@ const json = (req: Parameters[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))), ), ) From f948a1e3b024e1de3e157b4a5b27dcc3f365ec83 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 17:00:17 -0400 Subject: [PATCH 10/13] test(opencode): pin lifecycle invariants for DatabaseEffect + managed-runtime Adds regression tests for the two non-obvious invariants enforced by the Effect-Drizzle integration: - packages/opencode/test/storage/db-effect.test.ts pins that DatabaseEffect.layer rebuilds a fresh handle after Database.close + dispose, and demonstrates the shared-memoMap poisoning that resetDatabase prevents by disposing every DB-consuming runtime before closing the SQLite handle. - packages/opencode/test/effect/managed-runtime.test.ts pins makeManagedRuntime dispose semantics and the lazy.resetIf compare-and-reset guard so a rebuilt instance is never clobbered by a stale dispose. --- .../test/effect/managed-runtime.test.ts | 78 ++++++++++++ .../opencode/test/storage/db-effect.test.ts | 119 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 packages/opencode/test/effect/managed-runtime.test.ts create mode 100644 packages/opencode/test/storage/db-effect.test.ts diff --git a/packages/opencode/test/effect/managed-runtime.test.ts b/packages/opencode/test/effect/managed-runtime.test.ts new file mode 100644 index 0000000000..35ca382cc1 --- /dev/null +++ b/packages/opencode/test/effect/managed-runtime.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { makeManagedRuntime } from "@/effect/managed-runtime" +import { lazy } from "@/util/lazy" + +class Counter extends Context.Service()("@test/Counter") {} + +const layerWith = (value: number) => Layer.succeed(Counter, { value }) + +describe("makeManagedRuntime", () => { + test("disposing an unbuilt runtime is a no-op", async () => { + const rt = makeManagedRuntime(layerWith(0)) + expect(rt.peek()).toBeUndefined() + await rt.dispose() + expect(rt.peek()).toBeUndefined() + }) + + test("disposing rebuilds on next access", async () => { + const rt = makeManagedRuntime(layerWith(7)) + const first = rt() + const value = await first.runPromise( + Effect.gen(function* () { + return (yield* Counter).value + }), + ) + expect(value).toBe(7) + + await rt.dispose() + expect(rt.peek()).toBeUndefined() + + const second = rt() + expect(second).not.toBe(first) + expect(rt.peek()).toBe(second) + await rt.dispose() + }) + + test("dispose() does not clobber a runtime that was rebuilt mid-dispose", async () => { + // Simulates a race where dispose() ran on instance A, then someone + // invoked the lazy and got a fresh instance B before dispose() returned. + // The resetIf guard must leave instance B intact. + const rt = makeManagedRuntime(layerWith(1)) + const first = rt() + rt.reset() // force-eject the lazy + const second = rt() // build a new instance, distinct from first + expect(second).not.toBe(first) + + // Calling dispose() now should tear down `second` (the current value), + // not the orphaned `first`. + await rt.dispose() + expect(rt.peek()).toBeUndefined() + }) +}) + +describe("lazy.resetIf", () => { + test("resets when the value matches", () => { + const factory = lazy(() => ({})) + const value = factory() + expect(factory.peek()).toBe(value) + factory.resetIf(value) + expect(factory.peek()).toBeUndefined() + }) + + test("leaves the lazy intact when the value does not match", () => { + const factory = lazy(() => ({})) + const captured = factory() + factory.reset() + const fresh = factory() + expect(fresh).not.toBe(captured) + factory.resetIf(captured) + expect(factory.peek()).toBe(fresh) + }) + + test("is a no-op on an unloaded lazy", () => { + const factory = lazy(() => ({})) + factory.resetIf({} as never) + expect(factory.peek()).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/storage/db-effect.test.ts b/packages/opencode/test/storage/db-effect.test.ts new file mode 100644 index 0000000000..648eaaae9a --- /dev/null +++ b/packages/opencode/test/storage/db-effect.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, ManagedRuntime } from "effect" +import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { Database } from "@/storage/db" +import { DatabaseEffect } from "@/storage/db-effect" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + await resetDatabase() +}) + +describe("DatabaseEffect.layer", () => { + test("yields a working Service that round-trips a query", async () => { + const rt = ManagedRuntime.make(DatabaseEffect.layer) + try { + const value = await rt.runPromise( + Effect.gen(function* () { + const db = yield* DatabaseEffect.Service + return db.$client.prepare("SELECT 42 as n").get() as { n: number } + }), + ) + expect(value).toEqual({ n: 42 }) + } finally { + await rt.dispose() + } + }) + + test("rebuilds a fresh handle after Database.close + runtime dispose", async () => { + const rt1 = ManagedRuntime.make(DatabaseEffect.layer) + const first = await rt1.runPromise(Effect.sync(() => Database.Client().$client)) + expect(first.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) + + await rt1.dispose() + Database.close() + + const rt2 = ManagedRuntime.make(DatabaseEffect.layer) + try { + const second = await rt2.runPromise( + Effect.gen(function* () { + const db = yield* DatabaseEffect.Service + return db.$client + }), + ) + expect(second).not.toBe(first) + expect(second.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) + } finally { + await rt2.dispose() + } + }) +}) + +// Regression for the memoMap lifecycle bug. The shared layer memoMap caches +// every `DatabaseEffect.layer` build across every runtime built with +// `makeManagedRuntime`. If a runtime that consumed the layer is NOT disposed +// before `Database.close()`, the cached Service value (a Drizzle wrapper +// over a now-closed `bun:sqlite` handle) persists in the memoMap and any +// subsequent runtime that consumes the layer reuses it and operates on a +// closed handle. +// +// `test/fixture/db.ts:resetDatabase` disposes every module-scoped runtime +// before closing the DB to release the memoMap entries. The two tests below +// pin both halves of the invariant. +describe("DatabaseEffect.layer + shared memoMap lifecycle", () => { + test("disposing a runtime releases its memoMap entry so the next build sees a fresh DB handle", async () => { + const rt1 = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) + const captured = await rt1.runPromise(Effect.sync(() => Database.Client().$client)) + expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) + + await rt1.dispose() + Database.close() + + const rt2 = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) + try { + const fresh = await rt2.runPromise( + Effect.gen(function* () { + const db = yield* DatabaseEffect.Service + return db.$client + }), + ) + expect(fresh).not.toBe(captured) + expect(fresh.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) + } finally { + await rt2.dispose() + } + }) + + test("a stale runtime kept alive over Database.close poisons later memoMap consumers", async () => { + const stale = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) + const captured = await stale.runPromise( + Effect.gen(function* () { + const db = yield* DatabaseEffect.Service + return db.$client + }), + ) + expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) + + // Intentionally do NOT dispose `stale` before closing the DB. This is + // the shape of the bug `resetDatabase` guards against. + Database.close() + + const next = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) + try { + const seen = await next.runPromise( + Effect.gen(function* () { + const db = yield* DatabaseEffect.Service + return db.$client + }), + ) + // The memoMap returned the same stale handle because `stale` was + // never disposed. The underlying connection is closed, so any query + // on the handle throws. + expect(seen).toBe(captured) + expect(() => seen.prepare("SELECT 1 as n").get()).toThrow() + } finally { + await next.dispose() + await stale.dispose() + } + }) +}) From 25e546c837d60393eb658e71f65a262b1664fd3a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:05:16 -0400 Subject: [PATCH 11/13] refactor(effect-drizzle-sqlite): use Effectable prototype --- packages/effect-drizzle-sqlite/src/index.ts | 59 +++++---------------- 1 file changed, 14 insertions(+), 45 deletions(-) diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts index 0ba5e92134..57ffdecb7e 100644 --- a/packages/effect-drizzle-sqlite/src/index.ts +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -11,7 +11,7 @@ import type { PreparedQueryConfig, SQLiteSession, SQLiteTransaction, SQLiteTrans import { SQLitePreparedQuery } from "drizzle-orm/sqlite-core/session" import type { DrizzleConfig } from "drizzle-orm/utils" import { Cause, Effect, Exit, Schema } from "effect" -import { pipeArguments } from "effect/Pipeable" +import * as Effectable from "effect/Effectable" export class EffectDrizzleQueryError extends Schema.TaggedErrorClass()( "EffectDrizzleQueryError", @@ -22,7 +22,12 @@ export class EffectDrizzleQueryError extends Schema.TaggedErrorClass; readonly cause: unknown }) { + super(params) + Error.captureStackTrace?.(this, EffectDrizzleQueryError) } } @@ -75,29 +80,6 @@ class TransactionFailure extends Error { } } -// These keys are Effect runtime internals (effect/internal/core.ts). They are -// not exported from the `effect` public API. We rely on them to make Drizzle -// query builders directly yieldable. If a future Effect version renames or -// removes them, the module-load assertion below fails loudly instead of -// failing silently with "Effect.evaluate: Not implemented" defects deep in -// the fiber executor. -const EffectTypeId = "~effect/Effect" -const EffectIdentifier = `${EffectTypeId}/identifier` -const EffectEvaluate = `${EffectTypeId}/evaluate` - -if (!(Effect.succeed(0) as unknown as Record)[EffectTypeId]) { - throw new Error( - "@opencode-ai/effect-drizzle-sqlite: Effect protocol keys are missing on Effect.succeed(0). " + - "The installed `effect` version is incompatible with this adapter.", - ) -} - -const effectVariance = { - _A: (value: unknown) => value, - _E: (value: unknown) => value, - _R: (value: unknown) => value, -} - const queryInfo = (query: EffectLikeQuery | PreparedLike) => { const info = "getQuery" in query && typeof query.getQuery === "function" ? query.getQuery() : query.toSQL?.() return { @@ -130,26 +112,13 @@ const fromExecuteResult = (result: unknown) => { } const queryEffectProto = { - [EffectTypeId]: effectVariance, - pipe() { - return pipeArguments(this, arguments) - }, - [Symbol.iterator]() { - let done = false - const self = this - return { - next(value: unknown) { - if (done) return { done: true, value } - done = true - return { done: false, value: self } - }, - [Symbol.iterator]() { - return this - }, - } - }, - [EffectIdentifier]: "DrizzleSqliteQuery", - [EffectEvaluate](this: EffectLikeQuery) { + ...Effectable.Prototype & EffectLikeQuery>({ + label: "DrizzleSqliteQuery", + evaluate(this: EffectLikeQuery) { + return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()") + }, + }), + commit(this: EffectLikeQuery) { return this.asEffect?.() ?? Effect.die("Drizzle SQLite query is missing asEffect()") }, } From fd4887d45d2ac8965447222e69b8c52c3e9e928c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:13:28 -0400 Subject: [PATCH 12/13] refactor(effect-drizzle-sqlite): simplify single-row reads --- packages/effect-drizzle-sqlite/src/index.ts | 13 ++++++++++++- packages/effect-drizzle-sqlite/test/sqlite.test.ts | 11 ++++++++++- packages/opencode/src/permission/index.ts | 14 ++++++++------ packages/opencode/src/share/share-next.ts | 10 ++++------ 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/effect-drizzle-sqlite/src/index.ts b/packages/effect-drizzle-sqlite/src/index.ts index 57ffdecb7e..f8ffda9c9e 100644 --- a/packages/effect-drizzle-sqlite/src/index.ts +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -63,6 +63,10 @@ type SelectLike = EffectLikeQuery & { readonly all: () => A } +type GetLike = EffectLikeQuery & { + readonly get: () => A +} + type MutationLike = EffectLikeQuery & { readonly all: () => A readonly run: () => A @@ -104,6 +108,8 @@ const fromMutation = (query: MutationLike) => fromSync(query, () => (query.confi const fromCount = (query: CountLike) => fromSync(query, () => Number(query.session.values(query.sql)[0]?.[0] ?? 0)) +export const getOne = (query: GetLike) => fromSync(query, () => query.get()) + const fromExecuteResult = (result: unknown) => { if (result && typeof result === "object" && "sync" in result && typeof result.sync === "function") { return result.sync() @@ -155,6 +161,7 @@ const attachTransaction = < TRelations extends AnyRelations = EmptyRelations, >(db: SQLiteBunDatabase & { readonly $client: Database }): EffectSQLiteDatabase => { const txStack: Array> = [] + const bound = new WeakMap>() const current = () => txStack.at(-1) ?? db const runTransaction = (target: SQLiteBunDatabase | SQLiteTransaction<"sync", void, TSchema, TRelations>) => target.transaction.bind(target) as ( @@ -195,7 +202,11 @@ const attachTransaction = < const target = current() const value = Reflect.get(target, property) - return typeof value === "function" ? value.bind(target) : value + if (typeof value !== "function") return value + const methods = bound.get(target) ?? new Map() + bound.set(target, methods) + if (!methods.has(property)) methods.set(property, value.bind(target)) + return methods.get(property) }, }) as EffectSQLiteDatabase } diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts index 5c02c18c5f..b4c34e70af 100644 --- a/packages/effect-drizzle-sqlite/test/sqlite.test.ts +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -5,7 +5,7 @@ import { relations } from "drizzle-orm/_relations" import { drizzle as drizzleBun } from "drizzle-orm/bun-sqlite" import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" import { Cause, Effect, Exit } from "effect" -import { EffectDrizzleQueryError, make, type EffectSQLiteDatabase } from "../src" +import { EffectDrizzleQueryError, getOne, make, type EffectSQLiteDatabase } from "../src" const users = sqliteTable("users", { id: integer().primaryKey(), @@ -176,6 +176,15 @@ describe("effect drizzle sqlite", () => { }), ) + testEffect("supports single-row select effects", () => + Effect.gen(function* () { + yield* db.insert(users).values({ id: 1, name: "Ada" }) + + expect(yield* getOne(db.select().from(users).where(eq(users.id, 1)))).toEqual({ id: 1, name: "Ada" }) + expect(yield* getOne(db.select().from(users).where(eq(users.id, 2)))).toBeUndefined() + }), + ) + testEffect("nested pipeable transactions commit or roll back with the outer transaction", () => Effect.gen(function* () { yield* Effect.gen(function* () { diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d0baa9b042..daa73cb8fe 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -6,6 +6,7 @@ import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { DatabaseEffect } from "@/storage/db-effect" +import { getOne } from "@opencode-ai/effect-drizzle-sqlite" import { eq } from "drizzle-orm" import { zod } from "@/util/effect-zod" import * as Log from "@opencode-ai/core/util/log" @@ -156,14 +157,15 @@ export const layer = Layer.effect( const db = yield* DatabaseEffect.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { - const rows = yield* db - .select() - .from(PermissionTable) - .where(eq(PermissionTable.project_id, ctx.project.id)) - .pipe(Effect.orDie) + const row = yield* getOne( + db + .select() + .from(PermissionTable) + .where(eq(PermissionTable.project_id, ctx.project.id)), + ).pipe(Effect.orDie) const state = { pending: new Map(), - approved: rows[0]?.data ?? [], + approved: row?.data ?? [], } yield* Effect.addFinalizer(() => diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 754dd817db..d6f25b459a 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -10,6 +10,7 @@ import { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" import { DatabaseEffect } from "@/storage/db-effect" +import { getOne } from "@opencode-ai/effect-drizzle-sqlite" import { eq } from "drizzle-orm" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" @@ -224,12 +225,9 @@ export const layer = Layer.effect( }) const get = Effect.fnUntraced(function* (sessionID: SessionID) { - const rows = yield* db - .select() - .from(SessionShareTable) - .where(eq(SessionShareTable.session_id, sessionID)) - .pipe(Effect.orDie) - const row = rows[0] + const row = yield* getOne( + db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)), + ).pipe(Effect.orDie) if (!row) return return { id: row.id, secret: row.secret, url: row.url } satisfies Share }) From 68ff83a98ddacd992b0f026cd0dd00118eda9d90 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:23:03 -0400 Subject: [PATCH 13/13] refactor(opencode): simplify database effect reset lifecycle --- packages/opencode/src/effect/app-runtime.ts | 4 +- packages/opencode/src/storage/db-effect.ts | 18 +++-- packages/opencode/src/storage/db.ts | 12 +-- .../test/effect/managed-runtime.test.ts | 78 ------------------- packages/opencode/test/fixture/db.ts | 6 -- .../opencode/test/storage/db-effect.test.ts | 76 ++++-------------- 6 files changed, 33 insertions(+), 161 deletions(-) delete mode 100644 packages/opencode/test/effect/managed-runtime.test.ts diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 50e3ac5ed9..e8c8025ea3 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,6 +1,5 @@ -import { Layer } from "effect" +import { Layer, ManagedRuntime } 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" @@ -52,6 +51,7 @@ 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" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, diff --git a/packages/opencode/src/storage/db-effect.ts b/packages/opencode/src/storage/db-effect.ts index 97afcd3b5d..cb76beeefd 100644 --- a/packages/opencode/src/storage/db-effect.ts +++ b/packages/opencode/src/storage/db-effect.ts @@ -1,18 +1,20 @@ import { Database } from "@/storage/db" -import { Context, Effect, Layer } from "effect" +import { Context, 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>()( "@opencode/DatabaseEffect", ) {} -export const layer = Layer.effect(Service, Effect.sync(Database.Client)) +const client = new Proxy({} as EffectSQLiteDatabase, { + get(_target, property) { + const db = Database.Client() + const value = Reflect.get(db, property) + return typeof value === "function" ? value.bind(db) : value + }, +}) + +export const layer = Layer.succeed(Service, client) export * as DatabaseEffect from "./db-effect" diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index e06b1955ce..ca2b42a78e 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -48,9 +48,9 @@ export type Transaction = Parameters[0]>[0] type Journal = { sql: string; timestamp: number; name: string }[] // Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use. -const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void +const migrateFromJournal = migrate as unknown as (db: Client, entries: Journal) => void -function applyMigrations(db: SQLiteBunDatabase, entries: Journal) { +function applyMigrations(db: Client, entries: Journal) { migrateFromJournal(db, entries) } @@ -122,10 +122,10 @@ export function open() { export const Client = lazy(open) -export function close(client = Client.peek()) { - if (!client) return - client.$client.close() - Client.resetIf(client) +export function close() { + if (!Client.loaded()) return + Client().$client.close() + Client.reset() } export type TxOrDb = Transaction | Client diff --git a/packages/opencode/test/effect/managed-runtime.test.ts b/packages/opencode/test/effect/managed-runtime.test.ts deleted file mode 100644 index 35ca382cc1..0000000000 --- a/packages/opencode/test/effect/managed-runtime.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { makeManagedRuntime } from "@/effect/managed-runtime" -import { lazy } from "@/util/lazy" - -class Counter extends Context.Service()("@test/Counter") {} - -const layerWith = (value: number) => Layer.succeed(Counter, { value }) - -describe("makeManagedRuntime", () => { - test("disposing an unbuilt runtime is a no-op", async () => { - const rt = makeManagedRuntime(layerWith(0)) - expect(rt.peek()).toBeUndefined() - await rt.dispose() - expect(rt.peek()).toBeUndefined() - }) - - test("disposing rebuilds on next access", async () => { - const rt = makeManagedRuntime(layerWith(7)) - const first = rt() - const value = await first.runPromise( - Effect.gen(function* () { - return (yield* Counter).value - }), - ) - expect(value).toBe(7) - - await rt.dispose() - expect(rt.peek()).toBeUndefined() - - const second = rt() - expect(second).not.toBe(first) - expect(rt.peek()).toBe(second) - await rt.dispose() - }) - - test("dispose() does not clobber a runtime that was rebuilt mid-dispose", async () => { - // Simulates a race where dispose() ran on instance A, then someone - // invoked the lazy and got a fresh instance B before dispose() returned. - // The resetIf guard must leave instance B intact. - const rt = makeManagedRuntime(layerWith(1)) - const first = rt() - rt.reset() // force-eject the lazy - const second = rt() // build a new instance, distinct from first - expect(second).not.toBe(first) - - // Calling dispose() now should tear down `second` (the current value), - // not the orphaned `first`. - await rt.dispose() - expect(rt.peek()).toBeUndefined() - }) -}) - -describe("lazy.resetIf", () => { - test("resets when the value matches", () => { - const factory = lazy(() => ({})) - const value = factory() - expect(factory.peek()).toBe(value) - factory.resetIf(value) - expect(factory.peek()).toBeUndefined() - }) - - test("leaves the lazy intact when the value does not match", () => { - const factory = lazy(() => ({})) - const captured = factory() - factory.reset() - const fresh = factory() - expect(fresh).not.toBe(captured) - factory.resetIf(captured) - expect(factory.peek()).toBe(fresh) - }) - - test("is a no-op on an unloaded lazy", () => { - const factory = lazy(() => ({})) - factory.resetIf({} as never) - expect(factory.peek()).toBeUndefined() - }) -}) diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index c3415f913b..07b42d9946 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -2,12 +2,6 @@ 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() diff --git a/packages/opencode/test/storage/db-effect.test.ts b/packages/opencode/test/storage/db-effect.test.ts index 648eaaae9a..015786d5ce 100644 --- a/packages/opencode/test/storage/db-effect.test.ts +++ b/packages/opencode/test/storage/db-effect.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect, ManagedRuntime } from "effect" -import { memoMap } from "@opencode-ai/core/effect/memo-map" import { Database } from "@/storage/db" import { DatabaseEffect } from "@/storage/db-effect" import { resetDatabase } from "../fixture/db" @@ -25,17 +24,15 @@ describe("DatabaseEffect.layer", () => { } }) - test("rebuilds a fresh handle after Database.close + runtime dispose", async () => { - const rt1 = ManagedRuntime.make(DatabaseEffect.layer) - const first = await rt1.runPromise(Effect.sync(() => Database.Client().$client)) + test("service resolves a fresh handle after Database.close", async () => { + const rt = ManagedRuntime.make(DatabaseEffect.layer) + const first = await rt.runPromise(Effect.sync(() => Database.Client().$client)) expect(first.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) - await rt1.dispose() Database.close() - const rt2 = ManagedRuntime.make(DatabaseEffect.layer) try { - const second = await rt2.runPromise( + const second = await rt.runPromise( Effect.gen(function* () { const db = yield* DatabaseEffect.Service return db.$client @@ -44,34 +41,24 @@ describe("DatabaseEffect.layer", () => { expect(second).not.toBe(first) expect(second.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) } finally { - await rt2.dispose() + await rt.dispose() } }) -}) -// Regression for the memoMap lifecycle bug. The shared layer memoMap caches -// every `DatabaseEffect.layer` build across every runtime built with -// `makeManagedRuntime`. If a runtime that consumed the layer is NOT disposed -// before `Database.close()`, the cached Service value (a Drizzle wrapper -// over a now-closed `bun:sqlite` handle) persists in the memoMap and any -// subsequent runtime that consumes the layer reuses it and operates on a -// closed handle. -// -// `test/fixture/db.ts:resetDatabase` disposes every module-scoped runtime -// before closing the DB to release the memoMap entries. The two tests below -// pin both halves of the invariant. -describe("DatabaseEffect.layer + shared memoMap lifecycle", () => { - test("disposing a runtime releases its memoMap entry so the next build sees a fresh DB handle", async () => { - const rt1 = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) - const captured = await rt1.runPromise(Effect.sync(() => Database.Client().$client)) + test("a runtime kept alive over Database.close uses the refreshed handle", async () => { + const rt = ManagedRuntime.make(DatabaseEffect.layer) + const captured = await rt.runPromise( + Effect.gen(function* () { + const db = yield* DatabaseEffect.Service + return db.$client + }), + ) expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) - await rt1.dispose() Database.close() - const rt2 = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) try { - const fresh = await rt2.runPromise( + const fresh = await rt.runPromise( Effect.gen(function* () { const db = yield* DatabaseEffect.Service return db.$client @@ -80,40 +67,7 @@ describe("DatabaseEffect.layer + shared memoMap lifecycle", () => { expect(fresh).not.toBe(captured) expect(fresh.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) } finally { - await rt2.dispose() - } - }) - - test("a stale runtime kept alive over Database.close poisons later memoMap consumers", async () => { - const stale = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) - const captured = await stale.runPromise( - Effect.gen(function* () { - const db = yield* DatabaseEffect.Service - return db.$client - }), - ) - expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 }) - - // Intentionally do NOT dispose `stale` before closing the DB. This is - // the shape of the bug `resetDatabase` guards against. - Database.close() - - const next = ManagedRuntime.make(DatabaseEffect.layer, { memoMap }) - try { - const seen = await next.runPromise( - Effect.gen(function* () { - const db = yield* DatabaseEffect.Service - return db.$client - }), - ) - // The memoMap returned the same stale handle because `stale` was - // never disposed. The underlying connection is closed, so any query - // on the handle throws. - expect(seen).toBe(captured) - expect(() => seen.prepare("SELECT 1 as n").get()).toThrow() - } finally { - await next.dispose() - await stale.dispose() + await rt.dispose() } }) })