首页
/ pi 的 Node.js SQLite 会话后端:从 0.81.0 到 0.84.4 的演进与实现解析

pi 的 Node.js SQLite 会话后端:从 0.81.0 到 0.84.4 的演进与实现解析

2026-09-06 14:22:49作者:瞿蔚英Wynne

本文以 sqlite-node 会话后端的 CHANGELOG 为主线,梳理 @earendil-works/pi-session-backend-sqlite-node 从最初落地(0.81.0)到 v4 lane-based 存储契约(0.84.0)、再到 SQL 层性能与查询能力增强(0.84.1)的完整演进过程,并结合仓库源码还原每个版本变更背后的具体实现:lane/记录/事实/租约的数据模型、fenced writer lease 的并发保护机制、可组合的 sql 模板标签,以及 WAL 与 covering index 等底层调优。读完本文,你能理解该后端每一版的变更动机、可复用的 API 用法与默认参数,以及如何在 pi 项目中阅读和扩展这套 SQLite 存储层。

包概览与版本时间线

该包位于 packages/session-backends/sqlite-node,是 pi agent harness 会话(@earendil-works/pi-agent-core sessions)的 Node.js 专用存储后端。README 概括其提供的能力:node:sqlite 适配器(SqliteDatabase 实现)、SQLite 会话仓库(SqliteSessionRepository)、数据库迁移、物化会话视图,以及可选的 FTS 全文搜索。

package.json 显示当前版本为 0.84.4,且 "engines": { "node": ">=22.19.0" }——这是一个重要前提:node:sqliteDatabaseSync)是 Node.js 22.5+ 才引入的实验性内置模块,后端直接构建在它之上,因此整个包没有引入任何第三方 SQLite 驱动依赖。

对照 CHANGELOG,关键版本节点如下:

版本 日期 核心变更
0.81.0 2026-07-21 首次加入 Node.js SQLite 存储后端,含迁移与物化会话视图
0.82.0 ~ 0.83.0 2026-07-24 ~ 2026-07-29 常规迭代(无独立变更记录)
0.84.0 2026-08-06 破坏性变更:包重命名 + 换用 v4 lane-based SessionRepo 契约
0.84.1 2026-08-07 新增可组合 sql 模板标签;分支查询下推到 SQL、加 covering index
0.84.2 ~ 0.84.4 2026-08-14 ~ 2026-08-28 后续迭代(当前版本)

下面的章节按版本顺序展开,每一节都会给出对应的源码证据。

0.81.0:Node.js SQLite 后端首次落地

CHANGELOG 中 0.81.0(2026-07-21)的 Added 条目写道:

Added a Node.js SQLite storage backend for agent harness sessions, including migrations and materialized session views。

这对应了包的最初形态:把 agent 会话持久化到 SQLite 文件,并提供迁移脚本与物化视图。仓库中至今保留的迁移文件 001_initial.sql 就是该后端的数据模型基础。构建脚本会把它一并拷贝进发布产物(prepare-dist.mjsnpm run build 触发,见 package.json 的 build 脚本:tsgo -p tsconfig.build.json && node ./scripts/prepare-dist.mjs copy-sqlite-migrations),迁移由 migrations.ts 在首次打开数据库时自动应用。

0.84.0:破坏性变更——包重命名与 v4 lane-based SessionRepo

CHANGELOG 的 0.84.0(2026-08-06)是整个后端历史中最重要的一版,包含两条 Breaking Changes:

  1. 包重命名@earendil-works/pi-storage-sqlite-node@earendil-works/pi-session-backend-sqlite-node。这与当前 package.json 中的 name 字段完全一致。命名变化本身透露了定位转变:从泛化的“存储包”收窄为“agent 会话后端”,目录也从 packages/session-backends/sqlite-node 体现这一层级。
  2. 存储契约替换:legacy SQLite session schema 与 repository 被整体替换为 v4 lane-based SessionRepo 契约,且明确说明“Existing work-in-progress databases are not migrated”——旧库不迁移,属于一次彻底切换。

v4 契约在源码中的落点

