跳到正文
曾亮懿 ZengLiangYi Blog

sql.js WASM 持久化:内存、导出、外键与保存点

对照 ChatCrystal 的数据库生命周期,运行 sql.js 1.14.2 样本,区分 SQL 提交、二进制导出、文件写入、外键恢复和嵌套回滚。

曾亮懿

曾亮懿

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

SQL 提交成功之后,磁盘上的数据库仍可能没有变化。ChatCrystal 使用 sql.js 处理内存中的 SQLite 数据库,再显式导出和保存。这篇沿固定实现检查初始化、保存、迁移与关闭,并用独立临时数据库观察这些边界。

WASM 资源与数据库文件是两种输入

db/index.ts在尚无实例时初始化 sql.js。开发态使用包的默认 WASM 路径;ELECTRON_PACKAGED 有值时,locateFile 指向 process.resourcesPath 下的 sql-wasm.wasm。electron-builder.yml的 extraResources 与这个路径配合。存在的数据库文件被 readFileSync 整体读取,再传给 SQL.Database;否则创建新数据库。

这种方案减少了本项目数据库绑定对原生 addon 编译的依赖,仍需交付 WASM 资源。不能从“使用 WASM”推断所有平台都已验证、性能只慢固定比例或任意规模都能放进内存。加载、export、Buffer 转换和写入可能同时持有多份字节;本文没有内存峰值或延迟基准。

初始化还请求 journal_mode=WAL、开启 foreign_keys、执行迁移与回填,再保存。发出 WAL PRAGMA 不是已经实现原生数据库文件与 WAL/checkpoint 协议的证明。应实际读取返回模式、核对库版本和持久化方式,避免把虚拟文件系统的行为当作应用磁盘上的并发写入保证。

export 和写文件之间还有可观察的状态

saveDatabase 使用 exportDatabasePreservingForeignKeys 导出 Uint8Array、转换为 Buffer,再 writeFileSync 到数据库路径。包装器在 finally 恢复 foreign_keys=ON:在本篇固定的 sql.js 1.14.2 样本中,裸 export 后该开关变为 0。重新打开的连接也需要自己的连接设置,不能认为文件字节保存了所有 PRAGMA 状态。

当前写入直接覆盖目标文件,没有在该函数中看到临时文件替换、fsync 或多进程写入协调。因此“函数返回”和“断电后持久化保证”是不同验收结论。若需要更强保存策略,应单独设计、故障注入并验证,不能把建议写成已存在的机制。

在临时目录观察保存点与持久化

独立目录安装 npm install --ignore-scripts --save-exact sql.js@1.14.2,将下面保存为 lesson.mjs,用 Node 24+ 运行。它只读写自己创建的临时 fixture.sqlite,结束时清理;不打开真实 ChatCrystal 或个人数据库。保存点使用直接 SQL 演示,未调用应用的 withTransaction。

import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import initSqlJs from 'sql.js';

const SQL = await initSqlJs();
const folder = mkdtempSync(join(tmpdir(), 'sql-persistence-lesson-'));
const file = join(folder, 'fixture.sqlite');
const db = new SQL.Database();
function exportPreservingForeignKeys(activeDb) {
  try {
    return activeDb.export();
  } finally {
    activeDb.run('PRAGMA foreign_keys = ON;');
  }
}
function save() {
  writeFileSync(file, exportPreservingForeignKeys(db));
}
function count(database, table) {
  return database.exec('SELECT count(*) FROM ' + table)[0].values[0][0];
}
function savedParents() {
  const copy = new SQL.Database(readFileSync(file));
  try {
    return count(copy, 'parent');
  } finally {
    copy.close();
  }
}
try {
  db.run(`PRAGMA foreign_keys = ON;
    CREATE TABLE parent(id INTEGER PRIMARY KEY);
    CREATE TABLE child(id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE);
    INSERT INTO parent VALUES (1); INSERT INTO child VALUES (10, 1);`);
  db.export();
  assert.equal(db.exec('PRAGMA foreign_keys')[0].values[0][0], 0);
  save();
  assert.equal(db.exec('PRAGMA foreign_keys')[0].values[0][0], 1);
  db.run(`BEGIN; INSERT INTO parent VALUES (2); INSERT INTO child VALUES (20, 2);
    SAVEPOINT nested; INSERT INTO parent VALUES (3); INSERT INTO child VALUES (30, 3);
    ROLLBACK TO nested; RELEASE nested; COMMIT;`);
  assert.deepEqual(db.exec('SELECT id FROM parent ORDER BY id')[0].values, [[1], [2]]);
  assert.equal(savedParents(), 1);
  save();
  assert.equal(savedParents(), 2);
  db.run('DELETE FROM parent WHERE id = 2');
  assert.equal(count(db, 'child'), 1);
  console.log('savepoint, export, foreign keys, and file boundaries pass');
} finally {
  db.close();
  rmSync(folder, { recursive: true, force: true });
}

