diff --git a/bun.lock b/bun.lock index 25068f3d9a..2b2905ec7c 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", @@ -396,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:*", @@ -1572,6 +1586,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..f8ffda9c9e --- /dev/null +++ b/packages/effect-drizzle-sqlite/src/index.ts @@ -0,0 +1,246 @@ +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 { 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" +import * as Effectable from "effect/Effectable" + +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: ${this.params}` + } + + constructor(params: { readonly query: string; readonly params: ReadonlyArray; readonly cause: unknown }) { + super(params) + Error.captureStackTrace?.(this, EffectDrizzleQueryError) + } +} + +export type EffectSQLiteDatabase< + TSchema extends Record = Record, + TRelations extends AnyRelations = EmptyRelations, +> = SQLiteBunDatabase & { + readonly $client: Database + readonly withTransaction: ( + effect: Effect.Effect, + config?: SQLiteTransactionConfig, + ) => Effect.Effect +} + +export type MakeConfig< + TSchema extends Record = Record, + TRelations extends AnyRelations = EmptyRelations, +> = DrizzleConfig & { + readonly client?: Database +} + +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 GetLike = EffectLikeQuery & { + readonly get: () => A +} + +type MutationLike = EffectLikeQuery & { + readonly all: () => A + readonly run: () => A + readonly config?: { readonly returning?: unknown } +} + +type CountLike = EffectLikeQuery & { + readonly session: { readonly values: (sql: unknown) => unknown[][] } + readonly sql: unknown +} + +class TransactionFailure extends Error { + constructor(readonly effectCause: Cause.Cause) { + super("Effect transaction failed") + } +} + +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 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() + } + return result +} + +const queryEffectProto = { + ...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()") + }, +} + +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) + }, + }) +} + +// `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, + 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 ( + transaction: (tx: SQLiteTransaction<"sync", void, TSchema, TRelations>) => unknown, + config?: SQLiteTransactionConfig, + ) => 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 target = current() + const value = Reflect.get(target, property) + 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 +} + +export const make = < + TSchema extends Record = Record, + TRelations extends AnyRelations = EmptyRelations, +>(config: MakeConfig = {}): EffectSQLiteDatabase => { + patchQueryBuilders() + return attachTransaction( + drizzleBun({ + ...config, + client: config.client ?? new Database(":memory:"), + }), + ) +} + +export const drizzle = make + +declare module "drizzle-orm/query-promise" { + interface QueryPromise extends Effect.Effect { + 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 new file mode 100644 index 0000000000..b4c34e70af --- /dev/null +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -0,0 +1,246 @@ +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, getOne, 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", () => { + 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" }) + 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* 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( + 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) + expect(yield* db.select().from(users).orderBy(users.id)).toEqual([{ id: 1, name: "Ada" }]) + }), + ) + + 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" }) + expect(yield* db.$count(users)).toBe(1) + }).pipe(db.withTransaction) + + expect(yield* db.select().from(users)).toEqual([{ id: 2, name: "Grace" }]) + }), + ) + + 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("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* () { + 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" })) + + 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/*"] + } + ] + } +} 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..daa73cb8fe 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -5,7 +5,8 @@ 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 { 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" @@ -153,11 +154,15 @@ 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 row = yield* getOne( + db + .select() + .from(PermissionTable) + .where(eq(PermissionTable.project_id, ctx.project.id)), + ).pipe(Effect.orDie) const state = { pending: new Map(), approved: row?.data ?? [], @@ -319,6 +324,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..d6f25b459a 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -9,7 +9,8 @@ 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 { 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" @@ -76,9 +77,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 +114,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 +225,9 @@ 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 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 }) @@ -314,16 +313,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 +351,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 +360,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..cb76beeefd --- /dev/null +++ b/packages/opencode/src/storage/db-effect.ts @@ -0,0 +1,20 @@ +import { Database } from "@/storage/db" +import { Context, Layer } from "effect" +import type { EffectSQLiteDatabase } from "@opencode-ai/effect-drizzle-sqlite" +import * as StorageSchema from "@/storage/schema" + +export class Service extends Context.Service>()( + "@opencode/DatabaseEffect", +) {} + +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.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 06cb99f97f..ca2b42a78e 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,16 +41,16 @@ export const Path = iife(() => { return getChannelPath() }) -export type Transaction = SQLiteTransaction<"sync", void> +export type Client = ReturnType -type Client = SQLiteBunDatabase +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) } @@ -88,10 +87,10 @@ 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) + const db = init(Path, StorageSchema) db.run("PRAGMA journal_mode = WAL") db.run("PRAGMA synchronous = NORMAL") @@ -119,7 +118,9 @@ export const Client = lazy(() => { } return db -}) +} + +export const Client = lazy(open) export function close() { if (!Client.loaded()) return @@ -140,7 +141,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/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..f5e005f1e9 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" @@ -39,18 +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), - ) -} - function wired(client: HttpClient.HttpClient) { const http = Layer.succeed(HttpClient.HttpClient, client) return Layer.mergeAll( @@ -66,6 +55,7 @@ function wired(client: HttpClient.HttpClient) { Layer.provide(Config.defaultLayer), Layer.provide(http), Layer.provide(Provider.defaultLayer), + Layer.provide(DatabaseEffect.layer), ) } @@ -104,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" } } }, ), ) @@ -119,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))), ), ) @@ -128,7 +118,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") @@ -139,33 +129,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") @@ -179,60 +169,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", () => 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..015786d5ce --- /dev/null +++ b/packages/opencode/test/storage/db-effect.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, ManagedRuntime } from "effect" +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("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 }) + + Database.close() + + try { + const second = await rt.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 rt.dispose() + } + }) + + 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 }) + + Database.close() + + try { + const fresh = await rt.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 rt.dispose() + } + }) +})