---
title: sql.js WASM 持久化：内存、导出、外键与保存点
description: 对照 ChatCrystal 的数据库生命周期，运行 sql.js 1.14.2 样本，区分 SQL 提交、二进制导出、文件写入、外键恢复和嵌套回滚。
image: "https://zengliangyi.com/og/blog/sqljs-wasm-persistence.png"
---

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

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

[db/index.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/db/index.ts)在尚无实例时初始化 sql.js。开发态使用包的默认 WASM 路径；ELECTRON_PACKAGED 有值时，locateFile 指向 process.resourcesPath 下的 sql-wasm.wasm。[electron-builder.yml](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/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。



```js
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](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/db/transaction.ts)通过 WeakMap 记录每个 Database 的嵌套深度，顶层用 BEGIN/COMMIT/ROLLBACK，内层用 SAVEPOINT/RELEASE/ROLLBACK TO，并在成功或失败路径恢复深度。解析本身不自动成为 SQL 事务的一部分；[导入器](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/import.ts)在解析后才进入数据库写入事务。

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

[db/utils.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/db/utils.ts)的 resultToObjects 只转换 exec 的第一个结果集：用 columns 给每行 values 命名。它没有把所有 SQL 语句结果合并或验证字段类型。

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

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

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

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

继续阅读[桌面服务生命周期](/blog/fastify-electron-service-lifecycle/)和[向量索引恢复](/blog/vectra-index-recovery/)，分别验收关闭和跨存储状态。SQL 提交、export、文件保存与退出成功应分别有证据。[sql.js Database 文档](https://sql.js.org/documentation/Database.html)可用于核对导出与连接 API。

分享：

##  相关文章

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

对照五个固定适配器，区分 JSONL 容器、事件语义与 SQLite 键布局，用两个内存数据库观察消息索引、孤立数据和未知版本。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-format-divergence)    [##  AI 对话管理方案：四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入，区分原始副本、可读导出与知识库，并用合成清单验证身份和信息保留。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-management-options)    [##  Vectra 索引内部：元数据文件、取消更新与实例缓存

核对 Vectra 0.15.0 的真实查询和存储路径，在临时索引中观察外部元数据、取消后的文件残留，以及不同实例的缓存状态。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/vectra-search-internals)

##  从文章到代码

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

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "sql.js WASM 持久化：内存、导出、外键与保存点",
  "description": "对照 ChatCrystal 的数据库生命周期，运行 sql.js 1.14.2 样本，区分 SQL 提交、二进制导出、文件写入、外键恢复和嵌套回滚。",
  "url": "https://zengliangyi.com/blog/sqljs-wasm-persistence/",
  "image": "https://zengliangyi.com/images/blog/sqljs-wasm-persistence.svg",
  "datePublished": "2026-10-07T00:00:00.000Z",
  "dateModified": "2026-10-07T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/sqljs-wasm-persistence/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "sql.js WASM 持久化：内存、导出、外键与保存点",
      "item": "https://zengliangyi.com/blog/sqljs-wasm-persistence/"
    }
  ]
}
```