repo.tsSqliteSessionRepository 直接实现 agent-core 的 SessionRepository 接口(第 669-672 行),对外暴露 create / open / list / delete / fork / repairBranchCache 等方法。v4 契约新增的能力(CHANGELOG Added 条目:“bounded active-branch queries, durable operation records, global facts, shared sequence allocation, session statistics, fenced writer leases”)都能在迁移 SQL 中找到一一对应的表:

v4 能力 对应表(001_initial.sql) 源码实现
lane(可并行的写入轨道) lanes(session_id, lane, leaf_id, open_operation_id) lanes.ts
持久化操作记录 records + lane_moves records.ts
全局事实(会话名、标签) facts(session_id, seq, kind, key, value) facts.ts
共享序号分配 session_sequences(session_id, next_seq) session-sequences.ts
会话统计 session_stats(message_count、三类 tokens、cost_total) session-stats.ts
fenced writer lease writer_leases(session_id, owner_id, fence, expires_at_ms) writer-leases.ts
有界分支读 branch_entries 物化缓存 + branch_tips branch-cache.tsbranch-entries.ts

几个值得注意的设计细节:

  • entries 是 canonical 数据entries(session_id, seq, id, parent_id, type, timestamp, payload)(session_id, seq) 唯一,父链接(parent_id)是唯一的分支结构事实。branch_entries 表在 SQL 注释中被明确称为 “Derived branch cache… this cache exists only to make branch scans cheap”——它是物化派生缓存,repo.ts 提供了 repairBranchCache 用于从 canonical 父链接重建它。
  • 单一序号空间:条目(entry)、记录(record)、lane 移动(lane move)、事实(fact)共用 session_sequences 分配的 seq,getLog(repo.ts 第 568 行起)就是把四类行按 seq 归并排序后回放成统一日志。
  • fork 语义fork 支持 scope: "tree"(整树复制,含所有 lane 与分支尖端)与默认的单 lane 复制两种模式,且复制的是分支缓存中从根到目标 tip 的整条路径(repo.ts 第 797-909 行)。

0.84.0 的修复:会话列表不再抢占写者租约

CHANGELOG 0.84.0 的 Fixed 条目记录了 PR #7655 的修复:SQLite 会话列表不再获取 writer claim,且包含当前会话名,从而在会话存在活跃写者时也能完成清单读取。对应源码是 list() 方法的注释与实现(repo.ts 第 763-772 行):“Reads the session catalog without acquiring or renewing per-session writer leases”——它只读 sessions 表,而会话名从 global facts(readLatestFact(db, sessionId, "name", null))投影出来(见 types.tsSqliteSessionMetadata.name 的字段注释)。这是一个典型的“清单读与写互不阻塞”的解耦。

fenced writer lease:多进程写者保护

“fenced writer leases” 是 0.84.0 的关键安全机制,其参数与行为都定义在 repo.ts 中:

export interface SqliteWriterLeaseOptions {
    /** Time without a successful heartbeat before another writer may take over. Default: 30 seconds. */
    ttlMs?: number;
    /** Idle heartbeat cadence. Default: 10 seconds. Must be less than ttlMs. */
    heartbeatIntervalMs?: number;
}

实现要点(均可在 repo.ts 中核对):

  • resolveWriterLeaseOptions(第 114-122 行):默认 ttlMs = 30_000heartbeatIntervalMs = 10_000,并强制校验 heartbeatIntervalMs 为正且小于 ttlMs
  • fence 的作用writer_leases 表中的 fence 列(每次新 owner 接管时递增,SQL 注释为 “The fence prevents an expired owner from writing after a new owner takes over”)。每次写入事务都先调用 renewWriterLease(第 382-391 行)校验租约归属,过期被接管后的旧 owner 会收到 writer lease was lostSessionError,从根源上避免双写。
  • 心跳scheduleHeartbeat(第 394-415 行)按 heartbeatIntervalMs 周期性续租,计时器 unref() 不阻塞进程退出;心跳失败只重试,因为“每一次写仍会事务内校验归属”。
  • 单写者约束:同一会话被第二个写者 open 时,acquireWriterLease 失败,抛出 “already has an active writer”(第 124-126 行)。
  • 这些行为的回归测试见 writer-leases.test.ts

