Skip to content

Persistent Store ​

The store is where the bot keeps messages, chats, contacts, flow state, checkpoints and AI conversation history. Choosing the right store is the difference between a bot that forgets everything on restart and one that survives deploys.

Stores implement the generic, transport-neutral IStore from @botmesh/core — the same store works with any provider (Baileys, Cloud API, Telegram, Discord).

Available stores ​

StorePackagePersistenceUse case
MemoryStore (default)@botmesh/coreIn-memory (optional JSON dump)Development, tests, short-lived bots
SQLiteStore@botmesh/sqlite-storeSQLite file (WAL mode)Production — single-bot, zero-config
RedisStore@botmesh/redis-storeRedis serverDistributed state, multiple instances, TTL
MongoStore · PostgresStore · MysqlStore@botmesh/mongo-store · @botmesh/postgres-store · @botmesh/mysql-storeMongoDB / PostgreSQL / MySQLExperimental — real, tested implementations, not published yet

MemoryStore ships with the core. SQLiteStore and RedisStore are part of the first alpha, published on npm under the alpha dist-tag. The MongoDB, PostgreSQL and MySQL stores pass the same IStore conformance suite but stay private (unpublished) for now; see their READMEs in the repository.

MemoryStore (default) ​

If you don't pass a store, the bot creates a MemoryStore. Everything lives in RAM; an optional JSON file can snapshot chats/contacts/checkpoints (not messages) on an interval.

ts
const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  // Optional: persist chats + flow state to a JSON file
  store: new MemoryStore({
    file: './store.json',
    autoSaveInterval: '1m', // duration or ms (0 = disable)
    maxMessagesPerChat: 1000, // the default; Infinity disables the cap
  }),
})

MemoryStore keeps at most 1000 messages per chat by default. Unlike the persistent stores, where the cap is opt-in, this log lives in the process's RAM and is never written to disk, so an unbounded default would only grow memory on a long-running bot. Pass maxMessagesPerChat: Infinity to keep everything.

Messages are NOT restored

MemoryStore intentionally skips messages on reload (privacy + size). Only chats, contacts, flow states, checkpoints and AI conversations survive a restart via the JSON dump.

A real database backed by better-sqlite3 — synchronous, zero-config, single-file. Perfect for a single bot instance on a VPS or Docker container. It's a separate package; better-sqlite3 is a peer dependency you install next to it (it is loaded when the store is constructed, so a missing install fails with a clear error):

bash
pnpm add @botmesh/sqlite-store@alpha better-sqlite3

The supported range is ^11.7.0 || ^12.0.0 || ^13.0.0 (tested against 11.10.0, 12.11.1 and 13.0.3). npm picks a compatible major and only warns; pnpm installs the latest one and ignores the range.

better-sqlite3 is a native module: its install script downloads (or builds) the binary. pnpm 10 and later skip dependency build scripts until you approve them, and the store then fails at construction with better-sqlite3 is installed but its native module was never built. Approve it once with pnpm approve-builds (or list it under onlyBuiltDependencies in pnpm-workspace.yaml) and reinstall. After a Node.js upgrade, npm rebuild better-sqlite3 fixes a binary built for the previous version. The directory of file must exist: SQLite creates the file, not its folders.

Node.js 24.19 or later

Install better-sqlite3 13. Versions 11 and 12 can abort the process (Assertion failed: (env) != nullptr in RemoveEnvironmentCleanupHook) when a prepared statement is garbage-collected, because of a Node.js regression (nodejs/node#65446). Version 13 runs on N-API and is not affected.

ts
import { Bot } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
import { SQLiteStore } from '@botmesh/sqlite-store'

const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  store: new SQLiteStore({ file: './data/bot.db' }),
})

Options ​

