本文面向:想了解 ChatCrystal 数据层实现或评估 sql.js 方案的开发者。 预计阅读时间:12 分钟
传统 SQLite 需要原生 C 编译,无法直接在浏览器或纯 JS 环境中运行。sql.js 通过 WebAssembly 解决了这个问题------把 SQLite 编译成 WASM,让同一份 SQL 引擎跑在 Node.js、浏览器、Electron 等任何支持 WASM 的环境里,零原生依赖。
ChatCrystal 的整个数据层就建立在 sql.js 之上。本文以 ChatCrystal 的实际代码为例,从初始化到事务、从 Schema 设计到自动保存,完整走一遍 sql.js 的生产级用法。
sql.js 是什么
sql.js 是 SQLite 的 WebAssembly 移植版本。它把 SQLite 的 C 源码通过 Emscripten 编译为 .wasm 文件,再用一层薄 JS wrapper 暴露 API。核心特点:
- 零原生编译:不需要 node-gyp、不需要系统装 SQLite、不需要 C 编译器
- 内存数据库:整个数据库加载到内存中操作,读写极快
- 手动持久化:不像原生 SQLite 直接读写文件,需要你显式 export/import 二进制数据
- 跨平台:同一份代码在 Windows、macOS、Linux、浏览器里行为一致
为什么 ChatCrystal 选 sql.js
ChatCrystal 是一个 Electron + Node.js 的桌面应用。选 sql.js 而不是 better-sqlite3 的原因:
- Electron 打包简单 :better-sqlite3 需要为每个目标平台编译原生 addon,Electron 版本升级时经常出问题。sql.js 只需打包一个
.wasm文件 - 跨环境一致性:同一个数据库层在 Node.js 开发环境和 Electron 生产环境完全相同
- 零安装摩擦 :用户
npm install即可使用,不需要系统预装任何东西
安装和初始化
bash
npm install sql.js
初始化数据库是整个流程的第一步。ChatCrystal 的 initDatabase() 做了四件事:加载 WASM、创建/恢复数据库、设置 PRAGMA、执行 Schema 迁移:
ts
import initSqlJs, { type Database } from 'sql.js';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
export async function initDatabase(): Promise<Database> {
// 1. 加载 WASM 引擎
const SQL = await initSqlJs();
// 2. 从磁盘恢复或新建数据库
if (existsSync(DB_PATH)) {
const buffer = readFileSync(DB_PATH);
db = new SQL.Database(buffer);
} else {
db = new SQL.Database();
}
// 3. 设置关键 PRAGMA
db.run('PRAGMA journal_mode = WAL;');
db.run('PRAGMA foreign_keys = ON;');
// 4. 执行 Schema 迁移
applySchemaMigrations(db);
saveDatabase();
return db;
}
两个 PRAGMA 很关键:foreign_keys = ON 让外键约束生效(SQLite 默认关闭),journal_mode = WAL 启用预写日志模式。
Electron 打包后,WASM 文件位置不同,需要通过 locateFile 指定路径:
ts
const sqlJsOptions = process.env.ELECTRON_PACKAGED
? { locateFile: () => join(resourcesPath, 'sql-wasm.wasm') }
: undefined;
const SQL = await initSqlJs(sqlJsOptions);
核心 API:exec / run / getRowsModified
sql.js 的 API 表面积很小,核心就三个方法。
db.exec() ------ 查询数据
exec 执行 SELECT 查询,返回一个数组,每个元素包含 columns(列名数组)和 values(二维数组):
ts
const result = db.exec(
'SELECT file_size, file_mtime FROM conversations WHERE id = ? AND source = ?',
[meta.id, meta.source],
);
// result 的结构:
// [{ columns: ['file_size', 'file_mtime'], values: [[12345, '2026-05-18']] }]
这个返回格式虽然精确,但用起来不太方便------你得记住列的顺序去 values 里取值。ChatCrystal 写了一个 resultToObjects() 工具函数来解决这个问题。
db.run() ------ 写入数据
run 用于 INSERT、UPDATE、DELETE 等不返回结果集的操作:
ts
db.run(
'INSERT INTO tags (name) VALUES (?)',
['sql.js']
);
run 不返回结果,需要配合 db.getRowsModified() 确认影响行数。
db.getRowsModified() ------ 检查影响行数
ts
db.run('UPDATE conversations SET status = ? WHERE id = ?', ['filtered', convId]);
const affected = db.getRowsModified();
console.log(`更新了 ${affected} 条记录`);
resultToObjects:让结果更好用
sql.js 的 exec 返回 {columns, values} 格式,日常开发中我们更习惯对象数组。ChatCrystal 的 resultToObjects 就做这一件事:
ts
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;
});
}
使用前后对比:
ts
// 原始格式
const raw = db.exec('SELECT id, title FROM notes LIMIT 3');
// [{ columns: ['id', 'title'], values: [[1, 'Fastify 入门'], [2, 'WASM 实战']] }]
// 转换后
const notes = resultToObjects(raw);
// [{ id: 1, title: 'Fastify 入门' }, { id: 2, title: 'WASM 实战' }]
这个函数虽然只有十几行,但在整个项目中被大量使用,是 sql.js 和应用代码之间的桥梁。
Schema 设计:外键与索引
ChatCrystal 的数据库有 14 张表,定义在 SCHEMA_SQL 常量中。以下是几张核心表的设计:
sql
-- 对话表:记录每条导入的会话
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'claude-code',
project_dir TEXT NOT NULL,
project_name TEXT NOT NULL,
message_count INTEGER DEFAULT 0,
first_message_at TEXT NOT NULL,
last_message_at TEXT NOT NULL,
file_path TEXT NOT NULL,
status TEXT DEFAULT 'imported',
created_at TEXT DEFAULT (datetime('now'))
);
-- 消息表:通过外键关联到对话
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
type TEXT NOT NULL,
role TEXT,
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
sort_order INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
-- 标签关联表:多对多关系
CREATE TABLE IF NOT EXISTS note_tags (
note_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (note_id, tag_id),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
外键的 ON DELETE CASCADE 保证删除一条对话时,其下所有消息和关联数据自动清理。
索引对查询性能至关重要。ChatCrystal 在常用的查询字段上都建了索引:
sql
CREATE INDEX IF NOT EXISTS idx_conversations_project ON conversations(project_dir);
CREATE INDEX IF NOT EXISTS idx_conversations_source ON conversations(source);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_embeddings_note ON embeddings(note_id);
Schema 迁移通过 applySchemaMigrations 实现。它用 PRAGMA table_info 检查列是否存在,不存在才 ALTER TABLE,做到幂等迁移:
ts
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);
}
}
// 使用
ensureColumn(db, 'notes', 'embedding_status',
"ALTER TABLE notes ADD COLUMN embedding_status TEXT DEFAULT 'pending'");
事务处理:withTransaction
sql.js 支持标准的 BEGIN / COMMIT / ROLLBACK 事务。ChatCrystal 封装了一个 withTransaction 函数,额外支持嵌套事务(通过 SAVEPOINT):
ts
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) {
try {
if (isNested) {
db.run(`ROLLBACK TO ${savepointName}`);
db.run(`RELEASE ${savepointName}`);
} else {
db.run('ROLLBACK');
}
} finally {
setDepth(db, depth);
}
throw error;
}
}
核心思路:用 WeakMap<Database, number> 追踪每个数据库实例的事务嵌套深度。顶层用 BEGIN/COMMIT,嵌套层用 SAVEPOINT/RELEASE。任何层级出错都能精确回滚到对应的保存点。
使用方式非常简洁:
ts
withTransaction(db, () => {
db.run('INSERT INTO notes (...) VALUES (...)', [...]);
db.run('INSERT INTO note_tags (...) VALUES (...)', [...]);
// 任何一步失败,整个事务回滚
});
导入对话时,ChatCrystal 就用 withTransaction 把对话和消息的插入包在一起,保证原子性。
自动保存机制
sql.js 是内存数据库,所有修改只存在于内存中。如果不手动保存,进程退出后数据就丢了。
ChatCrystal 用 30 秒间隔的定时器做自动保存:
ts
let saveInterval: ReturnType<typeof setInterval> | null = null;
export function startAutoSave(intervalMs = 30_000): void {
if (saveInterval) return;
saveInterval = setInterval(() => saveDatabase(), intervalMs);
}
export function saveDatabase(): void {
if (!db) return;
const data = exportDatabasePreservingForeignKeys(db); // 导出为 Uint8Array,保持 foreign_keys = ON
const buffer = Buffer.from(data);
writeFileSync(DB_PATH, buffer); // 写入磁盘
}
db.export() 把整个数据库序列化为二进制数组,然后写入文件。关闭数据库时也会触发一次保存:
ts
export function closeDatabase(): void {
stopAutoSave();
if (db) {
saveDatabase(); // 最后一次保存
db.close();
db = null;
}
}
30 秒是一个折中:太频繁会增加 I/O 开销,太长则丢失数据的风险更大。对于 ChatCrystal 这种桌面应用,30 秒足够安全。
需要注意:db.export() 会重置连接级别的 PRAGMA 设置。ChatCrystal 的 exportDatabasePreservingForeignKeys 在 export 后重新设置 foreign_keys = ON:
ts
export function exportDatabasePreservingForeignKeys(activeDb: Database): Uint8Array {
try {
return activeDb.export();
} finally {
activeDb.run('PRAGMA foreign_keys = ON;');
}
}
性能特点
sql.js 的性能特征和原生 SQLite 有明显区别:
优势:
- 单条 SQL 执行很快,因为是内存操作,没有磁盘 I/O 延迟
- 批量插入在事务中表现良好
- 没有原生模块加载开销
劣势:
- WASM 调用有固定的桥接开销,高频小查询比原生 SQLite 慢
- 整个数据库必须加载到内存,不适合 GB 级数据
db.export()对大数据库有明显的序列化成本
实际感受: ChatCrystal 的数据库通常在几 MB 到几十 MB 级别(几万条消息),在这个规模下 sql.js 完全够用。导入 1000 条对话、执行几十次查询,总耗时在秒级。
sql.js vs better-sqlite3 vs 原生 SQLite
| 维度 | sql.js | better-sqlite3 | 原生 SQLite |
|---|---|---|---|
| 原生依赖 | 无 | 需要 C 编译器 | 需要系统库 |
| Electron 打包 | 简单(一个 .wasm) | 复杂(原生 addon) | 不适用 |
| 浏览器支持 | 支持 | 不支持 | 不支持 |
| 持久化方式 | 手动 export/import | 直接读写文件 | 直接读写文件 |
| 性能 | 内存操作,WASM 桥接开销 | 原生性能 | 原生性能 |
| 数据规模限制 | 受内存限制 | 受磁盘限制 | 受磁盘限制 |
| 适用场景 | 跨平台、Electron、浏览器 | 纯 Node.js 服务端 | 系统级应用 |
如果你的应用只跑在 Node.js 服务端且不需要跨平台,better-sqlite3 是更好的选择。但如果需要支持浏览器或 Electron 打包,sql.js 几乎是唯一成熟的方案。
下一步
- sql.js GitHub 仓库 --- 完整 API 文档和测试用例