0.84.1:sql 模板标签与查询下推

CHANGELOG 0.84.1(2026-08-07)有两条变更,分别对应查询构造与查询性能两个层面。

新增可组合、参数化的 sql 模板标签

实现位于 sql.ts(仅 66 行,设计紧凑):

/** Builds a parameterized query. Nested queries are inlined; other interpolations become `?` parameters. */
export function sql(strings: TemplateStringsArray, ...values: SqlTemplateValue[]): SqlQuery {
    // 嵌套的 SqlQuery 直接内联其 queryText 并拼接参数;
    // 其它插值一律生成 "?" 占位符并 push 进 params。
    ...
    return new SqlQuery(queryText, params);
}

要点:

  • SqlQuery 是一个不可变的参数化查询对象,持有 queryTextparams 两个只读字段,并提供 exec / run / get / all / iterate 五种执行方式(第 15-34 行)。exec 明确禁止带参,抛 TypeError——这与 node:sqliteexec 不支持绑定的语义一致。
  • 嵌套组合:模板标签中插入另一个 SqlQuery 时,其文本被内联、参数按原顺序并入,因此可以像搭积木一样组合出动态 WHERE 子句等片段;非查询值(字符串、数字等)则一律走 ? 绑定,杜绝字符串拼接注入。
  • joinSqlFragments(第 56-66 行)用于把多个“可信”片段按分隔符拼接并保持参数顺序,例如组装 IN (?, ?, ?)OR 链。
  • 该标签已贯穿整个后端:configureSqliteDatabasesql\PRAGMA journal_mode=WAL`.exec(db) 这类写法执行 PRAGMA,NodeSqliteDatabase.transaction用它包裹BEGIN IMMEDIATE / COMMIT / ROLLBACK`(src/index.ts 第 77-94 行)。参数化行为与组合规则的单测在 sql.test.ts 中。

分支查询下推到 SQL 与 covering index

