目录 展开 收起
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 与关联表的约束。在合成库中验证重复迁移、级联删除和保存后重新加载,才能判断这些路径是否符合预期。