ctx.user. Con el store en memoria por defecto, un reinicio
lo olvida todo; con uno persistente, el bot sigue donde lo dejó.
Todos los stores implementan el contrato IStore de @botmesh/core, neutral
respecto al transporte, así que el mismo store funciona con cualquier canal.
Elegir un store
Reglas prácticas:
- Un proceso del bot en un VPS o en un contenedor:
SQLiteStore. Sin servidor que mantener, un archivo que respaldar. - Varios procesos del mismo bot (réplicas), o estado que leen
otros servicios:
RedisStore. Todas las réplicas deben ver los mismos datos, así queMemoryStoreySQLiteStoreno sirven ahí. Si ya tienes PostgreSQL, MySQL/MariaDB o MongoDB, el store correspondiente hace lo mismo. - Pasa siempre una instancia de store (
store: new SQLiteStore({ … })). Un string comostore: 'sqlite'solo se acepta por compatibilidad y sigue creando unMemoryStore.
start() y stop() del store.
MemoryStore
Sin la opciónstore, el bot crea un MemoryStore. Todo vive en RAM. Un
archivo JSON opcional guarda una copia de chats, contactos, checkpoints de
flujos, conversaciones de IA y datos de ctx.user (no los mensajes) cada cierto
intervalo y en stop():
El log de mensajes tiene un tope de 1000 mensajes por chat por defecto porque
vive en RAM. Los stores persistentes no tienen tope salvo que pongas uno.
SQLiteStore
Una base de datos real en un archivo, sobre better-sqlite3.better-sqlite3
es una peer dependency, así que instálala junto al store:
file debe existir: SQLite crea el archivo, no sus carpetas.
Instalar better-sqlite3
better-sqlite3 es un módulo nativo. El rango soportado es
^11.7.0 || ^12.0.0 || ^13.0.0, y el store se prueba con los tres.
Opciones
Un número inválido (negativo, decimal) lanza un
RangeError en el constructor.
Qué almacena
Versiones del esquema y actualizaciones
El archivo guarda su estructura enPRAGMA user_version;
SQLITE_SCHEMA_VERSION es la versión que escribe esta release. Al abrir un
archivo se ejecutan en orden las migraciones pendientes, cada una en su propia
transacción, así que una base escrita por una release anterior se actualiza en
el sitio conservando sus filas. Nunca creas ni migras tablas a mano.
Un archivo escrito por una release más nueva se rechaza, sin tocarlo, con
SQLiteSchemaVersionError (code: 'SQLITE_SCHEMA_TOO_NEW', con found y
supported). No se admiten bajadas de versión, así que:
- Respalda el archivo antes de actualizar
@botmesh/sqlite-storesi puedes necesitar volver atrás. - Si varios procesos comparten un archivo, para los de la versión antigua antes de que uno nuevo lo abra.
Ciclo de vida
bot.stop() (y un bot.start() fallido) cierra la base de datos, y el
siguiente bot.start() la reabre, así que una misma instancia de
SQLiteStore, con los backends schedules() y durability() que entregó,
sobrevive a ciclos de parada y arranque.
SQLite guarda el texto en UTF-8, donde un surrogate UTF-16 suelto se convierte
en U+FFFD y dos ids distintos podrían compartir fila. saveMessage y
setCursor rechazan ese id, clave o valor de cursor con un TypeError.
Flujos persistentes
Con un store persistente, los checkpoints deflow(...).persistent()
sobreviven a un reinicio:
¿Y tu email? arriba) y la siguiente
respuesta del usuario es la que cuenta. Con durabilidad, el
mensaje que lo despierta se toma como la respuesta.
Se escribe un checkpoint al empezar el flujo y después de cada paso, así que
incluso un flujo que aún espera su primera pregunta se retoma ahí. Un
reinicio a mitad de un .form() vuelve a preguntar el
formulario desde su primer campo: todo el formulario es un solo paso.
RedisStore
Estado compartido por todos los procesos de un bot, sobre Redis (node-redis 4, 5 o 6 como peer dependency):
El mismo
RedisStore entrega los demás backends de Redis, todos sobre su
cliente y su prefijo: store.coordinator() para réplicas,
store.schedules() y store.leases() para el
scheduler durable. El backend de durabilidad es
new RedisDurability({ client, prefix }) (ver
Procesamiento durable).
Un cliente inyectado sin listener de 'error' tumba el proceso en la primera
caída de conexión (node-redis relanza el evento), así que ponle siempre uno. El
esquema de claves y el resto de detalles están en el
README del paquete.
PostgreSQL, MySQL y MongoDB
@botmesh/postgres-store, @botmesh/mysql-store y @botmesh/mongo-store
guardan el estado del bot en la base de datos que ya operas, compartida por
cualquier número de réplicas. Pasan la misma suite de conformidad que los demás
stores, crean sus tablas (o índices) en start() y traen un
store de schedules durable (store.schedules()). PostgreSQL
tiene además un backend de durabilidad, PostgresDurability.
El driver de la base de datos es una dependencia peer y solo se carga cuando el
store crea su propio pool o cliente:
pool / client), que
nunca cierran. Con un pool de pg inyectado, añade un listener
pool.on('error', …): node-postgres tumba el proceso cuando se cae una conexión
ociosa y nadie escucha. En PostgreSQL, tablePrefix admite como mucho 42
caracteres (el servidor trunca los nombres a 63 bytes; uno más largo lanza
RangeError). Las opciones, el esquema y las tablas de errores están en
el README de cada paquete: PostgreSQL,
MySQL/MariaDB,
MongoDB.
Tablas de datos de usuario
Los perfiles dectx.user se guardan junto al resto del
estado del bot, respetando tablePrefix / collectionPrefix:
Las entradas caducadas se borran de forma perezosa con
bot.purgeExpiredUserData() (Redis las caduca solo). Los stores SQL y Mongo
las borran en lotes de purgeBatchSize (por defecto 1000) hasta que no queda
nada caducado, así que un gran atraso nunca se ejecuta como un único borrado
largo.
Escribir tu propio store
ImplementaIStore para conectar otro backend. Quien escribe un store importa
el contrato y sus helpers desde @botmesh/core/spi:
MemoryStore es la implementación de referencia, y dentro del monorepo todos
los stores pasan 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. El bot ya lo hace: un mensaje sin nombre visible nunca borra el guardado.deleteChatborra también el log de mensajes de ese chat.getMessagesByChat(chatId, limit)devuelve loslimitmensajes más recientes, del más antiguo al más nuevo (por defecto50). Unlimitde0o menos no devuelve nada, una fracción se redondea hacia arriba eInfinitydevuelve todo el log. Los stores persistentes ordenan portimestamp, así que volver a guardar un mensaje con otro timestamp lo mueve;MemoryStorelo deja en la posición de su primer guardado.saveMessageignora un mensaje sinref.ido sinchatId(los providers mandanref.id: ''cuando no tienen un id estable), ygetMessagede esa ref resuelveundefined. Volver a guardar un id reemplaza el mensaje.maxMessagesPerChates opcional en los stores persistentes. Rechaza un tope negativo o decimal con unRangeError.setUserDatarechaza con unTypeErrorun valor que no es JSON (undefined) y no escribe nada.- Las filas de mensajes se indexan con
messageKey(ref)(de@botmesh/core/spi):{chatId}:{id}con%y:escapados en cada parte (%25,%3A), para que los ids con:(los chat idswhatsapp:+1…de Twilio) nunca choquen. Las filas escritas con la clave antigua sin escapar (legacyMessageKey(ref)) deben seguir leyéndose y borrándose, comprobando que elref.idy elref.chatIdguardados son los del mensaje pedido, y al volver a guardarlas pasan a la clave nueva.
- Datos de usuario (
getUserData,setUserData,deleteUserData,listUserData,purgeExpiredUserData): sin ellos,ctx.userrechaza conUNSUPPORTED_STORE. Compruébalos contest-utils/user-data-conformance.ts. - Cursores (
getCursor/setCursor): además persisten las marcas deWELCOMEy el índice de handoff. - Checkpoints condicionales (
setCheckpointIf/clearCheckpointIf): necesarios para un relevo seguro entre réplicas. Sin ellos, el bot avisa en el log cuando la coordinación está activa.