From 89efce865d0965aa7291367b6cb1376d8970f029 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Apr 2026 22:06:24 -0400 Subject: [PATCH] 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/*"] + } + ] + } +}