Skip to main content
The store is where the bot keeps messages, chats, contacts, flow checkpoints, AI conversation history and ctx.user data. With the default in-memory store a restart forgets all of it; with a persistent one the bot picks up where it left off. Every store implements the transport-neutral IStore contract of @botmesh/core, so the same store works with any channel.

Choosing a store

Rules of thumb:
  • One bot process on a VPS or in a container: SQLiteStore. No server to run, one file to back up.
  • Several processes of the same bot (replicas), or state that other services read: RedisStore. Every replica must see the same data, so MemoryStore and SQLiteStore do not fit there. If you already run PostgreSQL, MySQL/MariaDB or MongoDB, the matching store does the same job.
  • Always pass a store instance (store: new SQLiteStore({ … })). A string such as store: 'sqlite' is accepted only for compatibility and still creates a MemoryStore.
The bot calls the store’s start() and stop() for you.

MemoryStore

Without a store option the bot creates a MemoryStore. Everything lives in RAM. An optional JSON file snapshots chats, contacts, flow checkpoints, AI conversations and ctx.user data (not messages) on an interval and on stop():
The message log is capped at 1000 messages per chat by default because it lives in RAM. The persistent stores have no cap unless you set one.
Messages are not restoredThe JSON snapshot skips the message log on purpose (size and privacy). Use SQLiteStore or RedisStore when the history must survive a restart.

SQLiteStore

A real database in one file, on better-sqlite3. better-sqlite3 is a peer dependency, so install it next to the store:
The folder of file must exist: SQLite creates the file, not its folders.

Installing better-sqlite3

better-sqlite3 is a native module. The supported range is ^11.7.0 || ^12.0.0 || ^13.0.0, and the store is tested against all three.
Node.js 24.19 or later: use better-sqlite3 13Versions 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.

Options

An invalid number (negative, fractional) throws a RangeError from the constructor.

What it stores

Schema versions and upgrades

The file records its layout in PRAGMA user_version; SQLITE_SCHEMA_VERSION is the version this release writes. Opening a file runs the pending migrations in order, each in its own transaction, so a database written by an earlier release is upgraded in place with its rows kept. You never create or migrate tables by hand. A file written by a newer release is refused, untouched, with SQLiteSchemaVersionError (code: 'SQLITE_SCHEMA_TOO_NEW', with found and supported). Downgrades are not supported, so:
  • Back up the file before upgrading @botmesh/sqlite-store if you may need to roll back.
  • When several processes share one file, stop the ones on the old version before a new one opens it.
The package README lists what each version changed and how to roll back the latest step without a backup.

Lifecycle

bot.stop() (and a failed bot.start()) closes the database, and the next bot.start() reopens it, so one SQLiteStore instance, with the schedules() and durability() backends it handed out, survives stop/start cycles. SQLite stores text as UTF-8, where a lone UTF-16 surrogate becomes U+FFFD and two different ids could share a row. saveMessage and setCursor reject such an id, key or cursor value with a TypeError.

Persistent flows

With a persistent store, flow(...).persistent() checkpoints survive a restart:
If the process stops in the middle of the flow, the user’s next message resumes it at the step where it stopped. That message only wakes the flow: the pending question is sent again (And your email? above) and the user’s next reply is its answer. With durability the waking message is taken as the answer instead. A checkpoint is written when the flow starts and after every step, so even a flow still waiting on its first question resumes there. A restart in the middle of a .form() asks the form again from its first field: the whole form is one step.

RedisStore

State shared by every process of a bot, on Redis (node-redis 4, 5 or 6 as a peer dependency):
The same RedisStore hands out the other Redis backends, all on its client and prefix: store.coordinator() for replicas, store.schedules() and store.leases() for the durable scheduler. The durability backend is new RedisDurability({ client, prefix }) (see Durable message processing). An injected client without an 'error' listener crashes the process on the first dropped connection (node-redis rethrows the event), so always attach one. The key scheme and every other detail are in the package README.

PostgreSQL, MySQL and MongoDB

@botmesh/postgres-store, @botmesh/mysql-store and @botmesh/mongo-store keep the bot state in the database you already operate, shared by any number of replicas. They pass the same conformance suite as the other stores, create their tables (or indexes) on start() and provide a durable schedule store (store.schedules()). PostgreSQL also has a durability backend, PostgresDurability. The database driver is a peer dependency, loaded only when the store creates its own pool or client:
Each one also takes an existing pool or client (pool / client), which it never closes. With an injected pg pool, attach a pool.on('error', …) listener: node-postgres crashes the process when an idle connection drops and nobody listens. On PostgreSQL keep tablePrefix at 42 characters or fewer (the server truncates names to 63 bytes; a longer prefix throws a RangeError). The options, table layouts and error tables are in each package README: PostgreSQL, MySQL/MariaDB, MongoDB.

User data tables

ctx.user profiles are stored next to the rest of the bot state, honouring tablePrefix / collectionPrefix: Expired entries are removed lazily by bot.purgeExpiredUserData() (Redis expires them by itself). The SQL and Mongo stores delete them in batches of purgeBatchSize (default 1000) until nothing expired is left, so a large backlog never runs as one long delete.

Writing your own store

Implement IStore to plug in another backend. Store authors import the contract and its helpers from @botmesh/core/spi:
The full interface is in the API reference. MemoryStore is the reference implementation, and 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 never erases the stored one.
  • deleteChat also deletes that chat’s message log.
  • getMessagesByChat(chatId, limit) returns the newest limit messages, oldest first (default 50). A limit of 0 or less returns nothing, a fraction rounds up and Infinity returns the whole log. The persistent stores order 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.
  • 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.
  • maxMessagesPerChat is opt-in in persistent stores. Reject a negative or fractional cap with a RangeError.
  • setUserData rejects with a TypeError when the value is not JSON (undefined), and writes nothing.
  • 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. Rows written under the older unescaped key (legacyMessageKey(ref)) must still be read and deleted, after checking that the row’s stored ref.id and ref.chatId are the requested message, and re-saving moves them to the new key.
Optional methods:
  • User data (getUserData, setUserData, deleteUserData, listUserData, purgeExpiredUserData): without them ctx.user rejects with UNSUPPORTED_STORE. Check them with test-utils/user-data-conformance.ts.
  • Cursors (getCursor/setCursor): they also persist the WELCOME markers and the handoff index.
  • Conditional checkpoints (setCheckpointIf/clearCheckpointIf): needed for a safe hand-over between replicas. Without them the bot logs a warning when coordination is on.

Docker

Mount the data folder as a volume so the database survives container restarts. See Deployment for the full setup and Backups.