news 2026/9/7 3:56:07

opencode 的 Effect Drizzle SQLite 适配层:vendoring `@opencode-ai/effect-drizzle-sqlite` 包的设计与实现解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
opencode 的 Effect Drizzle SQLite 适配层:vendoring `@opencode-ai/effect-drizzle-sqlite` 包的设计与实现解析

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 vendoreddrizzle-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 就能在其上自建存储包装层,SessionStorageMessageStorage、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-ormeffect两个 catalog 依赖;@effect/sql-sqlite-bun被放在 devDependencies 中——这正印证了 AGENTS.md 中"具体 SQLite 客户端只进测试/示例"的约定。测试脚本为bun test --timeout 30000 --only-failures,类型检查使用tsgo --noEmit

三、公开表面:makemakeWithDefaultsDefaultServices

文档要求公开表面 "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))

这里有几个值得注意的实现细节:

  1. 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 注入。
  2. DefaultServices合并了EffectCache.DefaultEffectLogger.Default,与文档中 "DefaultServicesshould provide Drizzle's default logger/cache services, same as Effect Postgres" 的说明一致。
  3. $cache.invalidate被接管为cache.onMutate:写操作发生后通过 Effect 缓存服务的onMutate失效缓存,而不是 no-op(基类SQLiteEffectDatabase的默认$cache.invalidateEffect.void,见 db.ts)。
  4. 配置类型EffectDrizzleSQLiteConfigOmit<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(携带queryparamscause),调用方可以在 Effect 的错误通道里拿到结构化的查询错误而不是字符串。

4.2 事务:begin/commit/ 嵌套 savepoint

withTransaction是整个包中最体现 "Effect-native" 的一段(session.ts#L118-L187),其结构可以拆解为:

  1. 不可中断外壳Effect.uninterruptibleMask((restore) => Effect.withFiber(...))——begin之后的整个窗口对外部 fiber 取消不可中断,只有交给用户的事务 body 用restore(effect)恢复可中断性;
  2. 连接选择:若 fiber 上下文中已存在client.transactionService(即嵌套事务),直接复用已有连接并取id + 1;否则Scope.provide(this.client.reserve, scope)预留新连接,reserve失败时先关闭 scope 再向上抛错;
  3. SQL 语句选择:顶层(id === 0)执行begin ${config?.behavior ?? "deferred"},嵌套层执行savepoint effect_sql_${id}
  4. 上下文注入Context.add(services, this.client.transactionService, [connection, id])[连接, id]放入子 fiber 的服务上下文——这就是 4.1 中isInTransaction()与 "嵌套Database.use看到当前事务" 语义的底层机制;
  5. 结局处理
    • 成功且顶层:commit。源码中特别处理了一个 SQLite 怪癖——deferred 约束(如外键)在 commit 阶段失败时事务仍然打开,因此 commit 失败会补一条rollback(失败则吞掉)再抛出原错误;
    • 成功且嵌套:release savepoint effect_sql_${id}
    • 失败且顶层:rollback
    • 失败且嵌套:rollback to savepoint effect_sql_${id}后再release
  6. 资源回收:只有新开连接的路径才在onExitScope.close(scope, exit),复用连接时 scope 为undefined不做处理。

transaction的签名则直接采用 Drizzle 的SQLiteTransactionConfig(即{ behavior: "deferred" | "immediate" | "exclusive" }),错误通道为E | SqlErrorEffectSQLiteTransaction.rollback()返回EffectTransactionRollbackError,与 Drizzle 其他 Effect 适配器的"显式回滚"语义一致。

值得强调的是:文档在 "Opencode Adoption Notes" 里明确提醒,opencode 当前packages/opencode/src/storage/db.ts有两处非平凡语义(嵌套Database.useDatabase.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_migrationsconfig.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)),每条迁移执行完立即向迁移表插入元数据行(hashcreated_atnameapplied_at),任何一步失败整体回滚;
  • init语义config.init === true时用于"首次初始化"——库里已有迁移记录则报MigratorInitErrordatabaseMigrations),本地不止一条迁移则报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-bunSqliteClient.layer({ filename: ":memory:", disableWAL: true })与真实临时文件数据库):

