首页
/ pi SQLite 会话后端深度解析:node:sqlite 适配器、SqliteSessionRepository 与 FTS 会话搜索

pi SQLite 会话后端深度解析:node:sqlite 适配器、SqliteSessionRepository 与 FTS 会话搜索

2026-09-04 09:32:08作者:毕习沙Eudora

本文围绕 pi 仓库中的 @earendil-works/pi-session-backend-sqlite-node 包展开,完整讲解该 SQLite 会话后端的 node:sqlite 适配器、SqliteSessionRepository 会话仓库、数据库迁移与物化视图、可选 FTS5 全文搜索的实现细节与接入方式。读完你可以掌握:如何在 pi agent 体系中以 SQLite 单文件数据库持久化 agent 会话(创建、追加、分叉、列表、删除),以及会话跨库搜索的触发时机与同步机制,并理解其背后的写者租约(writer lease)、PRAGMA 配置与迁移管理设计。

包定位:pi 会话层的 Node SQLite 实现

@earendil-works/pi-session-backend-sqlite-node@earendil-works/pi-agent-core 会话模型的 Node 端 SQLite 后端,它提供四样东西:node:sqlite 适配器(SqliteDatabase 实现)、SQLite 会话仓库、迁移(migrations)、物化视图,以及可选的 FTS 搜索(见 README)。

package.json 可以看到关键约束与依赖:

  • 运行环境要求 "engines": { "node": ">=22.19.0" },因为适配器直接依赖 Node 内置的 node:sqliteDatabaseSync)模块,无第三方 SQLite 驱动;
  • 依赖 @earendil-works/pi-agent-core(会话契约:SessionRepoSessionSessionStorageEntryLaneRecord 等)与 @earendil-works/pi-aiuuidv7 会话 ID 生成);
  • 包名经历了演进:从源码与 CHANGELOG 可确认,它在 0.84.0 版本从 @earendil-works/pi-storage-sqlite-node 更名为现名,同时用 v4 lane-based SessionRepo 契约替换了旧版 schema 与仓库,且旧工作数据库不做迁移——这是使用该后端时必须注意的前提。

快速上手:仓库 + 搜索 + 会话生命周期

README 给出的最小可用示例如下,这里结合源码补全了 options 的真实字段:

import { createNodeSqliteFactory, SqliteSessionRepository, createSqliteSessionSearch } from "@earendil-works/pi-session-backend-sqlite-node";
import type { FileSystem } from "@earendil-works/pi-agent-core";

// env 只需要 pi-agent-core FileSystem 的三个能力(见 types.ts 中 SqliteSessionRepositoryEnv 的定义)
const repository = new SqliteSessionRepository({
  env: { absolutePath, createDir, exists } as Pick<FileSystem, "absolutePath" | "createDir" | "exists">,
  sqlite: createNodeSqliteFactory(), // 默认的 node:sqlite 工厂
  databasePath: "~/.pi/sessions.db",   // 支持相对路径,仓库内部会做绝对路径解析并自动创建父目录
  writerLease: { ttlMs: 30_000, heartbeatIntervalMs: 10_000 }, // 可选,默认即 30s / 10s
});

await using session = await repository.create({ cwd: process.cwd() });
await session.appendMessage(message);

const search = createSqliteSessionSearch({
  env,
  sqlite: createNodeSqliteFactory(),
  databasePath: "~/.pi/sessions.db", // 与仓库指向同一个“规范数据库”
});

const hits = [];
for await (const hit of search.search("needle")) hits.push(hit);
// hit: { sessionId, entryId, metadata, timestamp, score }

两个设计要点直接来自 README 并被源码印证:

  1. 仓库惰性持有唯一共享数据库连接:在 repo.ts 中,getDatabase() 只在首次被调用时执行 openDatabase()(解析绝对路径 → env.createDir 建目录 → sqlite.open → 配置 PRAGMA → 应用迁移),之后所有 create/open/list/delete/fork 操作复用同一连接。
  2. 搜索是独立服务,仓库不暴露 search()createSqliteSessionSearch 返回的是实现了 pi-agent-core SessionSearch 契约的独立对象(见 search-backend.ts),它自行打开同一规范数据库连接完成查询,查询结束即关闭。

仓库实现 AsyncDisposable[Symbol.asyncDispose] 中调用 close()),因此 await using 结束时会自动排空操作队列、释放所有活跃会话的写者租约并关闭数据库连接。

node:sqlite 适配器:SqliteDatabase 抽象与事务语义

包入口 src/index.ts 实现了 node:sqlite 的封装层,导出两个工厂函数:

  • createNodeSqliteFactory():返回 SqliteDatabaseFactory,其 open(path) 内部 new DatabaseSync(path) 并包装为 SqliteDatabase
  • wrapNodeSqliteDatabase(db):如果你已经在别处打开了 DatabaseSync(例如为了测试或共享连接),可直接包装复用。

