Skip to content

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 ​

StorePaquetePersistenciaCaso de uso
MemoryStore (default)@botmesh/coreEn memoria (dump JSON opcional)Desarrollo, tests, bots de vida corta
SQLiteStore@botmesh/sqlite-storeArchivo SQLite (modo WAL)Producción — single-bot, cero config
RedisStore@botmesh/redis-storeServidor RedisEstado distribuido, varias instancias, TTL
MongoStore · PostgresStore · MysqlStore@botmesh/mongo-store · @botmesh/postgres-store · @botmesh/mysql-storeMongoDB / PostgreSQL / MySQLExperimental — 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.

ts
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):

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

El 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.

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: 'mi-bot' }),
  store: new SQLiteStore({ file: './data/bot.db' }),
})

Opciones ​

OpciónTipoDefaultDescripción
filestring'./bot.db'Ruta al archivo de base de datos SQLite
walbooleantrueActivar modo WAL (mejores lecturas concurrentes)
maxMessagesPerChatnumber— (sin límite)Conserva solo los N mensajes más recientes por chat; los más viejos se podan al guardar
purgeBatchSizenumber1000Filas que borra cada sentencia de purgeExpiredUserData (repite hasta que no queda nada expirado)
busyTimeoutMsnumber5000Cuánto espera una sentencia al bloqueo de otra conexión antes de SQLITE_BUSY (PRAGMA busy_timeout)
loggerPartial<LoggerLike>—Logger para fallos al cerrar y migraciones de esquema (pino o el logger del bot; todos los métodos opcionales)

Qué almacena ​

TablaContenido
messagesCada mensaje entrante/saliente (indexado por chat + timestamp)
chatsMetadatos de chats (nombre, kind, etc.)
contactsInfo de contactos (display name, username, etc.)
checkpointsCheckpoints de flujos persistent(), incluido el c.state del flujo — sobreviven reinicios
conversationsHistorial de conversaciones AI (@botmesh/ai)
cursorsCursores 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:

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: '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:

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: '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:

StoreDónde
MemoryStoreen memoria; el file JSON suma el campo userData (los archivos antiguos siguen cargando)
SQLiteStoretabla user_data (user_id, namespace, key, value_json, expires_at, updated_at) + índice sobre expires_at
PostgresStoretabla {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
MysqlStoretabla {prefix}user_data: ids, namespace y clave como VARBINARY (exactos byte a byte), valor MEDIUMTEXT (16 MiB)
MongoStorecolección {prefix}user_data, índices sobre { userId, namespace } y expiresAt
RedisStoreuna 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):

ts
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 / upsertContact hacen merge superficial sobre el registro guardado. Un campo propio con valor undefined borra ese campo, así que un upsert parcial debe omitir lo que no conoce. El Bot ya lo hace: un mensaje sin nombre visible (o sin nombre de chat) nunca borra el que estaba guardado.
  • deleteChat borra también el log de mensajes de ese chat.
  • getMessagesByChat devuelve primero los mensajes más nuevos. Los stores persistentes los ordenan por timestamp, así que volver a guardar un mensaje con otro timestamp lo mueve. MemoryStore deja 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.
  • maxMessagesPerChat es opt-in en los stores persistentes; por defecto su log de mensajes no tiene límite. MemoryStore es la excepción: guarda 1000 mensajes por chat por defecto porque su log vive en RAM (Infinity quita el tope). Todos los stores incluidos rechazan un tope negativo o con decimales con un RangeError.
  • 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 ids whatsapp:+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 use messageKey debería hacer lo mismo y comprobar que la fila encontrada con la clave antigua es el mensaje pedido (su ref.id y su ref.chatId guardados): la clave antigua puede ser la de otro chat o la clave nueva de otro mensaje del mismo chat (la antigua c:%3A de ('c', '%3A') es la clave de ('c', ':')).
  • saveMessage ignora un mensaje sin ref.id o sin chatId (los providers envían ref.id: '' cuando no tienen un id estable), y getMessage de esa referencia resuelve undefined. Guardar de nuevo un id reemplaza el mensaje guardado.
  • getMessagesByChat(chatId, limit) devuelve los limit mensajes más recientes, del más antiguo al más nuevo. Un limit de 0 o menos no devuelve nada, una fracción se redondea hacia arriba e Infinity devuelve el log completo.
  • setUserData rechaza con un TypeError cuando 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:

yaml
# docker-compose.yml
services:
  bot:
    build: .
    volumes:
      - ./sessions:/app/sessions   # Auth de WhatsApp
      - ./data:/app/data           # Base de datos SQLite

Released under the MIT License.