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, soMemoryStoreandSQLiteStoredo 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 asstore: 'sqlite'is accepted only for compatibility and still creates aMemoryStore.
start() and stop() for you.
MemoryStore
Without astore 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.
SQLiteStore
A real database in one file, on better-sqlite3.better-sqlite3
is a peer dependency, so install it next to the store:
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.
Options
An invalid number (negative, fractional) throws a
RangeError from the constructor.
What it stores
Schema versions and upgrades
The file records its layout inPRAGMA 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-storeif 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.
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:
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:
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
ImplementIStore to plug in another backend. Store authors import the
contract and its helpers from @botmesh/core/spi:
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/upsertContactshallow-merge onto the stored record. An own field set toundefinedunsets 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.deleteChatalso deletes that chat’s message log.getMessagesByChat(chatId, limit)returns the newestlimitmessages, oldest first (default50). Alimitof0or less returns nothing, a fraction rounds up andInfinityreturns the whole log. The persistent stores order bytimestamp, so re-saving a message with a new timestamp moves it;MemoryStorekeeps a re-saved message at the position of its first save.saveMessageignores a message withoutref.idorchatId(providers sendref.id: ''when they have no stable id), andgetMessageof such a ref resolvesundefined. Re-saving an id replaces the stored message.maxMessagesPerChatis opt-in in persistent stores. Reject a negative or fractional cap with aRangeError.setUserDatarejects with aTypeErrorwhen 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’swhatsapp:+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 storedref.idandref.chatIdare the requested message, and re-saving moves them to the new key.
- User data (
getUserData,setUserData,deleteUserData,listUserData,purgeExpiredUserData): without themctx.userrejects withUNSUPPORTED_STORE. Check them withtest-utils/user-data-conformance.ts. - Cursors (
getCursor/setCursor): they also persist theWELCOMEmarkers 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.