@evolu/sqlite-wasm

中文文档
3.54.1-build3发布于 2026/10/30MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 1.2万

SQLite compiled to WebAssembly with Evolu's SQLite core, which encrypts databases, and Evolu's own TypeScript layer

原始使用文档
文档目录8 节
  1. Why our own TypeScript
  2. The WebAssembly
  3. Bundlers
  4. Versions
  5. Credits
  6. Documentation
  7. Source, examples, and tests
  8. Community

Evolu SQLite Wasm

This package is SQLite compiled to WebAssembly with Evolu's own minimal TypeScript layer for loading and using it. Evolu's SQLite core, in C and linked into the WebAssembly, encrypts databases.

Why our own TypeScript

SQLite's WebAssembly build ships a general-purpose JavaScript layer. It's about 15,000 lines of source covering the whole C API, several storage VFSes, and a worker API, plus Emscripten's generated runtime.

We replace that JavaScript with our own, written to be as correct as possible. It's TypeScript throughout, with branded pointer types and typed errors, instead of loosely typed JavaScript with hand-written declarations. We ported the tests of the SQLite team and of wa-sqlite for what this package ships. We also wrote our own tests for the failure paths that matter for local-first data, such as recovery after an interrupted transaction, storage running out, and lock conflicts between connections. They run in CI in Chromium, Firefox, and WebKit.

We reimplement SQLite's JavaScript strictly test-first: red, green, refactor. Every behavior starts as a failing test, ported from the SQLite team's or wa-sqlite's tests or written for our failure paths. Only then do we add code, the least that makes the test pass, and we clean it up while the tests stay green.

These failure paths are real. While preparing this package in September 2026, we found that opfs-sahpool, the VFS Evolu relies on, never rolled back a transaction interrupted by closing a tab. The next open saw it half-applied. Fixing that uncovered a second problem: overlapping connections in one worker could silently lose committed data. Evolu opens only one connection, but apps and libraries that open several were exposed. Now they get SQLITE_BUSY instead. We reported both, and the SQLite team fixed them within a day, in SQLite 3.54.0. The forum thread has the details and reproductions.

We also asked for SQLITE_FULL when the browser says storage is full, so an app can tell its users instead of showing a generic disk I/O error. The SQLite team declined. Their JavaScript avoids browser-specific behavior, so every failed write stays SQLITE_IOERR. But the main signal isn't browser-specific. The File System Standard requires a sync access handle's write() and truncate() to throw QuotaExceededError when the quota would be exceeded. Our layer reports SQLITE_FULL for it and for Firefox's short writes. We test that with a real quota in Chromium and Firefox.

Every public SQLite C function the WebAssembly exports can be imported as a standalone function from @evolu/sqlite-wasm/c-api, so an app pays only for the functions it uses. We generate them from SQLite's own signature table and check them against the binary. The functions of Evolu's SQLite core that the layer calls stay internal. The variadic sqlite3_config, sqlite3_db_config and sqlite3_vtab_config go through the build's fixed-argument shims, as in SQLite's JavaScript. A small loader replaces Emscripten's generated runtime. Together, that makes the JavaScript much smaller. The layer is synchronous, and the high-level API returns typed results.

Of SQLite's storage VFSes, we keep only opfs-sahpool. Evolu already uses it, and it fits Evolu best. It's synchronous, reading and writing through OPFS sync access handles directly in the worker, without a helper worker. It doesn't need cross-origin isolation. SQLite's "opfs" VFS needs SharedArrayBuffer, which requires the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers and the restrictions they bring. opfs-sahpool needs neither. And it's the fastest. SQLite's documentation calls it the highest-performing of its OPFS VFSes, clearly so for batch operations.