SqliteDatabase 接口(定义于 types.ts)只有四个能力:execpreparetransactionclose。其中 transaction<T>(fn) 的语义值得注意,源码实现是:

transaction<T>(fn: () => T): T {
  sql`BEGIN IMMEDIATE`.exec(this);
  try {
    const result = fn();
    if (isAsyncResult(result)) {
      throw new TypeError("SQLite transaction callbacks must be synchronous");
    }
    sql`COMMIT`.exec(this);
    return result;
  } catch (error) {
    try { sql`ROLLBACK`.exec(this); } catch { /* 忽略回滚错误以保留原始错误 */ }
    throw error;
  }
}

即:事务必须是同步回调——使用 BEGIN IMMEDIATE 抢占写锁,回调返回 Promise 会直接抛 TypeError,异常时回滚并保留原始错误。这解释了为什么仓库的所有写路径(追加条目、写记录、写 facts)都包在一个同步 db.transaction 内完成,而跨步骤的并发控制交给外层 SerialOperationQueue(一个 Promise 链串行队列)实现。

语句层 NodeSqliteStatement 支持 run/get/all/iterate 四个方法,并自动识别“命名参数对象”(普通对象作为第一个参数)与位置参数两种绑定方式;run 的返回被规整为 { changes, lastInsertRowid },对 BigInt 型 rowid 做了 Number 转换。

此外,0.84.1 版本加入了可组合的参数化 sql 模板标签(见 CHANGELOGsql.ts),源码中大量使用它编写查询,例如 PRAGMA 配置与迁移记录插入:

sql`PRAGMA journal_mode=WAL`.exec(db);
sql`INSERT INTO migrations (id, applied_at) VALUES (${migration.id}, ${new Date().toISOString()})`.run(db);

数据库初始化:PRAGMA 配置与迁移管理

每次打开数据库连接(仓库和搜索服务各自独立)都会先执行统一的 PRAGMA 配置(configureSqliteDatabase,在 repo.tssearch-backend.ts 中各有一份相同实现):

PRAGMA journal_mode=WAL;    -- 写前日志模式,读不阻塞写
PRAGMA synchronous=FULL;    -- 完整同步,优先数据完整性
PRAGMA busy_timeout=5000;   -- 库被锁时最多等待 5 秒

迁移管理在 migrations.ts 中:先确保 migrations(id, applied_at) 表存在,然后按 loadMigrations() 返回的列表逐一检查是否已应用,未应用的在单个事务内执行 SQL 并写入记录。当前仓库内置唯一迁移 001_initial.sql

逻辑 schema 与“物化视图”

001 初始迁移定义了整套会话存储的表结构,可以按职责分组理解:

  • 会话目录sessions(id, created_at, cwd, parent_session_id, metadata)WITHOUT ROWID,配 (created_at DESC)(cwd, created_at DESC) 两个索引——后者正是 list({ cwd }) 按目录过滤的高效路径;
  • 规范条目entries(session_id, seq, id, parent_id, type, timestamp, payload)seq 每会话唯一、id 每会话唯一,payload 是 JSON;
  • 序号与统计session_sequences(next_seq) 提供每会话单调序列分配,session_stats(message_count, cached_tokens, uncached_tokens, total_tokens, cost_total) 是 README 所称“物化统计视图”的落点;
  • 分支缓存branch_entries 是派生表(迁移文件注释明确写道:“entries 中的父链接仍是规范的;此缓存只为了让分支扫描变快”),另有 branch_tips 记录各分支 tip,二者共同构成活跃分支的物化视图,损坏时可用 repository.repairBranchCache(metadata) 从规范父链接重建;
  • 车道与记录lanes(session_id, lane, leaf_id, open_operation_id) 维护每条 lane 的叶节点;records 存储 lane 上的运行记录(操作开始/结束、用量等),lane_moves 记录 lane 叶节点移动历史;
  • 事实facts(session_id, seq, kind, key, value) 以追加方式存储 name/label 等全局事实,最新一条生效(getName/setName/getLabel/setLabel 即读写此表);
  • 写者租约writer_leases(session_id, owner_id, fence, expires_at_ms)fence 字段在迁移注释中被说明为“防止过期属主在新属主接管后继续写入”的屏障。

仓库对 entries 的 payload 有严格的类型解码(repo.tsdecodeEntry),支持 messagemodel_changethinking_level_changeactive_tools_changecompactionbranch_summarycustom 七类条目;任何解码失败都会包装为带 invalid_entry 代码的 SessionError 抛出,而不是静默吞掉。

写者租约:多进程安全写入

