opencode 的 Effect Drizzle SQLite 适配层:vendoring@opencode-ai/effect-drizzle-sqlite包的设计与实现解析
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
opencode 仓库中除了 Drizzle ORM 常见的异步 Promise 适配路径外,还存在一个面向 Effect 生态的 SQLite 适配层:仓库在 specs/storage/effect-sqlite-package.md 中规划、并在 packages/effect-drizzle-sqlite 中落地了这个包。本文围绕这份规格文档展开,讲清楚三件事:为什么 opencode 要自己 vendor 一份 Drizzle Effect SQLite 适配器而不是等上游;这个包的公开接口(make/makeWithDefaults/DefaultServices/migrate)如何逐字对齐 Drizzle 的 adapter 命名;以及事务、回滚、嵌套 savepoint 与迁移表这些关键保证在源码与测试中是如何被实现的。读完本文,你可以在自己的 Bun/Node 项目中以同样的方式构建 "Effect + Drizzle + SQLite" 数据访问层,并理解 opencode 后续如何在其上实现存储包装层。
一、设计目标:一个纯粹的 Drizzle + Effect + SQLite 包
规格文档对包的目标定位非常明确:这是一个vendored 的 Drizzleeffect-sqlite适配层,不是 opencode 的存储抽象。文档原文要求(见 specs/storage/effect-sqlite-package.md):
- 包名固定为
@opencode-ai/effect-drizzle-sqlite,风格上对齐 packages/http-recorder 这类小型 workspace 包; packages/opencode会在内部消费它,但包本身必须保持通用——任何 opencode 的路径、迁移、表结构、事务钩子、post-commit 行为或领域语言都不允许进入这个包;- 公开表面应尽量贴近 Drizzle 的 Effect 适配器("Think of it as a vendored
drizzle-orm/effect-sqlitepackage surface, not as a new storage service API"),以便未来上游发布effect-sqlite后可以直接替换。
这一约束同样写进了包内的 packages/effect-drizzle-sqlite/AGENTS.md:运行时代码只允许依赖泛型的effect/unstable/sql/SqlClient,具体客户端(如@effect/sql-sqlite-bun)只应出现在测试与示例中,除非包有意提供驱动特定的 helper。
文档还特意解释了"为什么先做适配器包,而不是先做 SessionStorage 这类领域层":
SessionStorage是有用的领域接缝,但它回答不了核心的适配器问题——如何让 Drizzle SQLite 在本仓库中 Effect 原生化。
先把适配器 vendor 下来,opencode 就能在其上自建存储包装层,SessionStorage、MessageStorage、event store 与 projector 写入都可以共享同一套事务与迁移模型。这个决策把"适配器正确性"与"领域存储设计"两个问题解耦了。
二、包的目录结构与导出面
规格文档给出的包形态与最终落地的目录一致:
- packages/effect-drizzle-sqlite/package.json:声明了 4 个子导出——
.、./effect-sqlite、./effect-sqlite/migrator、./sqlite-core/effect; - packages/effect-drizzle-sqlite/src/index.ts:入口导出;
src/effect-sqlite/*:驱动、会话、迁移器;src/sqlite-core/effect/*:Effect 化的 SQLite 查询构建器(select.ts/insert.ts/update.ts/delete.ts/raw.ts等);- packages/effect-drizzle-sqlite/test/sqlite.test.ts:适配器级保证的集成测试。
入口文件的导出与文档中规划的 "Initial exports" 完全一致:
// packages/effect-drizzle-sqlite/src/index.ts export { EffectLogger } from "drizzle-orm/effect-core" export * from "./effect-sqlite/driver" export * from "./effect-sqlite/session" export { migrate } from "./effect-sqlite/migrator" export * as EffectDrizzleSqlite from "."从 package.json 的依赖可以看到,运行时只依赖drizzle-orm与effect两个 catalog 依赖;@effect/sql-sqlite-bun被放在 devDependencies 中——这正印证了 AGENTS.md 中"具体 SQLite 客户端只进测试/示例"的约定。测试脚本为bun test --timeout 30000 --only-failures,类型检查使用tsgo --noEmit。
三、公开表面:make、makeWithDefaults与DefaultServices
文档要求公开表面 "mirror Drizzle's Effect adapters",并给出示例用法。packages/effect-drizzle-sqlite/src/effect-sqlite/driver.ts 的实现与文档中的 "Public Surface" 小节逐点对应:
// driver.ts 关键部分 export class EffectSQLiteDatabase<TRelations extends AnyRelations = EmptyRelations> extends SQLiteEffectDatabase<EffectSQLiteQueryEffectHKT, EffectSQLiteRunResult, TRelations> { static override readonly [entityKind]: string = "EffectSQLiteDatabase" } export type EffectDrizzleSQLiteConfig<TRelations extends AnyRelations = EmptyRelations> = Omit< DrizzleConfig<Record<string, never>, TRelations>, "cache" | "logger" | "schema" > export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Default) export const make = Effect.fn("SQLiteDrizzle.make")(function* ( config: EffectDrizzleSQLiteConfig<TRelations> = {}, ) { const client = yield* SqlClient // 泛型 Effect SqlClient 服务 const cache = yield* EffectCache const logger = yield* EffectLogger const dialect = new SQLiteAsyncDialect() const session = new EffectSQLiteSession(client, dialect, relations, { logger, cache, useJitMappers: jitCompatCheck(config.jit), }) const db = new EffectSQLiteDatabase(dialect, session, relations) db.$client = client db.$cache.invalidate = cache.onMutate return db }) export const makeWithDefaults = (config = {}) => make(config).pipe(Effect.provide(DefaultServices))这里有几个值得注意的实现细节:
make只依赖泛型SqlClient。文档注释写明:"Drizzle only depends on the genericSqlClient; install and provide a compatible SQLite provider such as@effect/sql-sqlite-node,@effect/sql-sqlite-bun, or another package that exposesSqlClient." 这正是文档中"SQLite clients come from Effect layers such asSqliteClient.layer({ filename })"这一 API 模式的落点——包本身不绑定 Bun 或 Node 客户端,由消费方通过 Layer 注入。DefaultServices合并了EffectCache.Default与EffectLogger.Default,与文档中 "DefaultServicesshould provide Drizzle's default logger/cache services, same as Effect Postgres" 的说明一致。$cache.invalidate被接管为cache.onMutate:写操作发生后通过 Effect 缓存服务的onMutate失效缓存,而不是 no-op(基类SQLiteEffectDatabase的默认$cache.invalidate是Effect.void,见 db.ts)。- 配置类型
EffectDrizzleSQLiteConfig用Omit<DrizzleConfig<...>, "cache" | "logger" | "schema">裁掉了三个字段——logger 和 cache 改由 Layer 提供,schema 则通过 relations 参数传入,避免重复声明。
由此得到的标准用法(与文档 Public Surface 示例等价):
import { SqliteClient } from "@effect/sql-sqlite-bun" import * as Effect from "effect/Effect" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" const db = yield* EffectDrizzleSqlite.make({ relations }).pipe( Effect.provide(EffectDrizzleSqlite.DefaultServices), Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })), ) yield* db.select().from(users) yield* db.transaction( (tx) => Effect.gen(function* () { yield* tx.insert(users).values({ name: "Ada" }) }), { behavior: "immediate" }, )仓库自带的 examples/basic.ts 给出了完整的 "生产级" 写法:用Context.Service把数据库包成Database服务(Layer.effect(Database, makeDatabase).pipe(Layer.provide(sqliteLayer))),再在其上构建一个带领域错误类型(UserStoreError,用Schema.TaggedErrorClass定义)的UserStore服务,演示了migrate/create/rename(事务内两条写语句)/list四个操作,最后用Effect.runPromise(program.pipe(Effect.provide(UserStore.layer)))驱动整个程序。迁移目录则指向 examples/migrations/20240101000000_create_users/migration.sql。
四、会话层实现:Effect 化的查询、缓存与事务
文档 "Upstream References" 一节列出了 API 模式的来源:Drizzle Effect Postgres RC 的query-effect.ts、SQLite Effect 分支的up-migrations/effect-sqlite.ts、以及 Effect SQLite 客户端的SqliteClient.ts。包内源码按这些参考移植,核心在两个文件。
4.1 查询执行:EffectSQLiteSession
packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts 中的EffectSQLiteSession继承 Drizzle 的SQLiteEffectSession,把泛型SqlClient适配为 Drizzle SQLite 会话。关键的execute私有方法展示了三种执行路径如何映射到 Effect SQL 客户端:
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") { const statement = this.client.unsafe(query.sql, params) if (method === "values") return statement.values if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0])) return statement.withoutTransform }注意withoutTransform的用法:适配器要求拿到原始行数组,再自行做 Drizzle 的行映射(mapAllResult/mapGetResult),这样 JIT mapper(makeJitQueryMapper)与非 JIT 路径(mapResultRow)的行为和 Drizzle 官方实现保持一致。
prepareQuery/prepareRelationalQuery还透传了一个对缓存语义很重要的参数——this.isInTransaction():
private isInTransaction() { return Effect.serviceOption(this.client.transactionService).pipe( Effect.map((option) => option._tag === "Some") ) }也就是说,查询构建器本身就能感知自己是否处于事务上下文中。在 sqlite-core/effect/session.ts 的queryWithCache里,事务内的 select 会跳过缓存策略("select && cacheConfig?.enabled && (yield* this.isInTransaction)" 时直接执行查询)——事务内读未提交数据时命中缓存会读到脏数据,这个细节保证了事务语义与 Drizzle 上游一致。
错误处理也在这里收敛:所有底层失败都被包装成EffectDrizzleQueryError(携带query、params与cause),调用方可以在 Effect 的错误通道里拿到结构化的查询错误而不是字符串。
4.2 事务:begin/commit/ 嵌套 savepoint
withTransaction是整个包中最体现 "Effect-native" 的一段(session.ts#L118-L187),其结构可以拆解为:
- 不可中断外壳:
Effect.uninterruptibleMask((restore) => Effect.withFiber(...))——begin之后的整个窗口对外部 fiber 取消不可中断,只有交给用户的事务 body 用restore(effect)恢复可中断性; - 连接选择:若 fiber 上下文中已存在
client.transactionService(即嵌套事务),直接复用已有连接并取id + 1;否则Scope.provide(this.client.reserve, scope)预留新连接,reserve失败时先关闭 scope 再向上抛错; - SQL 语句选择:顶层(
id === 0)执行begin ${config?.behavior ?? "deferred"},嵌套层执行savepoint effect_sql_${id}; - 上下文注入:
Context.add(services, this.client.transactionService, [connection, id])把[连接, id]放入子 fiber 的服务上下文——这就是 4.1 中isInTransaction()与 "嵌套Database.use看到当前事务" 语义的底层机制; - 结局处理:
- 成功且顶层:
commit。源码中特别处理了一个 SQLite 怪癖——deferred 约束(如外键)在 commit 阶段失败时事务仍然打开,因此 commit 失败会补一条rollback(失败则吞掉)再抛出原错误; - 成功且嵌套:
release savepoint effect_sql_${id}; - 失败且顶层:
rollback; - 失败且嵌套:
rollback to savepoint effect_sql_${id}后再release;
- 成功且顶层:
- 资源回收:只有新开连接的路径才在
onExit时Scope.close(scope, exit),复用连接时 scope 为undefined不做处理。
transaction的签名则直接采用 Drizzle 的SQLiteTransactionConfig(即{ behavior: "deferred" | "immediate" | "exclusive" }),错误通道为E | SqlError。EffectSQLiteTransaction.rollback()返回EffectTransactionRollbackError,与 Drizzle 其他 Effect 适配器的"显式回滚"语义一致。
值得强调的是:文档在 "Opencode Adoption Notes" 里明确提醒,opencode 当前packages/opencode/src/storage/db.ts有两处非平凡语义(嵌套Database.use在Database.transaction内看到当前事务;Database.effect在事务内排队 post-commit 副作用、事务外立即执行),opencode 包装层要用 Effect 上下文(而非LocalContext)实现一个私有{ tx, afterCommit }事务上下文来保留这些行为,并且"不要移除这一行为——SyncEvent.run依赖事务可组合性与behavior: "immediate"保证顺序正确性"。本包提供嵌套 savepoint 机制(effect_sql_${id}命名空间),正好为将来从"复用外层 tx"演进到"显式 savepoint"留了接口。
五、迁移:migrate与__drizzle_migrations表
migrator.ts 只有 14 行:读取 Drizzle 标准迁移文件(readMigrationFiles),再交给 sqlite-core/effect/session.ts 的 Effect 版coreMigrate。核心迁移逻辑保证:
- 迁移表:默认
__drizzle_migrations(config.migrationsTable可覆盖),schema 为id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT; - 只跑未应用的迁移:用
getMigrationsToRun对比本地迁移(readMigrationFiles读到的 hash/folderMillis/name)与库中已记录迁移,差集为空直接返回——即"幂等"; - 原子应用:所有待跑迁移在
session.transaction((tx) => ...)内逐条执行tx.run(sql.raw(stmt)),每条迁移执行完立即向迁移表插入元数据行(hash、created_at、name、applied_at),任何一步失败整体回滚; init语义:config.init === true时用于"首次初始化"——库里已有迁移记录则报MigratorInitError(databaseMigrations),本地不止一条迁移则报localMigrations,否则只登记单条迁移而不执行 SQL。
migrate本身返回Effect值(Effect.fn("migrate")),在消费方的 Effect 程序里yield*即可,错误类型是 Drizzle 的迁移错误。examples/basic.ts 的UserStore.migrate展示了典型接法:EffectDrizzleSqlite.migrate(db, { migrationsFolder }).pipe(Effect.mapError(...))。
六、测试对"适配器级保证"的验证
规格文档 Migration Strategy 第 3 步列出了需要验证的 5 条适配器级保证,test/sqlite.test.ts 逐条覆盖(测试使用@effect/sql-sqlite-bun的SqliteClient.layer({ filename: ":memory:", disableWAL: true })与真实临时文件数据库):
| 文档要求的保证 | 对应测试 |
|---|---|
| 查询构建器是 yieldable 的 Effect 值 | selects rows through Effect-yieldable query builders:yield* db.select().from(users)与db.select({ id: users.id }).from(users).where(eq(users.name, "Ada")).get() |
transaction(..., { behavior: "immediate" })提交成功写入 | commits successful transactions |
| 失败事务回滚 | rolls back failed transactions(Effect.fail("boom")后表仍为空)与rolls back explicit transaction rollback(tx.rollback()触发EffectTransactionRollbackError) |
| 迁移只跑一次且有序 | runs migrations once and records migration metadata:同一migrationsFolder连续migrate两次,migrated_users只建表一次,__drizzle_migrations中恰好一行记录 |
| close finalizer 关闭底层数据库 | 通过每个测试的Effect.scoped+SqliteClient.layer作用域实现:Effect 程序结束时 layer 的 finalizer 关闭 SQLite 连接 |
测试还额外验证了两条更细的保证:
- 错误保真(
preserves failed transaction begin errors):用bun:sqlite打开同名文件并begin immediate制造锁竞争,adapter 的begin immediate失败必须以SqlError的LockTimeoutError(cause 信息含 "database is locked")形式暴露,而不是被吞掉或降级; returning与写路径校验(supports returning and rejects empty update sets):insert...returning/update...returning/delete...returning都返回正确的行对象,而db.update(users).set({ name: undefined })会抛出 "No values to set"——确认 Effect 化的 query builder 保留了 Drizzle 的输入校验行为。
七、opencode 的迁移策略与采纳注意事项
文档给出的迁移路线(Migration Strategy)共 8 步,其核心思想是先适配器、后领域层、最后迁移调用点:
- 建立
@opencode-ai/effect-drizzle-sqlite,用最小化的内存/文件 SQLite 测试 schema; - 从 Drizzle SQLite 分支移植适配器,保留上游命名与 API 形态;
- 测试上述 5 条适配器级保证;
- 把包加为
packages/opencode的依赖; - 把
packages/opencode/src/storage/db.ts改造成"适配器之上的薄兼容包装层 + opencode 专属事务/post-commit 上下文"; - 先保持现有调用点可用:
Database.Client()、Database.use(...)、Database.transaction(...)、Database.effect(...); - 兼容稳定后,再把调用点从 callback 风格的
Database.use迁移到直接 yield Effect Drizzle 查询; - 最后才在 opencode 存储包装层之上构建 session/message/project 等领域 store。
文档同时明确了边界:opencode 专属的 path/channel 选择留在packages/opencode;afterCommit在事件发布机制迁移之前保持 opencode 专属(默认答案是"是")。"Recommended First PR" 一节则要求首个 PR只做包、刻意无聊:加包、用极小测试 schema(不用 opencode 领域表)、证明查询/事务/迁移、不迁移packages/opencode本体——"在惊动 opencode 现有数据库运行时之前,先有一个专注的验证点"。
文档末尾的 Open Questions 记录了若干当时未定的决策(首个包目标@effect/sql-sqlite-bun还是 node、从 Drizzle 分支拷贝多少源码还是直接 import catalog 的drizzle-orm内部实现、上游发布effect-sqlite后的更新路径、兼容包装层是否临时保留同步返回类型等)。从当前仓库状态看,前两个问题已有答案:包运行时依赖泛型SqlClient,测试与示例选择了@effect/sql-sqlite-bun,并依赖 catalog 版drizzle-orm的公开与内部模块实现适配。
八、小结:这个包值得借鉴的三个设计点
从 specs/storage/effect-sqlite-package.md 到 packages/effect-drizzle-sqlite 的落地过程,有几条对"在大型仓库中引入 Effect 数据层"有直接参考价值的经验:
- 适配层与领域层严格分层:包内没有任何 opencode 领域概念,
EffectSQLiteDatabase只回答"如何让 Drizzle 查询成为 Effect 值",领域错误建模(如UserStoreError)留给消费方——这使得上游drizzle-orm/effect-sqlite发布后替换成本最低; - 嵌套事务用服务上下文而非全局状态表达:
Context.add(services, client.transactionService, [connection, id])让"我在哪个事务里"成为 Effect 环境的一部分,天然随 fiber 隔离,嵌套层用savepoint effect_sql_${id}精确回滚,且顶层 commit 对 SQLite deferred 约束失败做了 rollback 兜底; - 用测试固化适配器契约:5 条保证(yieldable 查询、immediate 提交、失败回滚、迁移幂等、scope 关闭)全部有对应测试用例,锁竞争场景还验证了错误类型不丢失——这正是后续把 opencode 存储包装层建立在该包之上时最有价值的"地基验收单"。
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考