The catch is that only one tab or worker can have a database open at a time. As SQLite documents for opfs-sahpool, one pool may use a directory at a time, across workers and wasm instances. So opens of a directory must be serialized, and Evolu does that with its leader Web Lock. Otherwise, two openers of a new or empty directory can each create their own slots. On the next open, only one version of a file shows, and the other is kept hidden and never reused. We keep opfs-sahpool's slots byte for byte, so existing databases keep working. If Evolu ever needs another VFS, such as one with an OPFS file per database, new databases can be created on it while existing ones keep working in the pool, without a migration.

Evolu's SQLite core, its own C in @evolu/sqlite-core, encrypts databases inside the WebAssembly. The pool registers its VFS under an internal name and the core's codec over it under the pool's name, then unregisters the VFS, so every file SQL reaches through the pool goes through the codec. The core encrypts with BearSSL's bitsliced constant-time AES, aes_ct64, and its SHA-512, and with its own HMAC-SHA512 and PBKDF2. Unlike table-based AES, whose memory accesses depend on the key, it leaves no trace of the key in the CPU's caches for code running beside it, such as another tab or app. scripts/constant-time.mts checks that the compiled functions branch on no secret and index no memory by one, and scripts/constant-time.md keeps the review. WebAssembly and its JIT compilers promise no constant time, so this holds as long as an engine adds no secret-dependent branch or memory access of its own.

The format is SQLCipher 4's as the sqlcipher scheme of SQLite3 Multiple Ciphers writes it. So the databases @evolu/web 3 encrypted with @evolu/sqlite-wasm 2.2.4 open unchanged, and 2.2.4 opens what we write. Every page, also in a rollback journal, is encrypted with AES-256-CBC and a random IV and authenticated with HMAC-SHA512. SQLite never sees the key: the layer stages it in wasm memory for the open and zeroes it right after, and the core keeps its keys in wasm memory until the database is disposed, or after a trap until the instance ends. SQLite's heap shares that memory, so a memory-disclosure bug in SQLite or sqlite3-wasm.c could reach the keys, where with 3.54.0-build2, whose pool encrypted in TypeScript, it could reach plaintext but not the key. src/evolu_page.h of @evolu/sqlite-core documents the format. @evolu/web 1.0.1-preview.6 to 2.4.0 encrypted databases with PRAGMA legacy = 4, SQLCipher 4's own format with page 1 encrypted from byte 16. Those databases have not opened since @evolu/web 3.0.0, and they don't open here either.

@evolu/web passed 2.2.4 the key as the SQL text x'<hex>', SQLCipher's notation for a raw key. SQLite3 Multiple Ciphers 2.2.4 treated it as a passphrase because of a bug, fixed in its 2.2.5. So the databases @evolu/web 3 encrypted with 2.2.4 open with the key derived from that text, as before. They are never rekeyed automatically. A database we create is encrypted with the key itself, which 2.2.4 opens only when given the key in its raw-key notation, raw:<hex>. When a crash tore a legacy database's salt, its first 16 bytes, the salt that its hot journal's page-1 record stores opens it.

Only a database file and its journal are encrypted. 2.2.4 encrypted the files VACUUM INTO and ATTACH write with the database's key. Here, VACUUM INTO writes an unencrypted file, and a File or EncryptedFile database's ATTACH attaches a file read-only, ignoring its KEY clause, and creates no file. PRAGMA key, rekey, cipher and their variants fail on a file database, encrypted or not, rather than being ignored, so no database is keyed through SQL, while a memory database still ignores them. An encrypted database keeps secure_delete on, as 2.2.4 turned it on. Unlike 2.2.4, it refuses to turn it off, because a freed page SQLite doesn't write would fail to authenticate. Our tests compare the codec with 2.2.4 itself and with 3.54.0-build2, whose pool encrypted in TypeScript, in Node.js and in Chromium, Firefox and WebKit, and with an implementation of the format on node:crypto.

