
本文面向想了解 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.jsChatCrystal 是一个 Electron Node.js 的桌面应用。选 sql.js 而不是 better-sqlite3 的原因Electron 打包简单better-sqlite3 需要为每个目标平台编译原生 addonElectron 版本升级时经常出问题。sql.js 只需打包一个.wasm文件跨环境一致性同一个数据库层在 Node.js 开发环境和 Electron 生产环境完全相同零安装摩擦用户npm install即可使用不需要系统预装任何东西安装和初始化npminstallsql.js初始化数据库是整个流程的第一步。ChatCrystal 的initDatabase()做了四件事加载 WASM、创建/恢复数据库、设置 PRAGMA、执行 Schema 迁移importinitSqlJs,{typeDatabase}fromsql.js;import{readFileSync,writeFileSync,existsSync,mkdirSync}fromnode:fs;import{dirname}fromnode:path;exportasyncfunctioninitDatabase():PromiseDatabase{// 1. 加载 WASM 引擎constSQLawaitinitSqlJs();// 2. 从磁盘恢复或新建数据库if(existsSync(DB_PATH)){constbufferreadFileSync(DB_PATH);dbnewSQL.Database(buffer);}else{dbnewSQL.Database();}// 3. 设置关键 PRAGMAdb.run(PRAGMA journal_mode WAL;);db.run(PRAGMA foreign_keys ON;);// 4. 执行 Schema 迁移applySchemaMigrations(db);saveDatabase();returndb;}两个 PRAGMA 很关键foreign_keys ON让外键约束生效SQLite 默认关闭journal_mode WAL启用预写日志模式。Electron 打包后WASM 文件位置不同需要通过locateFile指定路径constsqlJsOptionsprocess.env.ELECTRON_PACKAGED?{locateFile:()join(resourcesPath,sql-wasm.wasm)}:undefined;constSQLawaitinitSqlJs(sqlJsOptions);核心 APIexec / run / getRowsModifiedsql.js 的 API 表面积很小核心就三个方法。db.exec()—— 查询数据exec执行 SELECT 查询返回一个数组每个元素包含columns列名数组和values二维数组constresultdb.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 等不返回结果集的操作db.run(INSERT INTO tags (name) VALUES (?),[sql.js]);run不返回结果需要配合db.getRowsModified()确认影响行数。db.getRowsModified()—— 检查影响行数db.run(UPDATE conversations SET status ? WHERE id ?,[filtered,convId]);constaffecteddb.getRowsModified();console.log(更新了${affected}条记录);resultToObjects让结果更好用sql.js 的exec返回{columns, values}格式日常开发中我们更习惯对象数组。ChatCrystal 的resultToObjects就做这一件事exportfunctionresultToObjects(result:{columns:string[];values:unknown[][]}[],):Recordstring,unknown[]{if(!result.length)return[];const{columns,values}result[0];returnvalues.map((row){constobj:Recordstring,unknown{};columns.forEach((col,i){obj[col]row[i];});returnobj;});}使用前后对比// 原始格式constrawdb.exec(SELECT id, title FROM notes LIMIT 3);// [{ columns: [id, title], values: [[1, Fastify 入门], [2, WASM 实战]] }]// 转换后constnotesresultToObjects(raw);// [{ id: 1, title: Fastify 入门 }, { id: 2, title: WASM 实战 }]这个函数虽然只有十几行但在整个项目中被大量使用是 sql.js 和应用代码之间的桥梁。Schema 设计外键与索引ChatCrystal 的数据库有 14 张表定义在SCHEMA_SQL常量中。以下是几张核心表的设计-- 对话表记录每条导入的会话CREATETABLEIFNOTEXISTSconversations(idTEXTPRIMARYKEY,sourceTEXTNOTNULLDEFAULTclaude-code,project_dirTEXTNOTNULL,project_nameTEXTNOTNULL,message_countINTEGERDEFAULT0,first_message_atTEXTNOTNULL,last_message_atTEXTNOTNULL,file_pathTEXTNOTNULL,statusTEXTDEFAULTimported,created_atTEXTDEFAULT(datetime(now)));-- 消息表通过外键关联到对话CREATETABLEIFNOTEXISTSmessages(idTEXTPRIMARYKEY,conversation_idTEXTNOTNULL,typeTEXTNOTNULL,roleTEXT,contentTEXTNOTNULL,timestampTEXTNOTNULL,sort_orderINTEGERNOTNULL,FOREIGNKEY(conversation_id)REFERENCESconversations(id)ONDELETECASCADE);-- 标签关联表多对多关系CREATETABLEIFNOTEXISTSnote_tags(note_idINTEGERNOTNULL,tag_idINTEGERNOTNULL,PRIMARYKEY(note_id,tag_id),FOREIGNKEY(note_id)REFERENCESnotes(id)ONDELETECASCADE,FOREIGNKEY(tag_id)REFERENCEStags(id)ONDELETECASCADE);外键的ON DELETE CASCADE保证删除一条对话时其下所有消息和关联数据自动清理。索引对查询性能至关重要。ChatCrystal 在常用的查询字段上都建了索引CREATEINDEXIFNOTEXISTSidx_conversations_projectONconversations(project_dir);CREATEINDEXIFNOTEXISTSidx_conversations_sourceONconversations(source);CREATEINDEXIFNOTEXISTSidx_messages_conversationONmessages(conversation_id);CREATEINDEXIFNOTEXISTSidx_note_tags_tagONnote_tags(tag_id);CREATEINDEXIFNOTEXISTSidx_embeddings_noteONembeddings(note_id);Schema 迁移通过applySchemaMigrations实现。它用PRAGMA table_info检查列是否存在不存在才 ALTER TABLE做到幂等迁移functionensureColumn(db:Database,table:string,column:string,sql:string){constinfodb.exec(PRAGMA table_info(${table}));constcolumnsinfo[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);事务处理withTransactionsql.js 支持标准的BEGIN/COMMIT/ROLLBACK事务。ChatCrystal 封装了一个withTransaction函数额外支持嵌套事务通过 SAVEPOINTexportfunctionwithTransactionT(db:Database,fn:()T):T{constdepthdepthMap.get(db)??0;constisNesteddepth0;constsavepointNamesp_${depth};if(isNested){db.run(SAVEPOINT${savepointName});}else{db.run(BEGIN);}setDepth(db,depth1);try{constresultfn();if(isNested){db.run(RELEASE${savepointName});}else{db.run(COMMIT);}setDepth(db,depth);returnresult;}catch(error){try{if(isNested){db.run(ROLLBACK TO${savepointName});db.run(RELEASE${savepointName});}else{db.run(ROLLBACK);}}finally{setDepth(db,depth);}throwerror;}}核心思路用WeakMapDatabase, number追踪每个数据库实例的事务嵌套深度。顶层用BEGIN/COMMIT嵌套层用SAVEPOINT/RELEASE。任何层级出错都能精确回滚到对应的保存点。使用方式非常简洁withTransaction(db,(){db.run(INSERT INTO notes (...) VALUES (...),[...]);db.run(INSERT INTO note_tags (...) VALUES (...),[...]);// 任何一步失败整个事务回滚});导入对话时ChatCrystal 就用withTransaction把对话和消息的插入包在一起保证原子性。自动保存机制sql.js 是内存数据库所有修改只存在于内存中。如果不手动保存进程退出后数据就丢了。ChatCrystal 用 30 秒间隔的定时器做自动保存letsaveInterval:ReturnTypetypeofsetInterval|nullnull;exportfunctionstartAutoSave(intervalMs30_000):void{if(saveInterval)return;saveIntervalsetInterval(()saveDatabase(),intervalMs);}exportfunctionsaveDatabase():void{if(!db)return;constdataexportDatabasePreservingForeignKeys(db);// 导出为 Uint8Array保持 foreign_keys ONconstbufferBuffer.from(data);writeFileSync(DB_PATH,buffer);// 写入磁盘}db.export()把整个数据库序列化为二进制数组然后写入文件。关闭数据库时也会触发一次保存exportfunctioncloseDatabase():void{stopAutoSave();if(db){saveDatabase();// 最后一次保存db.close();dbnull;}}30 秒是一个折中太频繁会增加 I/O 开销太长则丢失数据的风险更大。对于 ChatCrystal 这种桌面应用30 秒足够安全。需要注意db.export()会重置连接级别的 PRAGMA 设置。ChatCrystal 的exportDatabasePreservingForeignKeys在 export 后重新设置foreign_keys ONexportfunctionexportDatabasePreservingForeignKeys(activeDb:Database):Uint8Array{try{returnactiveDb.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.jsbetter-sqlite3原生 SQLite原生依赖无需要 C 编译器需要系统库Electron 打包简单一个 .wasm复杂原生 addon不适用浏览器支持支持不支持不支持持久化方式手动 export/import直接读写文件直接读写文件性能内存操作WASM 桥接开销原生性能原生性能数据规模限制受内存限制受磁盘限制受磁盘限制适用场景跨平台、Electron、浏览器纯 Node.js 服务端系统级应用如果你的应用只跑在 Node.js 服务端且不需要跨平台better-sqlite3 是更好的选择。但如果需要支持浏览器或 Electron 打包sql.js 几乎是唯一成熟的方案。下一步sql.js GitHub 仓库 — 完整 API 文档和测试用例项目地址github.com/ZengLiangYi/ChatCrystal