Skip to main content
A store is where zapo persists everything a session needs to survive a restart: pairing credentials, Signal protocol state, app-state collections, and optionally your message/thread/contact archive. You build one with createStore and pass it to the client.

The model

createStore separates backends (where data lives) from providers (which backend each domain uses). This lets you mix backends — e.g. keep hot signal state in Redis while archiving messages in Postgres.

Providers are required when you set backends

As soon as backends contains at least one entry, every persistence domain must be assigned explicitly in providers. The required domains are auth, signal, preKey, session, identity, senderKey, appState, privacyToken, messages, threads, and contacts. Both the TypeScript types and a runtime check enforce this — createStore throws and lists the missing providers.* keys when any are omitted. Three values are valid for each domain:
  • A backend name from backends (e.g. 'sqlite') — persist that domain there.
  • 'memory' — keep that domain in the in-tree memory provider for this run.
  • 'none' — only valid for the optional archive domains (messages, threads, contacts); skips the domain entirely.
This guard exists because partial coverage is almost always a bug. If you persist only auth and let Signal state, app-state, or the mailbox fall back to memory, the device pairs once and then loses its protocol state on every restart. Pick 'memory' deliberately when that is what you want.
When backends is empty or omitted, every domain falls back to memory (mailbox domains to 'none') — useful for tests, but the device re-pairs on every restart.

Persisted domains

These hold the state required to keep a session alive. Back them with a durable backend in production.

Optional archive domains

These accept 'none' to disable persistence entirely:

Cache domains

Configured under cacheProviders and default to bounded memory with TTLs:
Each backend evicts expired entries differently: memory runs an in-process sweep, Redis and MongoDB use native TTL, SQLite filters on read, and PostgreSQL/MySQL require an opt-in poller (result.startCleanup(sessionId)) or cache tables grow forever. See Cache expiry and cleanup for the per-backend matrix.

Read-through cache layer

When a hot signal domain points at a persistent backend, every send/recv round-trip pays the backend’s latency to fetch the same peer’s session, identity, or sender key. The cacheLayer option wraps the backend store with a bounded-LRU L1 (the in-tree memory provider) so repeated reads of the same peer skip the backend, while writes stay write-through so the backend remains authoritative. Four hot domains can be cached: All flags default to false. A flag is a no-op unless that domain resolves to a real backend in providers — caching 'memory' or 'none' in front of itself buys nothing and is skipped.
limits caps per-domain entry counts; once exceeded, the L1 evicts LRU. When unset, each domain defaults to the matching memory-provider cap.

When to enable it

Turn it on when your backend is a network hop (Redis, Postgres, MySQL, MongoDB) and you send or receive at a rate where the same peers repeat — typical for bots, group fan-out, and multi-tenant gateways. With a local SQLite backend the wins are smaller; measure before flipping it on.

Single-writer assumption

The L1 is per-process and has no cross-process invalidation channel. Enable cacheLayer only when a single process owns a given sessionId’s backend rows — the library’s standard connection model. Different sessions sharing one backend are fine; the same session opened from two processes is not.
Do not enable cacheLayer when multiple processes share one backend for the same sessionId. Another process’s writes would leave this cache stale and corrupt the Signal ratchet.

Why not every domain?

signal, appState, and preKey are deliberately excluded:
  • signal — the per-send registration read is already memoized inside the signal lock; a second cache adds nothing.
  • appState — the sync client already caches collection state for the sync-context lifetime, the only scope where reads both repeat and stay coherent.
  • preKey — one-time pre-keys are read exactly once then consumed. Serving a consumed key from a stale cache would reuse it and break forward secrecy.

Backends

SQLite

@zapo-js/store-sqlite — local, single-process.

PostgreSQL

@zapo-js/store-postgres — distributed, relational.

MySQL

@zapo-js/store-mysql — distributed, relational.

Redis

@zapo-js/store-redis — cache + persistence.

MongoDB

@zapo-js/store-mongo — document store.

Memory

Built in. Great for tests; does not survive a restart.
See the stores reference for each backend’s config options.

Memory-only (tests)

For quick experiments or tests, omit backends entirely — every domain falls back to memory:
A memory-only store loses all credentials on restart, so you re-pair every boot. Use a durable backend for anything long-lived.

Custom backends

The backend contract is WaStoreBackend<S, C>, parametrized by the persistence domains (S extends WaStoreDomain) and cache domains (C extends WaCacheDomain) the bundle actually implements. Both default to the full matrix, so a bare WaStoreBackend still means every domain — that’s what every in-tree @zapo-js/store-* package ships. To cover part of the matrix, name the domains you implement:
createStore() now infers the backend map (not just the backend names), so routing an undeclared domain — or misspelling a backend name — fails at compile time instead of throwing does not provide <kind>.<domain> on the first session(). The mandatory coverage of every persistence domain once backends is set is unchanged, and 'none' still resolves to the noop store.
A hand-written full backend has to declare the new chatMetadata cache alongside the other cache domains, or narrow itself with WaStoreBackend<S, C>. A bare satisfies WaStoreBackend without the cache factory no longer compiles.
A hand-written WaContactStore needs to read and write the new WaStoredContactRecord.username field — the contact handle without the display-only @, populated when the server surfaces one. The in-tree @zapo-js/store-* packages migrate their schemas automatically on connect (via ensurePgMigrations and its per-backend siblings), so consumers of those packages do not need to do anything.
Last modified on August 19, 2026