Store Persistente
El store es donde el bot guarda mensajes, chats, contactos, estado de flujos, checkpoints e historial de conversaciones de IA. Elegir el store correcto es la diferencia entre un bot que olvida todo al reiniciar y uno que sobrevive a los deploys.
Los stores implementan el IStore genérico y neutral de transporte de @botmesh/core — el mismo store funciona con cualquier provider (Baileys, Cloud API, Telegram, Discord).
Stores disponibles
| Store | Paquete | Persistencia | Caso de uso |
|---|---|---|---|
MemoryStore (default) | @botmesh/core | En memoria (dump JSON opcional) | Desarrollo, tests, bots de vida corta |
SQLiteStore | @botmesh/sqlite-store | Archivo SQLite (modo WAL) | Producción — single-bot, cero config |
RedisStore | @botmesh/redis-store | Servidor Redis | Estado distribuido, varias instancias, TTL |
MongoStore · PostgresStore · MysqlStore | @botmesh/mongo-store · @botmesh/postgres-store · @botmesh/mysql-store | MongoDB / PostgreSQL / MySQL | Experimental — implementaciones reales y con tests, aún sin publicar |
MemoryStore viene con el core. SQLiteStore y RedisStore forman parte de la primera alpha, publicada en npm bajo el dist-tag alpha. Los stores de MongoDB, PostgreSQL y MySQL pasan la misma suite de conformidad de IStore, pero de momento siguen siendo privados (sin publicar); mira sus READMEs en el repositorio.
MemoryStore (default)
Si no pasas un store, el bot crea un MemoryStore. Todo vive en RAM; un archivo JSON opcional puede guardar chats/contactos/checkpoints (no mensajes) en un intervalo.
const bot = new Bot({
provider: new BaileysProvider({ session: 'mi-bot' }),
// Opcional: persistir chats + estado de flujo a un archivo JSON
store: new MemoryStore({
file: './store.json',
autoSaveInterval: '1m', // duración o ms (0 = desactivar)
maxMessagesPerChat: 1000, // el valor por defecto; Infinity quita el tope
}),
})MemoryStore guarda como máximo 1000 mensajes por chat por defecto. A diferencia de los stores persistentes, donde el tope es opt-in, este log vive en la RAM del proceso y nunca se escribe en disco, así que un valor por defecto sin límite solo haría crecer la memoria en un bot de larga duración. Pasa maxMessagesPerChat: Infinity para conservarlo todo.
Los mensajes NO se restauran
MemoryStore intencionalmente omite los mensajes al recargar (privacidad + tamaño). Solo chats, contactos, flow states, checkpoints y conversaciones AI sobreviven un reinicio vía el dump JSON.
SQLiteStore (recomendado para producción)
Una base de datos real con better-sqlite3 — síncrona, cero config, un solo archivo. Perfecta para una sola instancia de bot en un VPS o contenedor Docker. Es un paquete separado; better-sqlite3 es una peer dependency que instalas a su lado (se carga al construir el store, así que si falta obtienes un error claro):
pnpm add @botmesh/sqlite-store@alpha better-sqlite3El rango soportado es ^11.7.0 || ^12.0.0 || ^13.0.0 (probado con 11.10.0, 12.11.1 y 13.0.3). npm elige un major compatible y solo avisa; pnpm instala el último y se salta el rango.
better-sqlite3 es un módulo nativo: su script de instalación descarga (o compila) el binario. pnpm 10 y posteriores no ejecutan los scripts de build de las dependencias hasta que los apruebas, y el store falla al construirse con better-sqlite3 is installed but its native module was never built. Apruébalo una vez con pnpm approve-builds (o añádelo a onlyBuiltDependencies en pnpm-workspace.yaml) y reinstala. Tras actualizar Node.js, npm rebuild better-sqlite3 arregla un binario compilado para la versión anterior. El directorio de file debe existir: SQLite crea el archivo, no sus carpetas.
Node.js 24.19 o posterior
Instala better-sqlite3 13. Las versiones 11 y 12 pueden abortar el proceso (Assertion failed: (env) != nullptr en RemoveEnvironmentCleanupHook) cuando el recolector libera una sentencia preparada, por una regresión de Node.js (nodejs/node#65446). La versión 13 usa N-API y no se ve afectada.
import { Bot } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
import { SQLiteStore } from '@botmesh/sqlite-store'
const bot = new Bot({
provider: new BaileysProvider({ session: 'mi-bot' }),
store: new SQLiteStore({ file: './data/bot.db' }),
})Opciones
| Opción | Tipo | Default | Descripción |
|---|---|---|---|
file | string | './bot.db' | Ruta al archivo de base de datos SQLite |
wal | boolean | true | Activar modo WAL (mejores lecturas concurrentes) |
maxMessagesPerChat | number | — (sin límite) | Conserva solo los N mensajes más recientes por chat; los más viejos se podan al guardar |
purgeBatchSize | number | 1000 | Filas que borra cada sentencia de purgeExpiredUserData (repite hasta que no queda nada expirado) |
busyTimeoutMs | number | 5000 | Cuánto espera una sentencia al bloqueo de otra conexión antes de SQLITE_BUSY (PRAGMA busy_timeout) |
logger | Partial<LoggerLike> | — | Logger para fallos al cerrar y migraciones de esquema (pino o el logger del bot; todos los métodos opcionales) |
Qué almacena
| Tabla | Contenido |
|---|---|
messages | Cada mensaje entrante/saliente (indexado por chat + timestamp) |
chats | Metadatos de chats (nombre, kind, etc.) |
contacts | Info de contactos (display name, username, etc.) |
checkpoints | Checkpoints de flujos persistent(), incluido el c.state del flujo — sobreviven reinicios |
conversations | Historial de conversaciones AI (@botmesh/ai) |
cursors | Cursores de entrega (p. ej. el update_id de Telegram) |
Schema
El store crea sus tablas al construirse (CREATE TABLE IF NOT EXISTS). Nunca necesitas crearlas a mano — solo apúntalo a un archivo y él se encarga del resto.
Flujos persistentes + SQLiteStore
Con SQLiteStore, los checkpoints de flow(...).persistent() sobreviven reinicios del proceso gratis:
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: 'mi-bot' }),
store: new SQLiteStore({ file: './data/bot.db' }),
})
bot.use(
flow('registro')
.persistent() // ← checkpoint guardado en SQLite tras cada paso
.ask('¿Tu nombre?', 'name')
.ask('¿Tu email?', 'email')
.say((_ctx, c) => `✅ Registrado: ${c.state.name}`)
)Si el proceso se cae a mitad del flujo, el próximo mensaje de ese usuario reanuda en el paso exacto donde se quedó. Ese mensaje solo despierta el flujo: un ask/choose pendiente se vuelve a enviar (¿Tu email? arriba), el flujo sigue con el estado que tenía y la siguiente respuesta del usuario es la que lo contesta (con durabilidad el mensaje que despierta es la respuesta). El checkpoint se escribe al completar un paso, así que un flujo que aún espera su primera pregunta no tiene nada guardado: tras un reinicio, el usuario lo vuelve a empezar con su disparador.
bot.stop() y un bot.start() fallido cierran la base de datos; el siguiente bot.start() la reabre, así que un bot puede pararse y volver a arrancar con el mismo SQLiteStore (y con los backends schedules() / durability() que entregó). Una base en memoria (file: ':memory:') nunca la cierra stop(): cerrarla perdería sus datos. Llamar a schedules() o durability() con el store parado la reabre del mismo modo.
Versionado del esquema. El archivo guarda su layout en PRAGMA user_version. Al abrirlo se ejecutan en orden las migraciones pendientes, cada una en su propia transacción (un paso que falla deja el archivo en la última versión completada), así que una base escrita por una versión anterior se actualiza en el sitio. Un archivo escrito por una versión más nueva se rechaza con SQLiteSchemaVersionError (code: 'SQLITE_SCHEMA_TOO_NEW', con found y supported) y no se toca. SQLITE_SCHEMA_VERSION es la versión que escribe esta release. No se admite volver atrás: haz una copia del archivo antes de una actualización que puedas tener que revertir.
SQLite guarda el texto en UTF-8, donde un surrogate UTF-16 suelto se convierte en U+FFFD, así que dos ids distintos podrían compartir fila. saveMessage y setCursor rechazan (TypeError) un id de mensaje, id de chat, clave o valor de cursor con un surrogate suelto, y una búsqueda con uno no encuentra nada.
RedisStore (distribuido)
Cuando necesitas estado compartido entre procesos/instancias (escalamiento horizontal, logs por chat con TTL), usa el store de Redis:
pnpm add @botmesh/redis-store@alphaimport { Bot } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
import { RedisStore } from '@botmesh/redis-store'
const bot = new Bot({
provider: new BaileysProvider({ session: 'mi-bot' }),
store: new RedisStore({ url: 'redis://localhost:6379', sessionTtl: '7d' }),
})sessionTtl (una duración como '24h', o milisegundos, como el resto de opciones de duración) hace expirar el historial de IA y los checkpoints de flujos; los mensajes, chats y contactos nunca expiran. La antigua opción ttl (en segundos) se eliminó: ttl: 3600 es sessionTtl: '1h', y pasar ttl ahora lanza un TypeError.
En un Redis Cluster, pasa un cliente cluster (u opciones cluster): ver Redis Cluster.
Tablas de datos de usuario
Los perfiles de ctx.user se guardan junto al resto del estado del bot, respetando tablePrefix / collectionPrefix:
| Store | Dónde |
|---|---|
MemoryStore | en memoria; el file JSON suma el campo userData (los archivos antiguos siguen cargando) |
SQLiteStore | tabla user_data (user_id, namespace, key, value_json, expires_at, updated_at) + índice sobre expires_at |
PostgresStore | tabla {prefix}user_data (valor como TEXT, no JSONB, para conservar el JSON exacto: U+0000 y los surrogates sueltos vuelven sin cambios, como escapes JSON \u0000) + índice de expiración |
MysqlStore | tabla {prefix}user_data: ids, namespace y clave como VARBINARY (exactos byte a byte), valor MEDIUMTEXT (16 MiB) |
MongoStore | colección {prefix}user_data, índices sobre { userId, namespace } y expiresAt |
RedisStore | una clave por entrada, {prefix}:ud:{user}:{ns}:{key}, con expiración nativa PX; para listar y borrar namespaces usa SCAN incremental (o KEYS con un cliente sin scanIterator), como listChats |
Postgres y MySQL crean ahora siete tablas en start(). Las filas expiradas se borran de forma perezosa con bot.purgeExpiredUserData() (Redis las expira por sí mismo). SQLite, Postgres, MySQL y MongoDB las borran en lotes de purgeBatchSize (por defecto 1000) hasta que no queda nada expirado, así que un backlog grande nunca se ejecuta como un único borrado largo.
Stores personalizados
Implementa la interfaz IStore para conectar cualquier otro backend (MongoDB, PostgreSQL y MySQL ya tienen stores experimentales en el monorepo):
import type { IStore } from '@botmesh/core'
class MiStore implements IStore {
async saveMessage(msg) { /* ... */ }
async getMessage(ref) { /* ... */ }
// ... implementa cada método
}
const bot = new Bot({
provider: new BaileysProvider({ session: 'mi-bot' }),
store: new MiStore({ /* ... */ }),
})La interfaz completa está documentada en la referencia de API. MemoryStore es la implementación de referencia del contrato; dentro del monorepo, todos los stores corren la suite de conformidad compartida (test-utils/store-conformance.ts).
Semántica que todo store debe respetar:
upsertChat/upsertContacthacen merge superficial sobre el registro guardado. Un campo propio con valorundefinedborra ese campo, así que un upsert parcial debe omitir lo que no conoce. ElBotya lo hace: un mensaje sin nombre visible (o sin nombre de chat) nunca borra el que estaba guardado.deleteChatborra también el log de mensajes de ese chat.getMessagesByChatdevuelve primero los mensajes más nuevos. Los stores persistentes los ordenan portimestamp, así que volver a guardar un mensaje con otro timestamp lo mueve.MemoryStoredeja un mensaje re-guardado en la posición de su primer guardado (actualiza la fila en su sitio); no cuentes con un re-guardado para reordenar el log.maxMessagesPerChates opt-in en los stores persistentes; por defecto su log de mensajes no tiene límite.MemoryStorees la excepción: guarda 1000 mensajes por chat por defecto porque su log vive en RAM (Infinityquita el tope). Todos los stores incluidos rechazan un tope negativo o con decimales con unRangeError.- Los mensajes se guardan con la clave
messageKey(ref)(de@botmesh/core/spi):{chatId}:{id}con%y:escapados en cada parte (%25,%3A), así que los ids que contienen:(los chat idswhatsapp:+1…de Twilio) ya no colisionan. Los ids sin:ni%conservan la clave que escribían las versiones anteriores; las filas guardadas con la clave antigua sin escapar (legacyMessageKey(ref)) se siguen leyendo y borrando, y al volver a guardarlas pasan a la clave nueva. Un store persistente propio que usemessageKeydebería hacer lo mismo y comprobar que la fila encontrada con la clave antigua es el mensaje pedido (suref.idy suref.chatIdguardados): la clave antigua puede ser la de otro chat o la clave nueva de otro mensaje del mismo chat (la antiguac:%3Ade('c', '%3A')es la clave de('c', ':')). saveMessageignora un mensaje sinref.ido sinchatId(los providers envíanref.id: ''cuando no tienen un id estable), ygetMessagede esa referencia resuelveundefined. Guardar de nuevo un id reemplaza el mensaje guardado.getMessagesByChat(chatId, limit)devuelve loslimitmensajes más recientes, del más antiguo al más nuevo. Unlimitde0o menos no devuelve nada, una fracción se redondea hacia arriba eInfinitydevuelve el log completo.setUserDatarechaza con unTypeErrorcuando el valor no es JSON (undefined), y no escribe nada.
Los métodos de datos de usuario (getUserData, setUserData, deleteUserData, listUserData, purgeExpiredUserData) son opcionales: sin ellos ctx.user rechaza con UNSUPPORTED_STORE. Si los añades, pasa también runUserDataConformance de test-utils/user-data-conformance.ts. Un store con getCursor/setCursor también persiste las marcas de WELCOME y el índice de handoff.
Docker + SQLiteStore
Monta el directorio de datos como volumen para que la base de datos sobreviva reinicios del contenedor:
# docker-compose.yml
services:
bot:
build: .
volumes:
- ./sessions:/app/sessions # Auth de WhatsApp
- ./data:/app/data # Base de datos SQLite