OptionTypeDefaultDescription
filestring'./bot.db'Path to the SQLite database file
walbooleantrueEnable WAL mode (better concurrent reads)
maxMessagesPerChatnumber— (unbounded)Keep only the newest N messages per chat; older rows are pruned on save
purgeBatchSizenumber1000Rows deleted per statement by purgeExpiredUserData (it loops until nothing expired is left)
busyTimeoutMsnumber5000How long a statement waits for another connection's lock before SQLITE_BUSY (PRAGMA busy_timeout)
loggerPartial<LoggerLike>—Logger for close failures and schema migrations (pino or the bot's logger; every method optional)

What it stores ​

TableContent
messagesEvery inbound/outbound message (keyed by chat + timestamp)
chatsChat metadata (name, kind, etc.)
contactsContact info (display name, username, etc.)
checkpointspersistent() flow checkpoints, including the flow's c.state — survive restarts
conversationsAI conversation history (@botmesh/ai)
cursorsDelivery cursors (e.g. Telegram's update_id)

Schema ​

The store creates its tables on construction (CREATE TABLE IF NOT EXISTS). You never need to create them manually — just point it at a file and it handles the rest.

Persistent flows + SQLiteStore ​

With SQLiteStore, flow(...).persistent() checkpoints survive process restarts for free:

ts
import { Bot, flow } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
import { SQLiteStore } from '@botmesh/sqlite-store'

const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  store: new SQLiteStore({ file: './data/bot.db' }),
})

bot.use(
  flow('registro')
    .persistent()          // ← checkpoint saved to SQLite after each step
    .ask('¿Tu nombre?', 'name')
    .ask('¿Tu email?', 'email')
    .say((_ctx, c) => `✅ Registrado: ${c.state.name}`)
)

If the process crashes mid-flow, the next message from that user resumes at the exact step where it left off. That message only wakes the flow: a pending ask/choose is sent again (¿Tu email? above), the flow goes on with the state it had, and the user's next reply is its answer (with durability the waking message is the answer instead). The checkpoint is written when a step completes, so a flow still waiting on its first question has nothing saved yet: after a restart, the user starts it again with its trigger.

bot.stop() and a failed bot.start() close the database; the next bot.start() reopens it, so a bot can be stopped and started again with the same SQLiteStore (and the schedules() / durability() backends it handed out). An in-memory database (file: ':memory:') is never closed by stop(): closing it would discard its data. Calling schedules() or durability() on a stopped store reopens it the same way.

Schema versioning. The file records its layout in PRAGMA user_version. Opening it runs the pending migrations in order, each in its own transaction (a failed step leaves the file at the last completed version), so a database written by an earlier release is upgraded in place. A file written by a newer release is refused with SQLiteSchemaVersionError (code: 'SQLITE_SCHEMA_TOO_NEW', with found and supported) and left untouched. SQLITE_SCHEMA_VERSION is the version this release writes. Downgrades are not supported: back up the file before an upgrade you may need to roll back.

SQLite stores text as UTF-8, where a lone UTF-16 surrogate becomes U+FFFD, so two different ids could share a row. saveMessage and setCursor reject a message id, chat id, cursor key or cursor value with a lone surrogate (TypeError), and a lookup with one finds nothing.

RedisStore (distributed) ​

When you need state shared across processes/instances (horizontal scaling, TTL'd per-chat logs), use the Redis store:

bash
pnpm add @botmesh/redis-store@alpha
ts
import { Bot } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
import { RedisStore } from '@botmesh/redis-store'

const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  store: new RedisStore({ url: 'redis://localhost:6379', sessionTtl: '7d' }),
})

sessionTtl (a duration like '24h', or milliseconds, like every other duration option) expires AI conversation history and flow checkpoints; messages, chats and contacts never expire. The former ttl option (seconds) was removed: ttl: 3600 is sessionTtl: '1h', and passing ttl now throws a TypeError.

On a Redis Cluster, pass a cluster client (or cluster options): see Redis Cluster.

User data tables ​

ctx.user profiles are stored next to the rest of the bot state, honouring tablePrefix / collectionPrefix:

StoreWhere
MemoryStorein memory; the JSON file gains a userData field (older files still load)
SQLiteStoretable user_data (user_id, namespace, key, value_json, expires_at, updated_at) + index on expires_at
PostgresStoretable {prefix}user_data (value as TEXT, not JSONB, to keep the exact JSON: U+0000 and unpaired surrogates round-trip unchanged, as JSON \u0000 escapes) + expiry index
MysqlStoretable {prefix}user_data: ids, namespace and key as VARBINARY (byte-exact), value MEDIUMTEXT (16 MiB)
MongoStorecollection {prefix}user_data, indexes on { userId, namespace } and expiresAt
RedisStoreone key per entry, {prefix}:ud:{user}:{ns}:{key}, with native PX expiry; listing and namespace deletes use incremental SCAN (falling back to KEYS for a client without scanIterator), like listChats

Postgres and MySQL now create seven tables in start(). Expired rows are removed lazily by bot.purgeExpiredUserData() (Redis expires them by itself). SQLite, Postgres, MySQL and MongoDB delete them in batches of purgeBatchSize (default 1000) until nothing expired is left, so a large backlog never runs as one long delete.

Custom stores ​

Implement the IStore interface to plug in any other backend (MongoDB, PostgreSQL and MySQL already have experimental stores in the monorepo):

ts
import type { IStore } from '@botmesh/core'

class MyStore implements IStore {
  async saveMessage(msg) { /* ... */ }
  async getMessage(ref) { /* ... */ }
  // ... implement every method
}

const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  store: new MyStore({ /* ... */ }),
})

The full interface is documented in the API reference. MemoryStore is the reference implementation of the contract; inside the monorepo, every store runs the shared conformance suite (test-utils/store-conformance.ts).

Semantics every store must keep:

  • upsertChat / upsertContact shallow-merge onto the stored record. An own field set to undefined unsets that field, so a partial upsert must omit what it does not know. The Bot already does: a message without a display name (or chat name) never erases the one stored.
  • deleteChat also deletes that chat's message log.
  • getMessagesByChat returns the newest messages first. The persistent stores order them by timestamp, so re-saving a message with a new timestamp moves it. MemoryStore keeps a re-saved message at the position of its first save (it updates the row in place); do not rely on re-saves to reorder the log.
  • maxMessagesPerChat is opt-in in the persistent stores; by default their message log is unbounded. MemoryStore is the exception: it keeps 1000 messages per chat by default because its log lives in RAM (Infinity disables the cap). Every built-in store rejects a negative or fractional cap with a RangeError.
  • Message rows are keyed by messageKey(ref) (from @botmesh/core/spi): {chatId}:{id} with % and : escaped in each part (%25, %3A), so ids that contain : (Twilio's whatsapp:+1… chat ids) never collide. Ids without : or % keep the key earlier versions wrote; rows stored under the old unescaped key (legacyMessageKey(ref)) are still read and deleted, and re-saving moves them to the new key. A custom persistent store that keys rows by messageKey should do the same and check that a row found under the legacy key is the requested message (its stored ref.id and ref.chatId): the legacy key can be another chat's key, or the new key of another message of the same chat (legacy c:%3A of ('c', '%3A') is the key of ('c', ':')).
  • saveMessage ignores a message without ref.id or chatId (providers send ref.id: '' when they have no stable id), and getMessage of such a ref resolves undefined. Re-saving an id replaces the stored message.
  • getMessagesByChat(chatId, limit) returns the newest limit messages, oldest first. A limit of 0 or less returns nothing, a fraction rounds up and Infinity returns the whole log.
  • setUserData rejects with a TypeError when the value is not JSON (undefined), and writes nothing.

The user-data methods (getUserData, setUserData, deleteUserData, listUserData, purgeExpiredUserData) are optional: without them ctx.user rejects with UNSUPPORTED_STORE. If you add them, also pass runUserDataConformance from test-utils/user-data-conformance.ts. A store with getCursor/setCursor also persists the WELCOME markers and the handoff index.

Docker + SQLiteStore ​

Mount the data directory as a volume so the database survives container restarts:

yaml
# docker-compose.yml
services:
  bot:
    build: .
    volumes:
      - ./sessions:/app/sessions   # WhatsApp auth
      - ./data:/app/data           # SQLite database

Released under the MIT License.