@effect/sql-sqlite-bun 4.0 变更全解析:从并发锁策略到结构化错误分类的演进
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
本篇指南以@effect/sql-sqlite-bun包的 CHANGELOG.md 为骨架,梳理该 Effect SQL SQLite 客户端在 v4 预发布周期内(4.0.0-beta.0至4.0.0-rc.112)的关键行为变更,并结合 SqliteClient.ts、SqliteMigrator.ts、Client.test.ts 与 SqlError.ts 的源码,深入讲解每个变更背后的实现原理与工程动机。读完你将掌握:Bun 运行时下 Effect SQL 客户端的并发与事务策略、只读模式与超时参数的实际取值逻辑、结构化SqlError分类体系的演进路径,以及迁移模块的使用方式。
一、包定位:Bun 运行时上的 Effect SQL 客户端
@effect/sql-sqlite-bun是 Effect 官方 SQL 生态中专用于Bun 运行时的 SQLite 适配层,底层直接构建在bun:sqlite之上。其 package.json 中的描述为 "A SQLite toolkit for Effect",声明了对effect的 peer 依赖,版本号与主库effect保持同步(当前仓库锁定的主库版本为effect@4.0.0-rc.112,见根目录 pnpm-workspace.yaml)。
从 src/index.ts 可以看到,包只导出两个命名空间模块:
SqliteClient:核心客户端,负责打开数据库、执行语句、事务管理与错误分类;SqliteMigrator:迁移执行器,复用 Effect 共享的迁移机制。
该模块头部的文档注释(SqliteClient.ts 第 1-15 行)概括了它的核心契约:串行化数据库访问、默认启用 WAL 模式、默认等待忙库最多五秒、显式事务使用BEGIN IMMEDIATE,并明确指出流式查询(streaming)与updateValues不受支持。这些能力与限制,正是 CHANGELOG 中多条变更的落点。
二、版本谱系:从 beta.0 到 rc.112 的演进脉络
CHANGELOG 完整记录了该包自4.0.0-beta.0(对应 PR #1183 "v4 beta")以来的全部发布记录,共 100 余个预发布版本。版本号分两段演进:
| 阶段 | 版本区间 | 性质 |
|---|---|---|
| Beta 阶段 | 4.0.0-beta.0→4.0.0-beta.107 | 功能开发与破坏性变更(Major Changes)集中发生 |
| RC 阶段 | 4.0.0-rc.108→4.0.0-rc.112 | 收敛期,以依赖同步与补丁修复为主 |
值得注意的是,绝大多数条目是 "Updated dependencies" 形式的依赖升级记录——这是 changesets 自动生成的产物,代表effect主库的每次迭代都会带动全部 SQL 适配包同步发版。真正体现@effect/sql-sqlite-bun自身行为变化的,是其中少数带有 PR 链接的Patch Changes说明,它们是本文接下来重点剖析的对象。
三、并发与事务策略:五秒忙等待与立即事务(beta.107)
4.0.0-beta.107版本引入了一条对本包行为影响最深的变更(PR #7162):
Use a configurable five-second busy timeout and immediate transactions by default to avoid SQLite lock failures under concurrent access. Busy waits can block the event loop, while immediate transactions serialize behind other writers.
这句话浓缩了两个核心决策,下面结合源码逐一验证。
3.1 可配置的五秒忙等待超时
在 SqliteClient.ts 第 138-142 行,忙等待超时通过以下公式计算并写入PRAGMA busy_timeout:
const busyTimeout = Math.min( MAX_BUSY_TIMEOUT, Math.max(0, Math.round(Duration.toMillis(options.busyTimeout ?? Duration.seconds(5)))) ) db.run(`PRAGMA busy_timeout = ${busyTimeout};`)其中MAX_BUSY_TIMEOUT = 2_147_483_647(第 34 行),这是 SQLite 内部接受的最大毫秒数。也就是说:
- 默认值:未配置时取
Duration.seconds(5),即 5000ms; - 最小值下限:
Math.max(0, ...)保证非负; - 最大值上限:
Duration.infinity会被钳制到MAX_BUSY_TIMEOUT,即约 24.8 天,避免溢出; - 可配置项:对应
SqliteClientConfig.busyTimeout?: Duration.Input(第 100 行)。
这些行为在 Client.test.ts 第 11-22 行有直接断言:
// 默认 5 秒 assert.deepStrictEqual(yield* sql`PRAGMA busy_timeout`, [{ timeout: 5000 }]) // 自定义 1 秒 const custom = yield* SqliteClient.make({ filename: ":memory:", busyTimeout: "1 second" }) assert.deepStrictEqual(yield* custom`PRAGMA busy_timeout`, [{ timeout: 1000 }]) // infinity 被钳制到 SQLite 最大值 const infinite = yield* SqliteClient.make({ filename: ":memory:", busyTimeout: Duration.infinity }) assert.deepStrictEqual(yield* infinite`PRAGMA busy_timeout`, [{ timeout: 2_147_483_647 }])CHANGELOG 中 "Busy waits can block the event loop" 的警告是真实存在的:bun:sqlite是同步 API,忙等待期间事件循环会被阻塞。这是选用此适配层时必须接受的运行时特性,也是为什么文档建议在高并发写场景下评估其他运行时方案。
3.2 立即事务(BEGIN IMMEDIATE)
同一变更将显式事务的开启语句从延迟事务改为立即事务。在 SqliteClient.ts 第 238 行,创建客户端时传入了:
beginTransaction: "BEGIN IMMEDIATE",普通BEGIN(延迟事务)在首次读时才尝试获取读锁,一旦后续要写,需要把读锁升级为写锁,而 SQLite 不允许就地升级,会直接返回SQLITE_BUSY。BEGIN IMMEDIATE则在事务开始时就获取写锁,将事务串行化排在其它写入者之后——即使事务内部只做读操作也是如此。CHANGELOG 原文 "immediate transactions serialize behind other writers" 指的就是这一行为。唯一的例外是readonly: true打开的客户端不受影响(见下一节)。
Client.test.ts 第 24-45 行用两个客户端竞争验证了这一语义:客户端client持有事务时,竞争者contender执行BEGIN IMMEDIATE会失败并抛出_tag === "SqlError"、cause 匹配/database is locked/i的错误。
四、只读模式强制化与写入拒绝(beta.104)
4.0.0-beta.104版本的变更(PR #6993)非常简短:
Enforce read-only mode when opening Bun SQLite databases.
对照源码,它在 SqliteClient.ts 第 130-136 行的数据库打开逻辑中落地:
const readonly = options.readonly === true const db = new Database(options.filename, { readonly, readwrite: readonly ? false : options.readwrite ?? true, create: readonly ? false : options.create ?? true } as any)解读这一实现:
readonly: true时,readwrite与create被强制设为false,即不能创建文件、不能以读写模式打开;- 默认(非只读)时,
readwrite与create均默认true,与 SQLite 常规语义一致; - 只读模式下WAL 不会被启用(第 144 行
if (options.disableWAL !== true && !readonly)),因为开启 WAL 需要写权限。
测试 Client.test.ts 第 47-71 行验证:先用可写客户端建表,再用readonly: true打开同一文件,读与事务内读均正常返回空结果,而INSERT会失败为SqlError,cause 匹配/attempt to write a readonly database/i。
五、错误模型演进:从缺陷到结构化 SqlError
CHANGELOG 中关于错误处理有三条相互关联的变更,构成一条清晰的演进线索:
5.1 语句准备失败转为类型化错误(beta.88)
4.0.0-beta.88(PR #2399):
Fail with a typed
SqlErrorwhen Bun SQLite statement preparation throws (for example a missing table or a syntax error), instead of letting the driver error escape as a defect, closes #2385.
此前的实现中,db.query(sql)准备语句时抛出的原生异常可能以 defect(未捕获缺陷)形式逃逸,无法被 Effect 的错误通道处理。变更后,所有执行路径都包裹在try/catch中并通过Effect.fail(new SqlError(...))返回。
看 SqliteClient.ts 第 155-181 行的run与runValues两个核心函数,这一模式非常清晰:
try { return Effect.succeed((prepare(sql, useSafeIntegers).all(...(params as any)) ?? []) as Array<any>) } catch (cause) { return Effect.fail(new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })) }错误分类由classifySqliteError完成(第 36-37 行),它定义在 Effect 共享层 SqlError.ts 中。同一模式还用于export(db.serialize())与loadExtension(db.loadExtension(path))两条路径(第 204-213 行)。
5.2 基于 reason 的错误形状统一(beta.37)
4.0.0-beta.37(PR #1812):
Consolidate the SqlError changes to the new reason-based shape across effect and the SQL drivers, classifying native failures into structured reasons with Unknown fallback where native codes are unavailable.
这一步把错误模型统一为 "reason-based" 结构:SqlError是外层包装,内部通过reason字段承载分类结果。从 SqlError.ts 的导出可以看到完整的 reason 类型族(测试 SqlError.test.ts 第 24-40 行逐一验证了每个类型的 tag 与可重试性):
| Reason 类型 | _tag | isRetryable | 含义 |
|---|---|---|---|
ConnectionError | ConnectionError | true | 连接/打开失败 |
AuthenticationError | AuthenticationError | false | 认证失败 |
AuthorizationError | AuthorizationError | false | 授权/权限失败 |
SqlSyntaxError | SqlSyntaxError | false | SQL 语法错误 |
UniqueViolation | UniqueViolation | false | 唯一约束冲突(带 constraint 字段) |
ConstraintError | ConstraintError | false | 其他约束违规 |
DeadlockError | DeadlockError | true | 死锁 |
SerializationError | SerializationError | true | 序列化失败 |
LockTimeoutError | LockTimeoutError | true | 锁等待超时 |
StatementTimeoutError | StatementTimeoutError | true | 语句执行超时 |
UnknownError | UnknownError | false | 原生错误码无法归类时的兜底 |
isRetryable是这套模型的工程价值所在:并发类错误(连接、死锁、序列化、锁超时、语句超时)标记为可重试,配合 Effect 的重试机制可以安全地自动恢复;而语法、认证、授权、约束类错误重试无意义,直接暴露给调用方。
5.3 新增 UniqueViolation reason(beta.65)
4.0.0-beta.65(PR #2148):
Add
UniqueViolationas a new SQL error reason. Supported unique constraint violations now classify asUniqueViolationinstead of the broaderConstraintErrorreason...UniqueViolation.constraintcontains the best available constraint, index, or key identifier and falls back to exactly"unknown"when no reliable identifier is available.
这是对 5.2 分类体系的细化:将唯一约束冲突从宽泛的ConstraintError中拆分出来。其结构定义在 SqlError.ts 第 133-136 行,在通用ReasonFields(cause、message、operation)之外增加了constraint: Schema.String字段。CHANGELOG 明确该分类覆盖 PostgreSQL、PGlite、MySQL、MSSQL 以及 SQLite 系列客户端共享的 SQLite 分类逻辑;当原生错误无法提供可靠标识符时,constraint精确回退为字符串"unknown"。测试 SqlError.test.ts 第 113-120 行构造了code: "23505"(PostgreSQL 唯一约束冲突码)的用例来验证该 reason 的识别。
六、API 演进:移除 index 入口与新增 unprepared 取值
除了错误模型,CHANGELOG 还记录了两条 API 层面的变更:
4.0.0-beta.103(PR #6701):Removed explicit ./index entrypoints。这一变更在 package.json 的exports字段中有直接对应——"./index": null与"./*/index": null显式禁用了 index 入口,发布产物只暴露"."与"./*"两级路径。4.0.0-beta.86(PR #2462):AddStatement.valuesUnpreparedfor returning unprepared SQL statement rows as arrays。这一能力落地在连接层,对应 SqliteClient.ts 第 195-197 行的executeValuesUnprepared方法,内部复用runValues(走statement.values(),以数组形式返回行),因此也继承了一致的SqlError分类与SafeIntegers处理。
七、迁移模块:复用共享机制执行 SQL 迁移
SqliteMigrator模块(SqliteMigrator.ts)虽然不在 CHANGELOG 的明细变更列表中,但它是该包开箱即用的组成模块,值得一并说明:
- 第 20 行
export * from "effect/unstable/sql/Migrator"直接转发共享的迁移加载器与错误类型; run(options)(第 28-36 行)基于Migrator.make(...)构造,使用当前SqlClient执行待应用的迁移文件,返回ReadonlyArray<[id, name]>(已应用迁移的编号与名称),失败类型为MigrationError | SqlError;layer(options)(第 84-87 行)在 Layer 构造期间执行迁移(Layer.effectDiscard(run(options))),适合在应用启动时通过依赖注入自动完成建表等操作。
源码中保留了被注释掉的 Bun 特定 schema dump 实现(第 35-75 行),注释明确说明当前"不提供 Bun 特定的 schema dump 支持",迁移执行完全依赖共享的 SQL migrator——这也解释了为什么该模块如此轻量。
八、在 t3code 仓库中的上下文与使用方式
该包作为参考仓库effect-smol的一部分被纳入 t3code 仓库的.repos/目录(完整路径 .repos/effect-smol/packages/sql/sqlite-bun),用于对照参考 Effect 官方 SQL 生态的实现。其 peer 依赖版本effect@4.0.0-rc.112与根仓库 pnpm-workspace.yaml 中 catalog 锁定的effect: 4.0.0-rc.112完全一致,说明两者处于同一 Effect v4 RC 版本线。
如需在 Bun 项目中安装使用该客户端,README(README.md)给出的命令是:
npm install effect@rc @effect/sql-sqlite-bun@rc最小使用模式(基于 SqliteClient.ts 的公开 API 与 Client.test.ts 的用法):
import { Effect } from "effect" import { Reactivity } from "effect/unstable/reactivity" import { SqliteClient } from "@effect/sql-sqlite-bun" const program = Effect.gen(function*() { const sql = yield* SqliteClient.make({ filename: "app.db" }) const rows = yield* sql`SELECT * FROM users` return rows }) program.pipe(Effect.provide(Reactivity.layer))几点取自源码的实际使用提示:
make需要Scope与Reactivity环境(第 121 行类型签名),测试中统一通过Effect.provide(Reactivity.layer)满足后者;连接关闭通过Effect.addFinalizer(() => Effect.sync(() => db.close()))保证(第 137 行);- 同一客户端内部通过
Semaphore.make(1)串行化连接访问(第 217 行),事务获取连接使用uninterruptibleMask包裹并绑定作用域 finalizer 释放信号量(第 221-231 行); - 客户端同时提供
SqliteClient与通用SqlClient两个服务 tag(layer/layerConfig,第 260-288 行),layerConfig额外支持从 EffectConfig读取配置; - 可用配置项完整清单见 SqliteClientConfig 第 89-106 行:
filename、readonly、create、readwrite、disableWAL、busyTimeout、spanAttributes、transformResultNames、transformQueryNames。
九、变更时间线速查
| 版本 | 变更类型 | 核心内容 |
|---|---|---|
4.0.0-beta.0 | Major | v4 beta 启动(PR #1183) |
4.0.0-beta.37 | Patch | SqlError 统一为 reason-based 结构,原生失败归类为结构化 reason,无法归类时回退Unknown |
4.0.0-beta.65 | Patch | 新增UniqueViolationreason,唯一约束冲突从ConstraintError中独立,constraint缺失时回退"unknown" |
4.0.0-beta.86 | Patch | 新增valuesUnprepared/executeValuesUnprepared数组取值能力 |
4.0.0-beta.88 | Patch | 语句准备失败(缺表、语法错误等)转为类型化SqlError,不再以 defect 逃逸 |
4.0.0-beta.103 | Patch | 移除显式./index入口 |
4.0.0-beta.104 | Patch | 打开 Bun SQLite 数据库时强制执行只读模式 |
4.0.0-beta.107 | Patch | 默认五秒忙等待超时 +BEGIN IMMEDIATE立即事务,缓解并发访问下的锁失败 |
4.0.0-rc.108→4.0.0-rc.112 | Patch | 仅依赖升级,随effect主库同步发版 |
十、小结
纵观4.0.0-beta.0到4.0.0-rc.112的完整变更记录,@effect/sql-sqlite-bun在 v4 周期内完成了三条主线演进:并发正确性(五秒忙等待、BEGIN IMMEDIATE立即事务、强制只读模式)、错误模型现代化(reason-based 结构化分类、UniqueViolation细化、语句准备错误类型化)、以及API 收敛(移除 index 入口、新增 unprepared 数组取值)。其中每条行为变更都能在 SqliteClient.ts 的实现与 Client.test.ts、SqlError.test.ts 的测试中得到印证。对于在 Bun 上使用 Effect SQL 的开发者而言,这些变更直接决定了事务该如何写、只读连接该如何开、错误该如何捕获与重试——理解它们,就是在理解这个客户端最核心的工程契约。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考