从 changelog 到源码:@effect/sql-sqlite-node 的核心演进与实现原理
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
@effect/sql-sqlite-node是 Effect SQL 生态中面向 Node.js 的 SQLite 客户端,当前仓库 .repos/effect-smol/packages/sql/sqlite-node 保留了它的完整 changelog、源码与测试。本文以该 changelog 为主线,梳理这个包从 v0.x 到 v4 的重要演进:底层驱动从better-sqlite3迁移到 Node.js 内置node:sqlite、并发锁策略(5 秒 busy timeout +BEGIN IMMEDIATE)、基于 reason 的结构化错误分类、以及.raw/valuesUnprepared等查询 API,并结合SqliteClient.ts源码与测试用例讲清每个特性背后的实现细节。读完本文,你将理解这个包的核心配置项、并发模型、错误处理与迁移机制,并能在自己的 Effect 项目中正确使用与排查问题。
一、包定位:Effect SQL 的 Node.js SQLite 驱动
@effect/sql-sqlite-node把 SQLite 接入 Effect 的类型化 SQL 体系。包的核心模块有两个(见 src/index.ts):
SqliteClient:基于node:sqlite打开数据库,同时暴露为节点专属的SqliteClient服务与通用的 Effect SQL 客户端;SqliteMigrator:复用共享迁移实现(src/SqliteMigrator.ts),提供run与layer两个入口执行迁移。
安装与版本要求
包 README(README.md)给出了安装方式:
npm install effect@rc @effect/sql-sqlite-node@rc配套要求也很明确:
- Node.js 22.16 或更新版本——该版本引入了本包依赖的
sqlite.backup()API。
从 package.json 可以看到,当前版本为4.0.0-rc.112,peerDependencies只要求effect(工作区^版本),本身不携带任何原生编译依赖——这正是后面要讲到的node:sqlite迁移带来的关键收益。
二、底层驱动演进:从 better-sqlite3 到 node:sqlite
changelog 中最具里程碑意义的一条变更出现在4.0.0-beta.94(PR #2487):
Replace the
better-sqlite3dependency with Node.js' built-innode:sqlitemodule.
在此之前,该包通过better-sqlite3这一原生模块访问 SQLite,这意味着安装时需要针对本地平台编译原生二进制(node-gyp),在跨平台交付、Electron 环境或离线安装场景下容易成为痛点。迁移到node:sqlite之后:
- 不再依赖第三方原生模块,安装与构建链路更轻;
- 由 Node.js 官方持续维护 SQLite 绑定(当前运行时内置,随 Node 版本升级);
- 代价是运行时版本被锁定为较新的 Node.js(见上文 22.16+ 要求)。
这条变更在源码中留下了清晰痕迹:SqliteClient.ts 顶部直接import { backup as backupDatabase, DatabaseSync } from "node:sqlite",并import type { StatementSync } from "node:sqlite",连接、预编译语句、备份全部走内置模块 API。
提示:由于
node:sqlite是同步 API,源码注释明确说明busy 等待会阻塞 Node.js 事件循环,这是使用本包时需要注意的运行时特性(详见下一节)。
三、并发与锁策略:5 秒 busy timeout + BEGIN IMMEDIATE
4.0.0-beta.107引入了一次针对 SQLite 并发访问锁失败的实质修复:
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.
这条变更包含两个互补的机制:
- 默认 5 秒 busy timeout:当数据库被其他连接持锁时,SQLite 最多等待 5 秒而不是立刻抛 "database is locked"。
- 立即事务(
BEGIN IMMEDIATE):可写连接上的显式事务默认以BEGIN IMMEDIATE开启,避免"先读后写"导致的锁升级死锁——即使事务内只读,也会在开始时就取得写锁,从而在写者身后排队。
源码实现
SqliteClient.ts中,busy timeout 的计算与设置如下(源码 SqliteClient.ts):
const MAX_BUSY_TIMEOUT = 2_147_483_647 // ... const busyTimeout = Math.min( MAX_BUSY_TIMEOUT, Math.max(0, Math.round(Duration.toMillis(options.busyTimeout ?? Duration.seconds(5)))) ) db.exec(`PRAGMA busy_timeout = ${busyTimeout}`)要点:
- 默认
Duration.seconds(5),对应PRAGMA busy_timeout = 5000; Duration.infinity会被钳制(clamp)到 SQLite 最大超时值2_147_483_647ms;- 传 0 可禁用等待。
事务开启方式在客户端构建处硬编码:
beginTransaction: "BEGIN IMMEDIATE",测试佐证
test/Client.test.ts 用三个用例验证了这条行为:
"uses a 5 second busy timeout":默认客户端PRAGMA busy_timeout返回[{ timeout: 5000 }];自定义busyTimeout: "1 second"返回 1000;Duration.infinity返回2_147_483_647。"starts transactions immediately":两个客户端并发打开同一数据库,第二个客户端在事务内执行BEGIN IMMEDIATE会因为拿不到写锁而得到 "database is locked" 的SqlError。"fails a contended transaction with a typed error":验证被抢占的事务失败是类型化的、可重试的SqlError,而不是被替换成 rollback 缺陷(defect)。
四、结构化错误分类:reason-based SqlError 与 UniqueViolation
@effect/sql-sqlite-node的错误体系经历过两次重要演进,都记录在 changelog 中。
4.0.0-beta.37:SqlError 改为 reason-based
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.
即:原生 SQLite 报错被归类为结构化的reason,无法可靠识别时回退到Unknown。在SqliteClient.ts中,所有失败路径都通过classifyError包装:
const classifyError = (cause: unknown, message: string, operation: string) => classifySqliteError(sqliteCauseWithErrno(cause), { message, operation })其中的sqliteCauseWithErrno处理了node:sqlite错误对象只有errcode没有errno的情况,把errcode拷贝为errno,使共享的classifySqliteError能正确识别原生错误码——这是一个典型的"驱动适配层"细节。
4.0.0-beta.65:新增 UniqueViolation
Add
UniqueViolationas a new SQL error reason. Supported unique constraint violations now classify asUniqueViolationinstead of the broaderConstraintErrorreason.
变更说明同时覆盖 PostgreSQL、PGlite、MySQL、MSSQL 以及 SQLite 家族客户端的共享分类逻辑,且:
UniqueViolation.constraint存放尽可能可靠的约束/索引/键标识;- 找不到可靠标识时回退为字符串
"unknown"。
这意味着业务代码可以通过匹配reason._tag === "UniqueViolation"精确处理唯一键冲突(如重试、提示用户),而不必把一切约束错误混为一谈。
五、查询 API 演进:.raw 与 valuesUnprepared
.raw:获取驱动原始结果
0.9.0(PR #3457)为Statement增加了.raw属性,用于拿到底层驱动的原始返回。changelog 里举了 MySQLResultSetHeader的例子;对本包而言,SQLite 的INSERT/UPDATE/DELETE返回的是{ changes, lastInsertRowid }。0.12.2(PR #3607)进一步明确该行为并给出 SQLite 的示例:
response = yield * sql`INSERT INTO test (name) VALUES ('hello')`.raw; assert.deepStrictEqual(response, { changes: 1, lastInsertRowid: 1 });源码实现见runStatement(SqliteClient.ts):当语句没有结果列(statement.columns().length === 0)且raw为 true 时,返回{ changes, lastInsertRowid };有结果列时照常返回行数组。test/Client.test.ts 中"should work with raw"用例完整验证了建表({ changes: 0, lastInsertRowid: 0 })、插入({ changes: 1, lastInsertRowid: 1 })以及事务内.raw的组合行为。
valuesUnprepared:返回未预编译的数组行
4.0.0-beta.86(PR #2462)新增Statement.valuesUnprepared,让查询结果以数组行(而非对象行)返回,且不经过预编译缓存:
response = yield* sql`SELECT * FROM test`.valuesUnprepared assert.deepStrictEqual(response, [[1, "hello"]])源码中对应的runStatementValuesUnprepared每次直接db.prepare(sql)并调用statement.setReturnArrays(true),不做缓存,适合一次性的、希望省去缓存管理或需要数组布局的场景。配套的values(runValues)则走预编译缓存,且在 acquire/release 之间临时开启setReturnArrays后复位,避免污染缓存中的语句对象。
六、配置项全解:SqliteClientConfig
综合 changelog 中的演进点与源码,SqliteClient.make/SqliteClient.layer接受的配置项(SqliteClient.ts)如下:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
filename | string | 必填 | 数据库文件路径;:memory:表示内存库 |
readonly | boolean | false | 只读打开;只读客户端不受事务写锁策略影响 |
prepareCacheSize | number | 200 | 预编译语句缓存容量 |
prepareCacheTTL | Duration.Input | 10 分钟 | 预编译语句缓存 TTL |
disableWAL | boolean | false | 设为 true 时不执行PRAGMA journal_mode = WAL |
busyTimeout | Duration.Input | 5 秒 | 数据库繁忙等待时长;infinity钳制为 SQLite 最大超时;等待会阻塞事件循环 |
spanAttributes | Record<string, unknown> | 无 | 额外 OpenTelemetry span 属性(自动附带db.system.name: "sqlite") |
transformResultNames | (str) => str | 无 | 结果列名转换(如 snake_case → camelCase) |
transformQueryNames | (str) => str | 无 | 查询参数名转换 |
make的其余行为也值得注意:
- 单连接串行化:通过容量为 1 的
Semaphore序列化所有访问(Semaphore.make(1)),事务连接通过transactionAcquirer单独获取; - WAL 模式:默认开启
PRAGMA journal_mode = WAL(除非disableWAL); - 预编译缓存:
Cache.make按 SQL 文本缓存StatementSync,prepare 失败归类为SqlError; - backup 与 loadExtension:
SqliteClient额外暴露backup(destination)(返回{ totalPages, remainingPages })与loadExtension(path),后者在加载期间临时开启enableLoadExtension、结束立即关闭; - 不支持的 API:
updateValues与executeStream不支持——updateValues类型为never,executeStream直接Stream.die。
七、layer / layerConfig 命名约定与迁移
0.19.0(PR #3852)对/sql-*系列包统一了命名约定:
layer构造器接收原始配置对象;- 新增
layerConfig构造器接收Config.Config<...>。
SqliteClient.ts中layerConfig对配置做了Config.unwrap后走make,并把客户端同时注册为SqliteClient与通用Client.SqlClient两个服务;layer直接接收普通对象。两者都通过Layer.provide(Reactivity.layer)提供响应式依赖(供.reactive使用,对应 changelog 0.22.0 中 "add .reactive method to SqlClient interface")。
迁移方面,SqliteMigrator.ts 的run复用共享Migrator.make,返回ReadonlyArray<[id, name]>(已应用迁移的编号与名称);layer则在层构造期间执行迁移并产出空服务。注意dumpSchema相关代码在源码中以注释形式存在,说明该包当前不提供 Node 特定的 schema dump 支持,迁移执行完全由共享 migrator 处理。
八、可观测性演进:OTel 属性重命名
0.43.0记录了一次跨 SQL 驱动的 OpenTelemetry 语义约定升级:db.system→db.system.name、db.name→db.namespace、db.statement→db.query.text、db.operation→db.operation.name。对@effect/sql-sqlite-node而言,变化是db.system→db.system.name。
源码中对应常量与注入逻辑:
const ATTR_DB_SYSTEM_NAME = "db.system.name" // ... spanAttributes: [ ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), [ATTR_DB_SYSTEM_NAME, "sqlite"] ],即在每个查询 span 上默认标注db.system.name: "sqlite",便于在可观测性后端按数据库系统过滤。
另外,0.45.1移除了对@opentelemetry/semantic-conventions的依赖(PR #5397),避免 ESM 构建问题——同类重构在 changelog 中多次出现,体现了对打包与运行时兼容性的持续关注。
九、更广的生态视角:其余关键变更速览
除上述主题外,changelog 还记录了若干与本包相关的周边演进,值得了解:
| 版本 | 变更 | 影响 |
|---|---|---|
4.0.0-beta.103 | 移除显式./index入口点 | 导入路径统一为@effect/sql-sqlite-node(exports 映射见 package.json) |
4.0.0-beta.44 | ServiceMap模块重命名为Context | 影响所有使用服务映射的代码 |
0.22.0 | 允许克隆禁用 transforms 的SqlClient;新增.reactive | 为响应式查询提供接口 |
0.23.5 | 补充@effect/experimental依赖 | 修复 sql 系列包的依赖声明缺失 |
0.5.3 | 添加 pure 注解 | 提升 tree-shaking 效果 |
十、运行与验证
你可以在本仓库直接运行该包的测试来验证上述行为。先安装依赖,然后执行测试:
cd .repos/effect-smol/packages/sql/sqlite-node pnpm install pnpm test主要测试文件:
- test/Client.test.ts:核心客户端行为——CRUD、
.raw、事务与回滚、busy timeout、BEGIN IMMEDIATE竞争、只读事务、backup; - test/Persistence.test.ts:基于 SQLite 的持久化存储(set/get、批量、过期、store 隔离);
- test/KeyValueStore.test.ts、test/SqlEventJournal.test.ts、test/SqliteMigrator.test.ts:键值存储、事件日志与迁移机制的集成验证。
结语
透过 CHANGELOG.md 这份版本记录,可以看到@effect/sql-sqlite-node的演进脉络始终围绕三个目标:降低依赖复杂度(better-sqlite3 → node:sqlite)、提升并发可靠性(5 秒 busy timeout + BEGIN IMMEDIATE)、强化错误与查询的可编程性(reason-based SqlError、UniqueViolation、.raw、valuesUnprepared)。结合SqliteClient.ts源码与配套测试,这些 changelog 条目背后的实现并不神秘——它们都可以在本仓库中被直接阅读、运行与验证。对于准备在 Effect 4 项目中引入 SQLite 的开发者,这份 changelog 加上源码,就是最贴近真实的实现说明书。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考