CHANGELOG 同版 Fixed 条目(PR #7727)写道:

Fixed SQLite branch queries to apply filters, cursors, and limits in SQL; bounded log reads; and added covering indexes for session, record, branch, and fact queries。

这条修复解决了三个问题:

  1. 过滤、游标、limit 全部在 SQL 层执行。此前分支查询如果先取回再在 JS 里过滤/截断,会随分支长度线性放大小结果集的内存与 IO。现在 findEntriesOnBranch(repo.ts 第 535-549 行)通过 queryCachedBranchRowsEntryQuerytype / cursor / order / limit 直接编进 SQL,取回行数即为返回行数。

  2. 有界的日志读取getLog 对 entries、records、lane_moves、facts 四个来源分别以 afterSeq + limit 参数化查询,避免“无界拉全量再截断”。

  3. covering index 补齐。对照 001_initial.sql 中的索引定义可以看到覆盖性设计的密度:

    • sessions:idx_sessions_created_atidx_sessions_cwd_created_at(支撑按时间倒序与按 cwd 过滤的列表查询);
    • records:多达 6 个索引(session+lane+seqsession+type+seqsession+type+op_kind+seqsession+lane+type+op_kind+seq 等),几乎每种 RecordQuery 组合都有一条可覆盖的复合索引;
    • branch_entries:4 个索引覆盖“按分支按 seq”“按 entry 反查所属分支”“按分支+type”“按分支+custom_type”四类扫描;
    • facts:idx_facts_session_kind_key_seq 支撑“取某 kind/key 的最新事实”。

    注意其中 idx_entries_session_type_seqidx_records_session_lane_type_op_kind_seq 这类把查询谓词列放在索引前列的写法,正是让 LIMIT 能尽早终止扫描的关键。

这些行为的验证集中在 branch-query.test.tslog-query.test.tsfacts-query.test.ts 三个测试文件中。

Node 适配器与数据库调优

CHANGELOG 中 0.81.0 的 “including migrations and materialized session views” 在 Node 侧的载体是 src/index.ts 里的 NodeSqliteDatabase / NodeSqliteStatement:它把 node:sqliteDatabaseSync 包装成后端的 SqliteDatabase 抽象(见 types.ts 第 12-30 行的接口定义),并做三件实事:

  • 参数形态归一:自动区分“命名参数对象”与“位置参数列表”(isNamedParameters),对外只暴露 unknown[] 的可变参数签名;
  • 结果归一run 返回 { changes, lastInsertRowid }lastInsertRowid 由可能的 BigInt 转为 number;
  • 同步事务transactionBEGIN IMMEDIATE 开启写事务,且显式拒绝返回 Promise 的回调(SQLite transaction callbacks must be synchronous),失败时回滚并保留原始错误。

对外部使用者,入口是 createNodeSqliteFactory():返回一个 SqliteDatabaseFactoryopen(path) 直接 new DatabaseSync(path)。仓库打开后的统一配置在 configureSqliteDatabase(repo.ts 第 172-176 行):

sql`PRAGMA journal_mode=WAL`.exec(db);      // 读写并发:读不阻塞写
sql`PRAGMA synchronous=FULL`.exec(db);      // 持久性优先:checkpoint 时全量同步
sql`PRAGMA busy_timeout=5000`.exec(db);     // 遇到锁等待至多 5 秒

这套组合表明该后端的设计取向是持久性与写安全优先:WAL 让 list 这类纯读操作不与活跃写者互斥(呼应 0.84.0 的 Fixed 条目),FULL + writer lease fence 保证崩溃恢复与双写防护。

使用方式与契约验证

结合 README 的最小示例与上文源码,一个典型的用法是:

// repository 惰性持有一条共享数据库连接;
// search 是同一 canonical 数据库上的独立服务,repository 不暴露 search()。
await using repository = new SqliteSessionRepository(options);
const search = createSqliteSessionSearch(options);
const session = await repository.create({ cwd });
await session.appendMessage(message);

const hits = [];
for await (const hit of search.search("needle")) hits.push(hit);

其中 options 即上文 SqliteSessionRepositoryOptionsenv(提供 absolutePath / createDir / exists 的 FileSystem 能力子集)、sqlite(数据库工厂,Node 场景传入 createNodeSqliteFactory())、databasePath,以及可选的 writerLease: { ttlMs, heartbeatIntervalMs }。README 还强调 FTS 的懒加载策略:FTS 表与触发器在第一次非空白搜索时才创建,创建时从 canonical 条目做一次性全量重建,之后由 SQLite 触发器随 canonical 条目的插入、删除、payload 更新自动同步——搜索功能因此对未用到它的部署完全零成本。

质量保障方面,除前述各功能测试外,conformance.test.ts 对整个后端做契约级回归,确保它稳定满足 agent-core 的 SessionRepo 语义;测试运行方式为包内的 npm test(即 vitest --run,配置见 vitest.config.ts)。

小结:如何沿 CHANGELOG 阅读这个后端

  • 0.81.0 奠定了“node:sqlite + 迁移 + 物化视图”的基本盘,前提是 Node.js >= 22.19;
  • 0.84.0 是一次契约换代:重命名为 pi-session-backend-sqlite-node、切换到 v4 lane-based SessionRepo,用 lanes/records/facts/sequences/stats 五类表加 fenced writer lease 重构了存储模型,并让会话列表摆脱写者租约;旧库不做迁移;
  • 0.84.1 打磨查询层:引入可组合、参数化的 sql 模板标签,把分支查询的过滤/游标/limit 下推 SQL,并用一组 covering index 覆盖 session/record/branch/fact 的高频查询路径。

由于 0.84.2 至 0.84.4 未记录独立变更,从 CHANGELOG 可以推断这三个版本属于常规维护迭代;如需确认具体行为差异,应以上下文中的源码与测试文件为准。整体来看,这份 CHANGELOG 虽短,却勾勒出 pi 会话存储从“能用”到“多写者安全 + 查询下推”的一条清晰演进路径,配合 001_initial.sqlrepo.ts 即可完整还原每一版变更的工程含义。

登录后查看全文
热门项目推荐
相关项目推荐