Tabs of an earlier Evolu version can share the pool with a newer one, taking turns on Evolu's leader lock. In SQLite's default DELETE journal mode, which Evolu keeps, this build and 3.53.4-build1 to 3.54.0-build2, whose pool encrypted in TypeScript, read and extend each other's databases and roll back each other's hot journals. A journal SQLite keeps, in PERSIST mode or with EXCLUSIVE locking and a database recreated at its path, can make those builds keep a half-applied transaction, so sharing a pool is supported in DELETE mode only. 2.2.4 never rolls back a hot journal, because its opfs-sahpool reports every file as locked. After a crash here, a 2.2.4 tab reads the half-written rows, and its next write transaction makes them permanent.

Constant-time AES is slower than the table-based AES of @awasm/noble, which encrypted 3.53.4-build1 to 3.54.0-build2. On an Apple M5, decrypting an 8 KiB page takes 60 to 70 µs, and writing one, with its journal record, about 300 µs, against 40 to 60 µs and about 100 µs with 3.54.0-build2, alike in Chromium, Firefox and WebKit. In bench:sqlite-wasm, wa-sqlite's workloads other than the first take 50% to 80% longer on an encrypted database than with 3.54.0-build2. Small commits pay more. Right after the storage idled, as in an app, encryption adds 2.5 to 2.7 ms to a small commit in all three engines, against 1.4 to 1.9 ms with 3.54.0-build2. Back-to-back commits in Chromium on macOS cost 2 to 4 ms more each, against 0.7 ms in Firefox and WebKit, mostly in slower flushes: Chromium's OPFS flushes with F_BARRIERFSYNC, which on macOS slows down after pauses in storage activity of about 100 to 200 µs, and encryption makes such pauses.

We keep constant-time AES anyway, because protecting the key matters more than raw speed. A small commit takes milliseconds longer, and the cost grows only with the pages a transaction writes, which is little against what the encryption protects. The price is temporary. aes_ct64 encrypts four blocks per pass, but CBC encryption chains each block to the one before, so encrypting a page leaves three of its four lanes empty, while decryption fills them. Once SQLCipher 5.0 is released, Evolu's core switches to its page format, AES-256-GCM (#757), whose counter mode doesn't chain blocks, so encrypting a page fills all four lanes and should cost about what decrypting one costs now.

The binary doesn't contain SQLite3 Multiple Ciphers, which 2.2.4 encrypts with. It's SQLite's own C from sqlite.org, compiled with SQLite's own makefile and linked with Evolu's SQLite core, plus Emscripten's output. The core's codec, page format and cryptography, evolu_codec.c, evolu_page.c and evolu_crypto.c without their headers, are 2,066 lines of C, part of which only native builds compile, over 1,296 lines of BearSSL's aes_ct64 and SHA-512 with the byte helpers they call, where 3.53.4-build1 to 3.54.0-build2 encrypted with 975 lines of TypeScript and @awasm/noble. In SQLite's full-featured build of 3.53.4, before the core, here is what leaving out SQLite3 Multiple Ciphers 2.5.1 gave:

  • The C source the build compiled was 100,241 lines shorter. It was SQLite's 269,649-line sqlite3.c instead of the 369,890-line SQLite3 Multiple Ciphers amalgamation. About 72,500 of the extra lines were third-party code: its cipher schemes, their cryptography, and bundled libraries such as AEGIS, miniz and Argon2. The rest were SQLite's own code: a second copy of sqlite3.h, sqlite3ext.h, tclsqlite.c and extensions from ext/misc.
  • The wasm went from 1,068,015 to 868,886 bytes, and from 473,354 to 401,985 bytes with gzip -9.
  • The wasm exports 216 public C functions instead of 232. The 11 sqlite3mc_* functions are gone. So is the API of SQLite's encryption extension that SQLite3 Multiple Ciphers implemented: sqlite3_key, sqlite3_key_v2, sqlite3_rekey, sqlite3_rekey_v2 and sqlite3_activate_see.

There's also less to trust. 2.2.4 repackaged a WebAssembly binary another project built, with that project's cipher code and bundled libraries inside. We build ours in CI from SQLite's source, pinned by the hash sqlite.org publishes, with Evolu's SQLite core and the BearSSL files it vendors, pinned by commit and checked by their digests, and the build reproduces the same bytes. The package has no runtime dependencies, only a peer dependency, @evolu/common, Evolu's own, whose Result and Task its API returns. 2.2.4 had none. That leaves less code to audit and fewer places for a supply-chain attack to hide.