样本先确认裸 export 的外键开关变化,再通过包装器保存;内层 SAVEPOINT 回滚后保留外层的两条 parent。内存已提交第二条时,旧文件重开仍只有一条,显式保存后才读到两条。删除第二条 parent 会级联删除 child。

删除包装器的外键恢复语句,开关断言失败;移除 ROLLBACK TO,parent ID 断言失败。再把 save 的写文件操作变成空操作,文件读取或持久化断言会失败。每个反例恢复后应重新通过。它们验证指定行为,不模拟进程崩溃、磁盘损坏或多个写入者。

实际事务包装器只覆盖同步 SQL 回调

transaction.ts通过 WeakMap 记录每个 Database 的嵌套深度,顶层用 BEGIN/COMMIT/ROLLBACK,内层用 SAVEPOINT/RELEASE/ROLLBACK TO,并在成功或失败路径恢复深度。解析本身不自动成为 SQL 事务的一部分;导入器在解析后才进入数据库写入事务。

withTransaction 直接调用 fn,再提交,没有 await。把异步回调传进去会让 SQL 提交与 Promise 完成脱节;TypeScript 泛型不能自动把这类运行时使用改成异步事务。SQL 原子性也不同时涵盖 Vectra 文件和 saveDatabase 的写入。

db/utils.ts的 resultToObjects 只转换 exec 的第一个结果集:用 columns 给每行 values 命名。它没有把所有 SQL 语句结果合并或验证字段类型。

迁移、自动保存与退出需要各自的失败验收

applySchemaMigrations 先执行建表/索引语句,再用 PRAGMA table_info 检查增量列。ensureIndexColumns 对比索引列的数量、顺序和名称,不一致就删除重建。反射检查可避免重复加列,但不等于迁移永远安全;已有数据、外键、索引变化和中断恢复仍需样本。

startAutoSave 默认每 30 秒安排同步 saveDatabase,并避免重复计时器;这不是严格每 30 秒完成一次保存的时钟保证。事件循环阻塞和写入异常影响实际结果。closeDatabase 先停计时器,再保存、关闭并清空实例;保存抛错时后续步骤没有 finally 保证。

openVscdb也把读取的主文件字节载入 sql.js,失败后等 500ms 再试一次,仍失败返回 null。它不把打开的原文件句柄保留为数据库连接,但不保证首次 readFileSync 一定能成功,也没有在这条路径合并原生 WAL。赋值已初始化模块而非缓存初始化中的 Promise,同样不能称为所有并发初始化只执行一次。

继续阅读桌面服务生命周期和向量索引恢复,分别验收关闭和跨存储状态。SQL 提交、export、文件保存与退出成功应分别有证据。sql.js Database 文档可用于核对导出与连接 API。

分享:

相关文章

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

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

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

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

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

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

Vectra 索引内部:元数据文件、取消更新与实例缓存

核对 Vectra 0.15.0 的真实查询和存储路径,在临时索引中观察外部元数据、取消后的文件残留,以及不同实例的缓存状态。

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

从文章到代码

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