跳到正文
曾亮懿 ZengLiangYi Blog

sql.js 实战:内存 SQLite、外键与显式保存

从 ChatCrystal 数据层理解 sql.js 的加载、事务和导出边界,用隔离练习验证外键、回滚与重新加载。

曾亮懿

曾亮懿

阅读约 3 分钟
目录 展开 收起

sql.js 把 SQLite 编译为 WebAssembly,并提供 JavaScript 接口。ChatCrystal 用它在 Node 服务里加载数据库字节、执行 SQL,再显式导出保存。它没有为每次 INSERT 自动写入应用的数据库文件;理解这个区别,比先比较跑分更有用。

沿真实初始化入口阅读

固定 数据层入口 先加载 WASM,再通过 readFileSync 读整个已有文件并创建 SQL.Database;不存在文件时新建。打包后的 Electron 通过 locateFile 从 resourcesPath 找 sql-wasm.wasm。随后设置 PRAGMA、执行迁移、补齐记忆元数据并保存。数据库已有 embedding 行时,启动代码还会把相应笔记状态改为 done;这不是检查 Vectra 文件是否完好的证明,参见 索引恢复。

源码执行 PRAGMA journal_mode = WAL,下面的独立练习在核对的 sql.js 1.14.2 环境里也返回 wal。但这个返回值不能证明每次提交会自动同步到应用的数据库文件,或与另一个宿主进程协调文件锁。应用持久化仍由整库 export 与 writeFileSync 完成;应同时检查 PRAGMA 结果与实际宿主文件写入路径。

一个不读取个人数据的练习

在空目录使用 Node 24 以上,安装固定版本,将代码保存为 lesson.mjs 后运行:

npm init -y
npm install sql.js@1.14.2
node lesson.mjs
// lesson.mjs: synthetic data, entirely in memory.
import assert from 'node:assert/strict';
import initSqlJs from 'sql.js';

const SQL = await initSqlJs();
const db = new SQL.Database();
let restored;
try {
  db.run('PRAGMA foreign_keys = ON');
  console.log('journal_mode:', db.exec('PRAGMA journal_mode = WAL')[0].values[0][0]);
  db.run(`CREATE TABLE parent (id INTEGER PRIMARY KEY);
    CREATE TABLE child (
      id INTEGER PRIMARY KEY,
      parent_id INTEGER REFERENCES parent(id)
    );`);
  assert.equal(db.run('INSERT INTO parent VALUES (?)', [1]), db);
  assert.equal(db.getRowsModified(), 1);
  db.run('INSERT INTO child VALUES (?, ?)', [1, 1]);
  assert.throws(() => db.run('INSERT INTO child VALUES (?, ?)', [2, 99]));

  db.run('BEGIN');
  try {
    db.run('INSERT INTO parent VALUES (?)', [2]);
    db.run('INSERT INTO child VALUES (?, ?)', [3, 99]);
    db.run('COMMIT');
  } catch {
    db.run('ROLLBACK');
  }
  assert.deepEqual(db.exec('SELECT id FROM parent ORDER BY id')[0].values, [[1]]);

  let bytes;
  try {
    bytes = db.export();
  } finally {
    db.run('PRAGMA foreign_keys = ON');
  }
  assert.equal(db.exec('PRAGMA foreign_keys')[0].values[0][0], 1);
  assert.throws(() => db.run('INSERT INTO child VALUES (?, ?)', [4, 99]));
  restored = new SQL.Database(bytes);
  restored.run('PRAGMA foreign_keys = ON');
  assert.deepEqual(restored.exec('SELECT id, parent_id FROM child')[0].values, [[1, 1]]);
  console.log('foreign keys, rollback, and exported bytes checked');
} finally {
  restored?.close();
  db.close();
}

这段代码只创建合成父子记录,不启动 ChatCrystal,不读取或覆盖数据库文件。故意删除 export 后 finally 中的外键恢复语句,外键状态断言应失败;恢复后再通过。成功完成 SQL 事务与成功保存文件,是两项不同的验证。

API 与事务各自保证什么

exec() 返回包含 columns 与 values 的结果集数组;没有行时可能没有结果集。run() 忽略结果行,但返回 Database 本身,并非 undefined。getRowsModified() 读取最近一次 INSERT、UPDATE 或 DELETE 的受影响行数,不是 SELECT 的行数。这些返回边界可对照 官方 Database API。

应用的 withTransaction 用 WeakMap 记录深度:顶层 BEGIN/COMMIT,内层 SAVEPOINT/RELEASE,异常则回滚对应层。回调是同步执行的;把 async 回调传进去会先返回 Promise,封装不会等待它,不能把 await 后的写入算进同一个受保护事务。网络或模型请求应先完成,再进入同步数据库修改段。

保存策略需要单独设计

应用的 saveDatabase 导出 Uint8Array,再用 Buffer.from 与 writeFileSync 写整库;export 的 finally 恢复 foreign_keys。默认自动保存间隔是 30 秒,closeDatabase 也先保存再关闭。定时器不会保证每 30 秒都成功落盘,进程崩溃、磁盘错误或退出超时仍可能丢失未保存更改。

当前实现没有临时文件加原子替换,也没有为多个进程共享文件建立写锁。因此应避免多个服务或桌面实例同时写同一数据目录。对文件大小、导出耗时、峰值内存与恢复结果做实际记录,参见 导入内存预算;不要仅凭 WASM 或内存数据库就给出“秒级”“GB 以下安全”的承诺。

完成练习后,再从 migrations 追踪真实 notes、messages 与关联表的约束。在合成库中验证重复迁移、级联删除和保存后重新加载,才能判断这些路径是否符合预期。

分享:

相关文章

AI 对话格式差异:事件、快照与跨库消息定位

对照五个固定适配器,区分 JSONL 容器、事件语义与 SQLite 键布局,用两个内存数据库观察消息索引、孤立数据和未知版本。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

AI 对话管理方案:四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入,区分原始副本、可读导出与知识库,并用合成清单验证身份和信息保留。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #SQLite

批量导入 1000 条对话:成本拆分与性能验收

从固定导入链路区分解析、事务、WASM 导出与摘要队列,并用临时合成输入检查流式保留范围与数据等价性。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #SQLite #Performance

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。