文档要求的保证对应测试
查询构建器是 yieldable 的 Effect 值selects rows through Effect-yieldable query buildersyield* 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 transactionsEffect.fail("boom")后表仍为空)与rolls back explicit transaction rollbacktx.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失败必须以SqlErrorLockTimeoutError(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 步,其核心思想是先适配器、后领域层、最后迁移调用点

  1. 建立@opencode-ai/effect-drizzle-sqlite,用最小化的内存/文件 SQLite 测试 schema;
  2. 从 Drizzle SQLite 分支移植适配器,保留上游命名与 API 形态;
  3. 测试上述 5 条适配器级保证;
  4. 把包加为packages/opencode的依赖;
  5. packages/opencode/src/storage/db.ts改造成"适配器之上的薄兼容包装层 + opencode 专属事务/post-commit 上下文";
  6. 先保持现有调用点可用:Database.Client()Database.use(...)Database.transaction(...)Database.effect(...)
  7. 兼容稳定后,再把调用点从 callback 风格的Database.use迁移到直接 yield Effect Drizzle 查询;
  8. 最后才在 opencode 存储包装层之上构建 session/message/project 等领域 store。

文档同时明确了边界:opencode 专属的 path/channel 选择留在packages/opencodeafterCommit在事件发布机制迁移之前保持 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 数据层"有直接参考价值的经验:

  1. 适配层与领域层严格分层:包内没有任何 opencode 领域概念,EffectSQLiteDatabase只回答"如何让 Drizzle 查询成为 Effect 值",领域错误建模(如UserStoreError)留给消费方——这使得上游drizzle-orm/effect-sqlite发布后替换成本最低;
  2. 嵌套事务用服务上下文而非全局状态表达Context.add(services, client.transactionService, [connection, id])让"我在哪个事务里"成为 Effect 环境的一部分,天然随 fiber 隔离,嵌套层用savepoint effect_sql_${id}精确回滚,且顶层 commit 对 SQLite deferred 约束失败做了 rollback 兜底;
  3. 用测试固化适配器契约:5 条保证(yieldable 查询、immediate 提交、失败回滚、迁移幂等、scope 关闭)全部有对应测试用例,锁竞争场景还验证了错误类型不丢失——这正是后续把 opencode 存储包装层建立在该包之上时最有价值的"地基验收单"。

【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 3:55:46

现在专业的AI论文平台有哪些品牌?选对工具少走弯路

每到期末、毕业答辩、课题申报阶段&#xff0c;很多学生都会深陷论文难题&#xff1a;选题毫无头绪、大纲搭建逻辑混乱、正文撰写耗时长、参考文献格式出错、查重重复率偏高、AIGC检测告警、本校论文排版标准复杂。依靠纯人工从零开始撰写、一遍遍修改格式和降重&#xff0c;常…

作者头像 李华
网站建设 2026/9/7 3:53:50

动力电池CCS设计全流程解析:母排、FPC与采样连接的关键技术

在动力电池模组设计中&#xff0c;CCS&#xff08;Cell Contact System&#xff0c;电芯连接系统&#xff09;承担的不只是导电。它是电芯与BMS之间的桥梁&#xff0c;既要把几十安培甚至几百安培的电流在电芯极柱之间可靠传递&#xff0c;又要把每一串电芯的电压和温度信号准确…

作者头像 李华
网站建设 2026/9/7 3:53:15

MicroPython操作RP2040 DMA实现内存到内存数据传输保姆级教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 3:52:41

分清两种OOK:Brainfuck/Ook!解码脚本与无线波形0和1的判断

简介&#xff1a;Brainfuck及其变体Ook是CTF赛事中常见的极简编程语言&#xff0c;选手常需将其解码为可读文本&#xff0c;但比赛环境经常无法访问在线解码网站。这套离线解码工具正是为解决这一痛点而设计&#xff0c;主要面向CTF参赛者、安全竞赛选手以及对异种编程语言有兴…

作者头像 李华
网站建设 2026/9/7 3:48:54

SQL LIKE 模糊查询全解析:从通配符、性能优化到防注入最佳实践

很多开发者第一次接触 LIKE&#xff0c;是在写“模糊查询”的时候&#xff1a;输入一个关键词&#xff0c;把包含它的记录全部捞出来。这个需求太常见了&#xff0c;常见到我们几乎不会停下来想一个问题——LIKE 真的是实现模糊匹配的最好方案吗&#xff1f;它有哪些容易踩的坑…

作者头像 李华