本文面向:想用纯 WASM 版 SQLite 避免原生编译的 Node.js / Electron 开发者。
预计阅读时间:10 分钟
最终效果:理解 sql.js 的内存模型、自动保存、嵌套事务、增量迁移,以及只读打开 VS Code state.vscdb 的方案。
为什么不用 better-sqlite3
Node.js 生态中最流行的 SQLite 绑定是 better-sqlite3,它性能好、API 简洁、同步调用不需要 async/await。但 better-sqlite3 是原生 C++ addon,需要 node-gyp 编译。这意味着:
- 安装时需要 Python + C++ 编译工具链
- Electron 打包时需要针对目标平台重新编译
- 不同 Node.js/Electron 版本需要不同的预编译二进制
sql.js 是 SQLite 的纯 WASM 编译版本,通过 Emscripten 把 SQLite 的 C 源码编译成 WebAssembly。它没有原生依赖,npm install sql.js 就能用,不需要编译。代价是性能略低于原生版本(大约慢 20-30%),但对于 ChatCrystal 这种场景(单用户、本地数据库、非高频写入)完全够用。
初始化流程
数据库初始化在 server/src/db/index.ts 的 initDatabase() 中:
typescript
export async function initDatabase(): Promise<Database> {
const sqlJsOptions = process.env.ELECTRON_PACKAGED
? { locateFile: () => join(process.resourcesPath, 'sql-wasm.wasm') }
: undefined;
const SQL = await initSqlJs(sqlJsOptions);
if (existsSync(DB_PATH)) {
const buffer = readFileSync(DB_PATH);
db = new SQL.Database(buffer);
} else {
db = new SQL.Database();
}
db.run('PRAGMA journal_mode = WAL;');
db.run('PRAGMA foreign_keys = ON;');
applySchemaMigrations(db);
saveDatabase();
return db;
}
关键步骤:
-
WASM 定位: 打包后的 Electron 应用中,WASM 文件通过
electron-builder.yml的extraResources配置复制到resources/目录。locateFile回调告诉 sql.js 去哪找这个文件。开发环境下使用默认路径(node_modules 内)。 -
数据库加载: 如果数据库文件已存在,用
readFileSync读取整个文件到Buffer,传给new SQL.Database(buffer)。这是 sql.js 的核心特性------它在内存中操作数据库,初始化时需要把整个文件加载进内存。 -
PRAGMA 设置: 启用 WAL(Write-Ahead Logging)模式和外键约束。WAL 模式在 sql.js 中的意义有限(因为没有真正的并发写入),但保持与原生 SQLite 的一致性。
-
Schema 迁移:
applySchemaMigrations()执行建表 SQL 和增量迁移。
内存模型:全量加载,手动持久化
sql.js 的数据库完全在内存中。这意味着:
- 所有读写操作都在内存中完成,速度很快
- 但修改不会自动写入磁盘
- 需要手动调用
db.export()获取数据库的二进制表示,然后写入文件
saveDatabase() 函数负责持久化:
typescript
export function saveDatabase(): void {
if (!db) return;
const data = exportDatabasePreservingForeignKeys(db);
const buffer = Buffer.from(data);
writeFileSync(DB_PATH, buffer);
}
exportDatabasePreservingForeignKeys() 是一个包装函数,处理 sql.js 的一个陷阱:
typescript
export function exportDatabasePreservingForeignKeys(activeDb: Database): Uint8Array {
try {
return activeDb.export();
} finally {
activeDb.run('PRAGMA foreign_keys = ON;');
}
}
db.export() 会重置数据库连接的所有 PRAGMA 设置,包括 foreign_keys。所以在 export 之后必须重新启用外键约束。这个 bug 在 sql.js 的 issue 中有记录,ChatCrystal 用 wrapper 函数统一处理。
自动保存机制
手动保存容易遗漏,所以 ChatCrystal 实现了定时自动保存:
typescript
let saveInterval: ReturnType<typeof setInterval> | null = null;
export function startAutoSave(intervalMs = 30_000): void {
if (saveInterval) return;
saveInterval = setInterval(() => saveDatabase(), intervalMs);
}
默认每 30 秒保存一次。这个间隔是权衡的结果:
- 太频繁:
db.export()需要序列化整个数据库到内存,频繁调用会增加内存压力 - 太稀疏:进程崩溃时可能丢失最近 30 秒的数据
- 30 秒是一个合理的折中
除了定时保存,关键操作后也会主动保存。比如导入完成后立即调用 saveDatabase(),确保新导入的数据不会因意外退出而丢失。
resultToObjects:查询结果的标准化
sql.js 的 db.exec() 返回格式是 [{ columns: string[], values: unknown[][] }]------列名数组 + 二维值数组。这种格式不够直观,ChatCrystal 提供了一个工具函数:
typescript
export function resultToObjects(
result: { columns: string[]; values: unknown[][] }[],
): Record<string, unknown>[] {
if (!result.length) return [];
const { columns, values } = result[0];
return values.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((col, i) => { obj[col] = row[i]; });
return obj;
});
}
把 [{columns: ["id", "name"], values: [["1", "foo"]]}] 转成 [{id: "1", name: "foo"}]。在路由处理中广泛使用,让代码更可读。
事务支持:嵌套 SAVEPOINT
server/src/db/transaction.ts 实现了支持嵌套的事务包装器:
typescript
const depthMap = new WeakMap<Database, number>();
function setDepth(db: Database, depth: number): void {
if (depth === 0) depthMap.delete(db);
else depthMap.set(db, depth);
}
export function withTransaction<T>(db: Database, fn: () => T): T {
const depth = depthMap.get(db) ?? 0;
const isNested = depth > 0;
const savepointName = `sp_${depth}`;
if (isNested) {
db.run(`SAVEPOINT ${savepointName}`);
} else {
db.run('BEGIN');
}
setDepth(db, depth + 1);
try {
const result = fn();
if (isNested) {
db.run(`RELEASE ${savepointName}`);
} else {
db.run('COMMIT');
}
setDepth(db, depth);
return result;
} catch (error) {
if (isNested) {
db.run(`ROLLBACK TO ${savepointName}`);
db.run(`RELEASE ${savepointName}`);
} else {
db.run('ROLLBACK');
}
setDepth(db, depth);
throw error;
}
}
用 WeakMap<Database, number> 跟踪每个数据库实例的事务嵌套深度。顶层事务用 BEGIN/COMMIT/ROLLBACK,嵌套事务用 SAVEPOINT/RELEASE/ROLLBACK TO。这保证了导入服务中的事务是原子的------如果一条对话的解析或入库失败,整个对话的写入都会回滚,不会留下半成品数据。
Schema 迁移:无 ORM 的增量方案
没有 ORM 意味着 schema 迁移要手动管理。ChatCrystal 的策略是:
-
SCHEMA_SQL包含所有CREATE TABLE IF NOT EXISTS和CREATE INDEX IF NOT EXISTS------幂等执行,不会重复创建。 -
applySchemaMigrations()中的ensureColumn()函数处理增量列迁移:
typescript
function ensureColumn(db: Database, table: string, column: string, sql: string) {
const info = db.exec(`PRAGMA table_info(${table})`);
const columns = info[0]?.values.map((row) => String(row[1])) ?? [];
if (!columns.includes(column)) {
db.run(sql);
}
}
用 PRAGMA table_info 检查列是否存在,不存在就执行 ALTER TABLE ADD COLUMN。这种模式比版本号迁移更简单,适合单机应用的场景。
ensureIndexColumns() 处理索引的增量更新------如果索引的列定义变了,先删后建:
typescript
function ensureIndexColumns(db, indexName, expectedColumns, createSql) {
const info = db.exec(`PRAGMA index_info('${indexName}')`);
const columns = info[0]?.values.map((row) => String(row[2])) ?? [];
const isCurrent = columns.length === expectedColumns.length &&
columns.every((column, index) => column === expectedColumns[index]);
if (!isCurrent) {
db.run(`DROP INDEX IF EXISTS ${indexName}`);
db.run(createSql);
}
}
与 VSCDB 的复用
Cursor 和 Trae 的适配器需要读取 VS Code 的 state.vscdb 文件。这些文件也是 SQLite,但由 VS Code 进程持有锁。ChatCrystal 的 openVscdb() 函数用 sql.js 以只读方式打开:
typescript
export async function openVscdb(dbPath: string): Promise<Database | null> {
try {
const SQL = await getSqlJs();
const buf = readFileSync(dbPath);
return new SQL.Database(buf);
} catch {
await new Promise((r) => setTimeout(r, 500));
try {
const SQL = await getSqlJs();
const buf = readFileSync(dbPath);
return new SQL.Database(buf);
} catch {
return null;
}
}
}
由于 sql.js 把整个文件读入内存再创建数据库实例,它不持有文件句柄------读完就可以释放。这天然避免了与 VS Code 进程的文件锁冲突。如果读取时文件被锁(VS Code 正在写入),等待 500ms 重试一次。
sql.js 实例通过模块级单例复用:
typescript
let sqlJsInstance: Awaited<ReturnType<typeof initSqlJs>> | null = null;
async function getSqlJs() {
if (!sqlJsInstance) sqlJsInstance = await initSqlJs();
return sqlJsInstance;
}
WASM 模块只需要初始化一次,后续所有数据库实例共享同一个 WASM 运行时。
性能与限制
sql.js 的主要限制:
- 内存占用: 整个数据库加载到内存。ChatCrystal 的典型数据库大小在几 MB 到几十 MB,完全在可接受范围内。
- 并发: 单线程操作,没有真正的并发写入。但 ChatCrystal 的写入场景(导入、摘要生成)本身就是串行的(p-queue 并发度为 1),所以这不是问题。
- export 开销:
db.export()需要序列化整个数据库。30 秒自动保存一次,对于 10MB 的数据库,序列化耗时在毫秒级。 - 无 WAL 支持: sql.js 的 WAL 模式是模拟的,没有真正的 checkpoint 机制。但对于单进程应用,这不影响数据安全。
总结
sql.js 让 ChatCrystal 避免了原生编译的麻烦,同时提供了完整的 SQLite 功能。内存模型虽然限制了数据库大小的上限,但对于本地知识库应用完全够用。自动保存 + 事务支持 + 增量迁移,三个机制组合起来保证了数据的持久性和一致性。openVscdb() 的只读内存打开方式,巧妙地解决了与 VS Code 进程的文件锁冲突问题。
源码参考:db/index.ts · db/schema.ts · db/transaction.ts · db/utils.ts · parser/vscdb.ts
项目地址:github.com/ZengLiangYi/ChatCrystal
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。