We don't ship that full-featured build, though. We ship SQLite's bare-bones configuration, chosen with the makefile's own variables. It comes with JSON, which Evolu queries, and the math functions, which apps can call through Evolu's query builder. It also keeps temporary files, such as sorts, temporary tables and statement journals, in memory. It omits WAL, the authorizer, the progress callback, incremental blob I/O and the introspection pragmas. Evolu's own SQL tests pass on it as they do on Evolu's native builds. The C API covers only what we build. FTS5, R*Tree, the session extension, the preupdate hook, the column metadata functions sqlite3_column_database_name, sqlite3_column_table_name and sqlite3_column_origin_name, and the registration of virtual tables and window functions aren't there. If you need one of them, start a discussion. Compared with SQLite's full-featured build of 3.53.4 without SQLite3 Multiple Ciphers:

  • Less C is compiled. Thirteen files of sqlite3.c, 49,818 of its 269,649 lines, contribute nothing, among them fts5.c, sqlite3session.c, wal.c and rtree.c. After the preprocessor, SQLite's C in our 3.53.4 build had 87,381 non-blank lines instead of 119,158, before Evolu's SQLite core was linked in.
  • The wasm went from 868,886 to 649,755 bytes, from 401,985 to 298,537 with gzip -9, and from 348,808 to 259,521 with Brotli at quality 11. With SQLite3 Multiple Ciphers, the full-featured build was 1,068,015 bytes, 473,354 with gzip -9. The stack overflow check we add, described below, made our 3.53.4 binary 669,403 bytes, 302,491 with gzip -9 and 262,026 with Brotli. The 3.54.0 binary, which also exports the backup API, was 677,479 bytes, 305,733 with gzip -9 and 265,111 with Brotli. The 3.54.1 binary we ship now, which also contains Evolu's SQLite core, is 720,542 bytes, 323,059 with gzip -9 and 279,123 with Brotli. The JavaScript shrank more than the wasm grew: what @evolu/web imports from the package is 39,956 bytes minified by esbuild, 13,493 with gzip -9, where it was 194,844 and 67,501 with @awasm/noble. A worker that encrypts also no longer allocates @awasm/noble's two WebAssembly memories, 30 MiB.
  • The wasm exports 156 public C functions instead of 216, and imports 32 functions instead of 35, besides its memory and the stack overflow check's handler. Since 3.54.0, the bare-bones build also exports the five functions of the backup API, so ours exports 161. Since 3.54.1-build1, it also exports the five functions of Evolu's SQLite core the layer calls and imports WASI's random_get, from which the core draws salts and IVs.

Evolu opens one connection per database and runs one operation at a time. There's no SQLITE_BUSY to handle and no interleaving to reason about. More connections in one worker wouldn't add parallelism anyway. They all run on the same JavaScript thread, and with a rollback journal, a reader still blocks another connection's commit. And because every call is synchronous, a connection that gets SQLITE_BUSY can't even wait for the other one to finish. It can only yield and retry, which is serializing with extra steps. We prefer this simple mental model, but we're open to revisiting it if a real use case appears. Feel free to start a discussion.

We watch upstream changes to SQLite's JavaScript and incorporate them after our own audit. Our loader also depends on the functions the WebAssembly imports and exports, and those come from the Emscripten version, SQLite's build settings and the functions of Evolu's SQLite core that the layer calls. The generator pins the imports and exports with their wasm types, together with SQLite's constants and struct layouts. A check fails whenever a new build differs in any of them, so every toolchain upgrade gets the same review. The loader refuses a binary whose constants or export names differ from the pinned ones, or that needs an import it doesn't implement. Neither the check nor the loader compares code or build settings that change none of these, such as SQLITE_TEMP_STORE. A test pins those with PRAGMA compile_options.