SQLite 文件可被多个进程并发打开,pi 用带围栏(fence)的写者租约保证同一会话同一时刻只有一个有效写者:

  • SqliteSessionRepositoryOptions.writerLease 提供两个可调参数(repo.ts):
    • ttlMs:无成功心跳后多久允许其他写者接管,默认 30 秒
    • heartbeatIntervalMs:空闲心跳间隔,默认 10 秒,构造时会校验其为正数且必须小于 ttlMs,否则抛 RangeError
  • 打开/创建会话时通过 claimWriterLease 抢占租约;已有活跃写者则抛 already has an active writerSessionError
  • 每次写入在事务内先 renewWriterLease 续租并校验所有权,续租失败立即置为 writer lease was lost 错误并停止心跳,之后所有写入直接失败;
  • 心跳定时器 unref(),不会阻止进程退出;单次心跳失败会被静默重试,因为“每次写入都会事务性地再验证所有权”(源码注释原话)。

这套机制使得 open/create 返回的 Session 是“带独占写权限”的对象:同进程内重复 open 同一会话会复用已有 storage,跨进程则会因租约被拒。

会话生命周期:create / open / list / fork / delete

  • create({ cwd, id?, parentSessionId?, metadata? }):未指定 id 时生成 uuidv7();整个创建过程在一个事务内完成——插入 session 行、初始化序列、统计、初始 lane,并抢占写者租约,返回即处于“已持有写锁”状态;
  • open(metadata):按元数据取回已有会话并抢占租约;
  • list({ cwd? }):只读目录查询,源码注释明确它“不获取或续租任何 per-session 写者租约”,因此即使有会话正被活跃写者占用也能安全枚举(这是 0.84.0 修复的行为,见 CHANGELOG);
  • fork(source, options):支持两种范围——scope: "tree" 全量复制条目、lanes 与分支 tip;否则基于 main lane 按 entryId 定位(目标必须是 message 条目)并在 at/before 位置截断分叉,同时拷贝最新的 name 与相关 label 事实、初始化消息计数;分出的新会话继承 parentSessionId(可用 options.parentSessionId 覆盖);
  • delete(metadata):单事务内先释放/删除该会话的所有活动 storage,再依次清理分支缓存、facts、lanes、records、entries、writer leases、stats、sequences 与 session 行;
  • repairBranchCache(metadata):先释放该会话的活跃 storage,再在事务内重建分支缓存,用于分支物化视图与规范数据不一致时的修复。

仓库顶层还有自己的 SerialOperationQueue 串行队列,create/open/list/delete/fork 等仓库级操作互相排队执行,避免同一连接上的元数据竞争。

可选 FTS 搜索:惰性建表、触发器同步与一次性重建

createSqliteSessionSearch 是独立于仓库的搜索服务,其核心行为与 README 描述一一对应,实现见 search-backend.ts

  1. 惰性创建 FTS 表:只有第一次收到非空白查询时,ensureSearchSchema 才执行建表 DDL。FTS5 虚拟表以外部内容表形式挂载在 entries.payload 上:

    CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5(
      payload,
      content = 'entries',
      content_rowid = 'rowid',
      tokenize = 'trigram remove_diacritics 1'
    );
    

    采用 trigram 分词器(配合去重音),意味着搜索是子串级匹配而非词级分词,对中文、代码标识符等场景比较友好;

  2. 一次性重建:若 FTS 表是首次创建但 entries 已有数据,立即执行 INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild') 从规范条目全量构建索引;

  3. 触发器持续同步:同时创建 AFTER INSERTAFTER DELETEAFTER UPDATE OF payload 三个触发器,把后续条目写入、删除、payload 更新实时同步进 FTS 索引,无需应用层维护;

  4. 查询语义search(text, options) 把查询词按短语匹配(内部对 " 做转义),可叠加 options.entryTypes(条目类型过滤)、options.limit(默认不限)、options.signalAbortSignal 支持,迭代中检查取消);结果按 bm25 得分排序,逐条 yield { sessionId, entryId, metadata, timestamp, score }SqliteSessionSearchHit 在核心 SessionSearchHit 上增加了 SQLite 特有的 metadatatimestampscore 字段)。

搜索每次调用自行打开、配置、迁移数据库并在 finally 中关闭连接,因此与仓库连接互不干扰;空白查询词、limit <= 0entryTypes: [] 会直接返回空迭代。

验证与测试

该包的测试位于 test/ 目录,通过 vitest --run 执行,覆盖面与源码职责一一对应:

小结

@earendil-works/pi-session-backend-sqlite-node 用零依赖的 node:sqlite 把 pi 的会话模型完整落到单文件 SQLite 上:SqliteDatabase 抽象屏蔽驱动差异并以同步事务保证原子性;SqliteSessionRepository 提供惰性共享连接、带围栏的写者租约、分支物化视图与 fork/repair 等高级操作;createSqliteSessionSearch 则以独立服务的方式提供基于 FTS5 trigram 的跨会话搜索,靠惰性建表、一次性重建与触发器保持索引与规范条目一致。接入时只需提供 env(绝对路径/建目录/存在性检查)、sqlite 工厂与 databasePath 三项配置,并注意其要求 Node 22.19+ 且 0.84 起不兼容旧版数据库这两条前提。

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