Skip to main content
Each backend package exports a create*Store factory you pass into a backends entry of createStore. All backends implement the same per-domain store contracts, so switching backends is a config change, not a code change. The backend type is WaStoreBackend<S, C> — both parameters default to the full domain matrix (every persistence domain plus every cache domain). Every bundled @zapo-js/store-* package ships a full backend; a hand-written partial backend narrows itself with WaStoreBackend<S, C> and createStore() refuses undeclared domains at compile time. See Custom backends.

SQLite

@zapo-js/store-sqlite — createSqliteStore(config).
Provide exactly one of path or connection. With path, the library opens (and ref-counts) its own connection and closes it on store.destroy(). With connection, you own the lifecycle — store.destroy() leaves it open so you can keep using it elsewhere.

Choosing a driver

Since @zapo-js/store-sqlite 1.3.0 the store no longer needs a native addon: better-sqlite3 is an optional peer dependency, and the runtime’s built-in node:sqlite works as a fallback. That makes SQLite persistence available on platforms where the addon can’t be built — Termux, musl without prebuilds, locked-down CI. Behavior for existing installs is unchanged: with better-sqlite3 present, 'auto' still prefers it and never touches node:sqlite.
On Node 20, 21, or 22.5–22.12 without the flag, node:sqlite does not exist, so 'auto' cannot fall back to it — you get the better-sqlite3 install error, and installing the addon is the real fix there.
The on-disk format is identical across drivers — same schema, same migrations — so you can switch an existing database between them without migrating anything. Write throughput is effectively the same on all three; SQLite itself dominates. node:sqlite is slower only when materializing wide or multi-row result sets, since it builds a fresh object per row: roughly 1.3× on a single wide row and 2.5× on a 50-row page with blobs, measured against better-sqlite3 on Node 22. That cost lands on history/mailbox queries, not on the per-message session writes.

Bring your own connection

Share a single SQLite handle with the rest of your application by opening it yourself with openSqliteConnection and passing it through connection:
openSqliteConnection takes the same driver option and follows the same resolution order.

PostgreSQL

@zapo-js/store-postgres — createPostgresStore(config).
Also exports createPgPool and ensurePgMigrations. Requires the pg peer dependency.

MySQL

@zapo-js/store-mysql — createMysqlStore(config).
Also exports createMysqlPool and ensureMysqlMigrations. Requires the mysql2 peer dependency.

Batch insert chunking

batchInsertChunkSize caps how many rows the PostgreSQL and MySQL backends fold into a single multi-row INSERT for batch writes — Signal sessions, remote identities, sender-key distributions, prekey generation, and the message/thread/contact upsertBatch paths used by write-behind persistence. The value is rounded down to the nearest power of two internally (500 → 256, 1000 → 512, …), and each batch is decomposed into power-of-two sub-chunks. That keeps the set of distinct prepared statements per connection bounded at log2(chunkSize) + 1 regardless of how N varies between calls — important for staying under the mysql2 client-side cache and MySQL’s max_prepared_stmt_count quota, and for keeping pg’s named statement cache stable. Leave the default unless benchmarks for your workload show it helps. Raise it for steady high-fanout group sends; lower it if your database limits are tight.

Redis

@zapo-js/store-redis — createRedisStore(config).

Per-domain store TTL

Persistent store domains never expire by default. Set storeTtlMs.<domain>Ms to make Redis prune that domain’s keys automatically — implemented as PEXPIRE on each write, no background cleanup needed. Any domain left unset stays persistent (byte-identical to the previous behavior).
Two semantics, by domain kind:
auth has no TTL knob on purpose: expiring login credentials would log the device out. Pick a crypto TTL longer than your worst idle window — an idle session beyond that window evicts the Signal / app-state keys and forces a re-handshake or re-sync. Non-positive ttlMs values are rejected at construction.
Requires the ioredis peer dependency.

MongoDB

@zapo-js/store-mongo — createMongoStore(config).
Requires the mongodb peer dependency. Bulk writes use { ordered: false } so independent upserts run in parallel — a per-document failure does not abort the rest of the batch.

Cache expiry and cleanup

The five cache domains (retry, groupMetadata, chatMetadata, deviceList, messageSecret) carry a TTL set through cacheTtlMs. How expired entries are evicted differs per backend:
For PostgreSQL and MySQL, without startCleanup your cache tables grow monotonically. Reads still ignore expired rows so stale data is never served, but disk usage climbs forever. Start one poller per session id.
cleanup.intervalMs defaults to 60_000 (60s). result.destroy() stops every poller started through it, so calling poller.stop() yourself is only useful if you want to halt cleanup before tearing down the backend. For MongoDB, the TTL monitor’s ~60s latency means cache entries can linger past the configured TTL. Acceptable for groupMetadata/deviceList; switch to Redis if you need tighter eviction.

Mixing backends

createStore lets each domain choose a backend by name, so you can combine them:
Every persistence domain must be listed once backends is set — see Stores for the rule and the accepted values ('<backend>', 'memory', 'none').
Last modified on September 21, 2026