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.
Last modified on May 29, 2026