Engine¶
La capa de persistencia de @arcaelas/whatsapp v3.
Filosofía¶
Engine es un contrato key/value solo de strings. No sabe nada sobre WhatsApp, JSON o Buffers — simplemente almacena y recupera strings opacos bajo rutas jerárquicas.
| Preocupación | Vive en |
|---|---|
| Protocolo de cable | baileys |
| Formas del dominio | IChatRaw, IContactRaw, IMessage |
| Serialización | serialize / deserialize (BufferJSON, en ~/lib/store) |
| Persistencia | Implementaciones de Engine |
Esta separación significa que una implementación de motor puede estar respaldada por cualquier cosa que pueda get/set/unset/list/count/clear strings bajo una ruta: un árbol de archivos, Redis, SQLite, DynamoDB, un map en memoria para pruebas, etc.
Interfaz¶
import type { Engine } from '@arcaelas/whatsapp';
interface Engine {
/** Lee un valor por ruta. Devuelve null si no existe. */
get(path: string): Promise<string | null>;
/** Escribe un valor. DEBE refrescar el mtime usado por `list`. */
set(path: string, value: string): Promise<void>;
/**
* Elimina el valor Y todo descendiente bajo `path`.
* DEBE ser idempotente — nunca lanzar cuando `path` no exista.
*/
unset(path: string): Promise<boolean>;
/**
* Lista los valores de los **hijos directos** de `path`,
* paginados y ordenados por mtime DESC.
*/
list(path: string, offset?: number, limit?: number): Promise<string[]>;
/** Cuenta los hijos directos de `path` sin cargar sus valores. */
count(path: string): Promise<number>;
/** Descarta todo en el namespace de este motor. */
clear(): Promise<void>;
}
Semántica por método¶
| Método | Contrato |
|---|---|
get | Devuelve el string exacto escrito previamente por set, o null si la ruta nunca se escribió / fue eliminada. |
set | Sobrescribe cualquier valor anterior. Refresca el mtime de la ruta para que los siguientes list reordenen correctamente. |
unset | Cascada — elimina la ruta y todas las sub-rutas. Idempotente: devuelve true incluso cuando no existía nada. |
list | Devuelve los valores (no las claves) de los hijos directos, ordenados por mtime DESC, recortados por offset/limit. Por defecto: offset=0, limit=50. |
count | Devuelve el número de hijos directos. Debe ser O(1) donde el backend lo permita (ZCARD en Redis). |
clear | Limpia todo el keyspace del motor. Usado en loggedOut cuando autoclean: true. |
Información: normalización de rutas
Ambos drivers integrados colapsan // y recortan las / iniciales/finales antes de usar. Un motor personalizado debería hacer lo mismo para que /chat/x, chat/x y /chat//x/ resuelvan a la misma clave.
Advertencia: list devuelve valores, no claves
A diferencia de muchas APIs key/value, Engine.list devuelve el contenido del documento. Esto le permite al orquestador hacer lecturas paginadas en un solo round-trip (ZREVRANGE + MGET en Redis, readdir + readFile paralelo en disco).
Implementaciones integradas¶
RedisEngine¶
import IORedis from 'ioredis';
import { RedisEngine, WhatsApp } from '@arcaelas/whatsapp';
const redis = new IORedis(process.env.REDIS_URL!);
const engine = new RedisEngine(redis, 'wa:584144709840');
const wa = new WhatsApp({ engine, phone: 584144709840 });
Keyspaces:
<prefix>:doc:<path> # el documento
<prefix>:idx:<parent_path> # sorted set: score=mtime, member=ruta hija completa
Puntos destacados:
listes unZREVRANGE+ unMGET— sin round-trip por documento.countesZCARD(O(1)).unsetcascada porSCAN/DELsobredoc:<path>/*eidx:<path>(/*).clearlimpia todo lo que coincida con<prefix>:*.
La interfaz mínima del cliente (RedisClient) solo requiere los comandos que el motor realmente usa, por lo que funciona con ioredis, node-redis (con adaptadores delgados), o cualquier driver compatible.
FileSystemEngine¶
import { FileSystemEngine, WhatsApp } from '@arcaelas/whatsapp';
import { join } from 'node:path';
const engine = new FileSystemEngine(join(process.cwd(), '.baileys'));
const wa = new WhatsApp({ engine, phone: 584144709840 });
Layout en disco:
Cada documento vive bajo su propio directorio para poder coexistir con sub-recursos anidados (p. ej. un directorio de chat contiene tanto index.json como un directorio message/).
Puntos destacados:
sethacemkdir -pluegowriteFile.listleemtimeMspara elindex.jsonde cada hijo y ordena DESC.unsetesrm -rfsobre el directorio de la ruta. Idempotente.clearesrm -rfsobre todo el directorio base.
Implementando un motor personalizado¶
A continuación hay dos plantillas listas para ajustar. Ambas respetan el contrato completo; solo set necesita refrescar el mtime por ruta para que list ordene correctamente.
Motor en memoria (pruebas, fixtures)¶
import type { Engine } from '@arcaelas/whatsapp';
function normalize(path: string): string {
return path.replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
}
export class MemoryEngine implements Engine {
private readonly _docs = new Map<string, { value: string; mtime: number }>();
async get(path: string): Promise<string | null> {
return this._docs.get(normalize(path))?.value ?? null;
}
async set(path: string, value: string): Promise<void> {
this._docs.set(normalize(path), { value, mtime: Date.now() });
}
async unset(path: string): Promise<boolean> {
const root = normalize(path);
const prefix = `${root}/`;
for (const key of this._docs.keys()) {
if (key === root || key.startsWith(prefix)) {
this._docs.delete(key);
}
}
return true;
}
async list(path: string, offset = 0, limit = 50): Promise<string[]> {
const root = normalize(path);
const prefix = root === '' ? '' : `${root}/`;
const direct: Array<{ value: string; mtime: number }> = [];
for (const [key, entry] of this._docs) {
if (!key.startsWith(prefix)) continue;
const rest = key.slice(prefix.length);
if (rest.length === 0 || rest.includes('/')) continue;
direct.push(entry);
}
direct.sort((a, b) => b.mtime - a.mtime);
return direct.slice(offset, offset + limit).map((e) => e.value);
}
async count(path: string): Promise<number> {
const root = normalize(path);
const prefix = root === '' ? '' : `${root}/`;
let total = 0;
for (const key of this._docs.keys()) {
if (!key.startsWith(prefix)) continue;
const rest = key.slice(prefix.length);
if (rest.length > 0 && !rest.includes('/')) total++;
}
return total;
}
async clear(): Promise<void> {
this._docs.clear();
}
}
Motor SQLite¶
Un esquema de una sola tabla es suficiente — mantén índices (mtime DESC) y amigables con prefijos en la columna de path.
import Database from 'better-sqlite3';
import type { Engine } from '@arcaelas/whatsapp';
function normalize(path: string): string {
return path.replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
}
export class SqliteEngine implements Engine {
private readonly _db: Database.Database;
constructor(file: string) {
this._db = new Database(file);
this._db.exec(`
CREATE TABLE IF NOT EXISTS docs (
path TEXT PRIMARY KEY,
value TEXT NOT NULL,
mtime INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_docs_mtime ON docs (mtime DESC);
CREATE INDEX IF NOT EXISTS idx_docs_prefix ON docs (path COLLATE BINARY);
`);
}
async get(path: string): Promise<string | null> {
const row = this._db
.prepare('SELECT value FROM docs WHERE path = ?')
.get(normalize(path)) as { value: string } | undefined;
return row?.value ?? null;
}
async set(path: string, value: string): Promise<void> {
this._db
.prepare(
`INSERT INTO docs (path, value, mtime) VALUES (?, ?, ?)
ON CONFLICT(path) DO UPDATE SET value = excluded.value, mtime = excluded.mtime`
)
.run(normalize(path), value, Date.now());
}
async unset(path: string): Promise<boolean> {
const root = normalize(path);
this._db
.prepare('DELETE FROM docs WHERE path = ? OR path LIKE ?')
.run(root, `${root}/%`);
return true;
}
async list(path: string, offset = 0, limit = 50): Promise<string[]> {
const root = normalize(path);
const prefix = root === '' ? '' : `${root}/`;
const rows = this._db
.prepare(
`SELECT value, path FROM docs
WHERE path LIKE ?
ORDER BY mtime DESC`
)
.all(`${prefix}%`) as { value: string; path: string }[];
const direct = rows.filter((r) => {
const rest = r.path.slice(prefix.length);
return rest.length > 0 && !rest.includes('/');
});
return direct.slice(offset, offset + limit).map((r) => r.value);
}
async count(path: string): Promise<number> {
const root = normalize(path);
const prefix = root === '' ? '' : `${root}/`;
const rows = this._db
.prepare('SELECT path FROM docs WHERE path LIKE ?')
.all(`${prefix}%`) as { path: string }[];
let total = 0;
for (const r of rows) {
const rest = r.path.slice(prefix.length);
if (rest.length > 0 && !rest.includes('/')) total++;
}
return total;
}
async clear(): Promise<void> {
this._db.prepare('DELETE FROM docs').run();
}
}
Consejo: casos límite que vale la pena probar
unsetsobre una ruta faltante devuelvetrue(idempotente).listde una ruta sin hijos devuelve[], nunca lanza.setsobre una ruta existente sobrescribe e incrementa el mtime — las posiciones antiguas enlistdeberían desaparecer y el nuevo valor aparecer en la parte superior.- Normalización de rutas:
chat/x,/chat/xy/chat//x/todos impactan el mismo registro.
Multicuenta: un proceso, varios motores¶
Cada instancia WhatsApp posee exactamente un Engine. Para ejecutar varias cuentas concurrentemente en el mismo proceso, dale a cada una su propio motor — posiblemente de tipos diferentes:
import IORedis from 'ioredis';
import { join } from 'node:path';
import {
FileSystemEngine,
RedisEngine,
WhatsApp,
} from '@arcaelas/whatsapp';
const redis = new IORedis(process.env.REDIS_URL!);
// Cuenta A — respaldada por Redis (hot, amigable con múltiples instancias)
const wa_a = new WhatsApp({
engine: new RedisEngine(redis, 'wa:584144709840'),
phone: 584144709840,
});
// Cuenta B — filesystem local (bot de un solo host, fácil de inspeccionar)
const wa_b = new WhatsApp({
engine: new FileSystemEngine(join(process.cwd(), '.sessions/B')),
phone: 584121234567,
});
await Promise.all([
wa_a.connect((auth) => console.log('A:', auth)),
wa_b.connect((auth) => console.log('B:', auth)),
]);
Dos reglas que recordar:
- Nunca compartas una instancia de motor entre dos clientes
WhatsApp. El estado colisionaría bajo las mismas rutas. Con Redis, dale a cada cuenta unprefixúnico. Con FileSystem, dale a cada cuenta un directorio base único. - El motor ya debe estar conectado cuando construyes
WhatsApp— es leído por el constructor y usado inmediatamente enconnect().
autoclean y loggedOut¶
Cuando baileys reporta DisconnectReason.loggedOut, el orquestador decide qué hacer con el motor antes de emitir disconnected, para que los listeners siempre observen el estado final:
Valor de autoclean | Acción en loggedOut |
|---|---|
true (por defecto) | await engine.clear() — todo el namespace del motor se limpia. |
false | await engine.unset('/session/creds') — solo credenciales; chats / contactos / mensajes se preservan. |
// Limpiar todo cuando el usuario hace logout desde el teléfono
const wa1 = new WhatsApp({ engine, autoclean: true });
// Mantener historial; solo forzar re-autenticación en el próximo connect
const wa2 = new WhatsApp({ engine, autoclean: false });
disconnect({ destroy: true }) también llama a engine.clear(), independientemente de autoclean, por lo que una destrucción manual siempre está a una bandera de distancia:
Nota: la limpieza ocurre antes del evento
El orquestador espera a que termine la limpieza del motor antes de emitir disconnected. Cualquier handler adjunto vía wa.on('disconnected', ...) garantizado verá el estado post-limpieza del store.