Node.js 生态中最流行的 SQLite 绑定是 better-sqlite3,它性能好、API 简洁、同步调用不需要 async/await。但 better-sqlite3 是原生 C++ addon,需要 node-gyp 编译。这意味着:

sql.js 是 SQLite 的纯 WASM 编译版本,通过 Emscripten 把 SQLite 的 C 源码编译成 WebAssembly。它没有原生依赖,npm install sql.js 就能用,不需要编译。代价是性能略低于原生版本(大约慢 20-30%),但对于 ChatCrystal 这种场景(单用户、本地数据库、非高频写入)完全够用。
数据库初始化在 server/src/db/index.ts 的 initDatabase() 中:
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;
}
关键步骤:
electron-builder.yml 的 extraResources 配置复制到 resources/ 目录。locateFile 回调告诉 sql.js 去哪找这个文件。开发环境下使用默认路径(node_modules 内)。readFileSync 读取整个文件到 Buffer,传给 new SQL.Database(buffer)。这是 sql.js 的核心特性——它在内存中操作数据库,初始化时需要把整个文件加载进内存。applySchemaMigrations() 执行建表 SQL 和增量迁移。sql.js 的数据库完全在内存中。这意味着:
db.export() 获取数据库的二进制表示,然后写入文件saveDatabase() 函数负责持久化:
export function saveDatabase(): void {
if (!db) return;
const data = exportDatabasePreservingForeignKeys(db);
const buffer = Buffer.from(data);
writeFileSync(DB_PATH, buffer);
}
exportDatabasePreservingForeignKeys() 是一个包装函数,处理 sql.js 的一个陷阱:
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 实现了定时自动保存:
let saveInterval: ReturnType<typeof setInterval> | null = null;export function startAutoSave(intervalMs = 30_000): void {
if (saveInterval) return;
saveInterval = setInterval(() => saveDatabase(), intervalMs);
}
默认每 30 秒保存一次。这个间隔是权衡的结果:
db.export() 需要序列化整个数据库到内存,频繁调用会增加内存压力除了定时保存,关键操作后也会主动保存。比如导入完成后立即调用 saveDatabase(),确保新导入的数据不会因意外退出而丢失。
sql.js 的 db.exec() 返回格式是 [{ columns: string[], values: unknown[][] }]——列名数组 + 二维值数组。这种格式不够直观,ChatCrystal 提供了一个工具函数:
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"}]。在路由处理中广泛使用,让代码更可读。
server/src/db/transaction.ts 实现了支持嵌套的事务包装器:
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。这保证了导入服务中的事务是原子的——如果一条对话的解析或入库失败,整个对话的写入都会回滚,不会留下半成品数据。
没有 ORM 意味着 schema 迁移要手动管理。ChatCrystal 的策略是:
SCHEMA_SQL 包含所有 CREATE TABLE IF NOT EXISTS 和 CREATE INDEX IF NOT EXISTS——幂等执行,不会重复创建。applySchemaMigrations() 中的 ensureColumn() 函数处理增量列迁移: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() 处理索引的增量更新——如果索引的列定义变了,先删后建:
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);
}
}
Cursor 和 Trae 的适配器需要读取 VS Code 的 state.vscdb 文件。这些文件也是 SQLite,但由 VS Code 进程持有锁。ChatCrystal 的 openVscdb() 函数用 sql.js 以只读方式打开:
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 实例通过模块级单例复用:
let sqlJsInstance: Awaited<ReturnType<typeof initSqlJs>> | null = null;async function getSqlJs() {
if (!sqlJsInstance) sqlJsInstance = await initSqlJs();
return sqlJsInstance;
}
WASM 模块只需要初始化一次,后续所有数据库实例共享同一个 WASM 运行时。
sql.js 的主要限制:
db.export() 需要序列化整个数据库。30 秒自动保存一次,对于 10MB 的数据库,序列化耗时在毫秒级。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…
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。
《聪明开局吧》第420关掰怎么过-第420关掰找出七个常用字图文攻略
《英雄立志传三国志》正版购买指南-Steam抢先体验及配置要求详解
《聪明开局吧》第419关螺蛳粉如何过关-第419关螺蛳粉找出32个常用字图文攻略
Claude开发者国内可以用吗?3种合规接入方法
《聪明开局吧》第418关焙通关方法-第418关焙找出9个常用字图文攻略
《聪明开局吧》第417关玉龙雪山怎么过-第417关玉龙雪山找到18个常用字图文攻略