The WebAssembly

We build the .wasm ourselves with scripts/build-wasm.mts. It's SQLite 3.54.1 from sqlite.org's source zip, unpatched, the release @evolu/sqlite-core pins, compiled by Emscripten 6.0.3 with SQLite's own makefile, in its bare-bones configuration with the options above, plus Emscripten's stack overflow check, and linked with Evolu's SQLite core and the BearSSL code it vendors, which emcc compiles with -O2. It also writes a symbol map, wasm/sqlite3.symbols, with the function names the stripped wasm leaves out. The source is pinned by the SHA3-256 sqlite.org publishes. Emscripten is pinned by its version, and in CI by the image digest too. The output's sha256 covers the rest: the build fails unless it equals the pinned one and the wasm matches the generator's pins. Two builds in fresh work directories gave the same bytes. The SQLite Wasm job of the Checks workflow builds it in CI, in the official Emscripten image pinned by digest. When no input changed, the job restores the wasm from a cache instead. Either way, it hands the wasm to the jobs that test it. Built files are never committed.

The tests and playgrounds of the Evolu repository load wasm/sqlite3.wasm and fail when it's missing or isn't the pinned wasm. To get it without building it, run pnpm sqlite-wasm:download in the repository root. It downloads the version in this package's package.json and writes the package's wasm only if its sha256 matches the pin in scripts/check-wasm.mts. Before that version is published, the download fails with HTTP 404. If the pin changes before the package version, the downloaded wasm has a different checksum. In either case, build it locally as described below. The examples load the wasm too, but they don't check it, because they're templates to copy.

To build or verify it, use an emsdk that has Emscripten 6.0.3 installed and activated. If the build succeeds, it has reproduced the pinned bytes:

EMSDK=/path/to/emsdk node scripts/build-wasm.mts

Without emsdk, build it with Docker 28 or later in the Emscripten image CI uses, pinned by the same digest. Run pnpm install first, then this in the repository root. The image's Node.js is older than the repository's, so the command mounts the repository's version from the official node image, which Docker doesn't pull for a mount by itself. A build in this image on Linux gave the same bytes as one with emsdk on macOS:

docker pull node:24.22.0-slim
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v evolu-sqlite-wasm:/tmp \
  -v "$PWD":/src -w /src \
  --mount type=image,source=node:24.22.0-slim,target=/node \
  emscripten/emsdk:6.0.3@sha256:bb0910e6a18bb9bd7cb31ae4ed40f9073148b78cb2cdb8ea8676454e0d85425c \
  /node/usr/local/bin/node packages/sqlite-wasm/scripts/build-wasm.mts

The build runs in the evolu-sqlite-wasm volume rather than in the repository, because building on a macOS host's file share can lose the permissions SQLite's configure sets. A failed build keeps its work directory there, so to pin the wasm it names, run packages/sqlite-wasm/scripts/generate.mts --pin-build with that path in the same command instead of build-wasm.mts. docker volume rm evolu-sqlite-wasm removes the work directories.

SQLite's makefile puts the 512 KiB C stack directly above SQLite's static data. In SQLite's own build, SQL that is legal but nests deeply, such as a chain of 600 triggers where SQLite allows 1,000, runs the stack into that data and overwrites it without an error. With 2.2.4, such an insert never returned. Built without the check, our binary was left failing with SQLITE_NOMEM, even to open another database. So we build with -sSTACK_OVERFLOW_CHECK=2. With it, every function compares the stack pointer it sets with the stack's limits, and our loader turns an overflow into a WebAssembly.RuntimeError, which breaks the instance as any trap does. It added 19,648 bytes to our 3.53.4 binary, 3,954 with gzip -9. In Node.js 24 on an Apple M5, our micro benchmarks of run and exec on an in-memory database take 1% to 5% longer with it.

Bundlers

sqliteWasmUrl is new URL("../wasm/sqlite3.wasm", import.meta.url). Vite, webpack, Next.js and other bundlers recognize it. They emit the binary with the app and give the URL of their copy. Pass fetch(sqliteWasmUrl) to createSqliteWasm. It compiles the binary while it downloads if the server sends it as exactly application/wasm, without parameters, which is the type WebAssembly.compileStreaming requires. Otherwise, it compiles the binary from its bytes once it has downloaded.

Vite doesn't process the URL in a dependency it prebundles, so add @evolu/sqlite-wasm to optimizeDeps.exclude. Vite also emits the binary whenever the app imports @evolu/sqlite-wasm, even when the app loads its own binary instead, so a PWA that precaches *.wasm downloads it too. webpack emits it only when the app uses sqliteWasmUrl.

Versions

As in SQLite's own npm package, the version is the version of SQLite the package builds, followed by -build<n>. 3.53.4-build1 is our first build of SQLite 3.53.4, 3.53.4-build2 the next one, and a new SQLite release starts again at -build1. The build number counts our releases, and a release may also change the TypeScript API. Pin the exact version and read the release notes before upgrading. npm treats -build<n> as a prerelease, so a range such as ^3.53.4-build1 never reaches a build of another SQLite release. Semver compares build10 with build9 as text, so a tenth build would sort below the ninth. The repository's scripts/version-sqlite-wasm.mts refuses it, so a SQLite release gets at most nine builds, and a tenth needs a new SQLite pin.

Credits

  • SQLite, public domain.
  • BearSSL by Thomas Pornin, MIT License, whose constant-time AES and SHA-512 encrypt databases and whose SHA-256 the skiplist functions use.
  • Emscripten, MIT License, which compiles the WebAssembly and links musl's C library into it.

THIRD_PARTY_NOTICES.md holds the notices the WebAssembly's licenses ask for.

Documentation

For detailed information and usage examples, please visit evolu.dev.

Source, examples, and tests

The npm package includes source and tests matching the installed version in node_modules/@evolu/sqlite-wasm/. The links below open GitHub's main branch; use the installed files when checking version-specific behavior.

  • src/ contains the implementation and JSDoc API documentation, including memory ownership rules in Memory.ts.
  • *.test.ts files alongside the source provide usage examples and show tested behavior, edge cases, and type contracts. See Memory.test.ts.

For application imports, use the exports in the installed package's package.json. Relative imports in tests may access internal helpers.

Community

The Evolu community is on GitHub Discussions, where you can ask questions and voice ideas.

To chat with other community members, you can join the Evolu Discord.

X

版本与发布记录

@evolu/sqlite-wasm 共发布 9 个版本,首次发布于 2025/10/29;过去 12 个月发布了 7 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.54.1-build3latest2026/10/3015 天前
3.54.1-build22026/10/2916 天前
3.54.1-build12026/10/2817 天前
3.54.0-build22026/10/131 个月前
3.54.0-build12026/10/121 个月前
3.53.4-build22026/10/111 个月前

关于 @evolu/sqlite-wasm 的常见问题

@evolu/sqlite-wasm 是什么?

SQLite compiled to WebAssembly with Evolu's SQLite core, which encrypts databases, and Evolu's own TypeScript layer

如何安装 @evolu/sqlite-wasm?

在项目目录运行 npm install @evolu/sqlite-wasm,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@evolu/common(^8.24.1)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@evolu/sqlite-wasm 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@evolu/sqlite-wasm 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@evolu/sqlite-wasm 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.20.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@evolu/sqlite-wasm 可以商用吗?

@evolu/sqlite-wasm 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@evolu/sqlite-wasm 的下载量有多少?

根据 npm 官方下载统计,@evolu/sqlite-wasm 在 2026/10/14 – 2026/11/12 这 30 天内被下载 12,084 次,日均约 403 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@evolu/sqlite-wasm 还在维护吗?

最新版本 3.54.1-build3 发布于 2026/10/30(15 天前);至今共发布 9 个版本,其中过去 12 个月发布了 7 个;首次发布于 2025/10/29。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。