Engine¶
The persistence layer of @arcaelas/whatsapp v3.
Philosophy¶
Engine is a string-only key/value contract. It knows nothing about WhatsApp, JSON, or Buffers — it just stores and retrieves opaque strings under hierarchical paths.
| Concern | Lives in |
|---|---|
| Wire protocol | baileys |
| Domain shapes | IChatRaw, IContactRaw, IMessage |
| Serialization | serialize / deserialize (BufferJSON, in ~/lib/store) |
| Persistence | Engine implementations |
This separation means an engine implementation can be backed by anything that can get/set/unset/list/count/clear strings under a path: a file tree, Redis, SQLite, DynamoDB, an in-memory map for tests, etc.
Interface¶
import type { Engine } from '@arcaelas/whatsapp';
interface Engine {
/** Read a value by path. Returns null if missing. */
get(path: string): Promise<string | null>;
/** Write a value. MUST refresh the mtime used by `list`. */
set(path: string, value: string): Promise<void>;
/**
* Delete the value AND every descendant under `path`.
* MUST be idempotent — never throw when `path` does not exist.
*/
unset(path: string): Promise<boolean>;
/**
* List values of the **direct children** of `path`,
* paginated and ordered by mtime DESC.
*/
list(path: string, offset?: number, limit?: number): Promise<string[]>;
/** Count direct children of `path` without loading their values. */
count(path: string): Promise<number>;
/** Drop everything in this engine's namespace. */
clear(): Promise<void>;
}
Per-method semantics¶
| Method | Contract |
|---|---|
get | Returns the exact string previously written by set, or null if the path was never written / was unset. |
set | Overwrites any prior value. Refreshes the path's mtime so subsequent list calls reorder correctly. |
unset | Cascades — removes the path and all sub-paths. Idempotent: returns true even when nothing existed. |
list | Returns the values (not the keys) of direct children, sorted by mtime DESC, sliced by offset/limit. Defaults: offset=0, limit=50. |
count | Returns the number of direct children. Should be O(1) where the backend allows (ZCARD in Redis). |
clear | Wipes the engine's full keyspace. Used on loggedOut when autoclean: true. |
Path normalization
Both built-in drivers collapse // and trim leading/trailing / before use. A custom engine should do the same so that /chat/x, chat/x, and /chat//x/ all resolve to the same key.
list returns values, not keys
Unlike many key/value APIs, Engine.list returns the document contents. This lets the orchestrator do paginated reads in a single round-trip (ZREVRANGE + MGET on Redis, readdir + parallel readFile on disk).
Built-in implementations¶
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> # the document
<prefix>:idx:<parent_path> # sorted set: score=mtime, member=full child path
Highlights:
listis oneZREVRANGE+ oneMGET— no per-document round-trip.countisZCARD(O(1)).unsetcascades bySCAN/DELoverdoc:<path>/*andidx:<path>(/*).clearwipes everything matching<prefix>:*.
The minimal client interface (RedisClient) only requires the commands the engine actually uses, so it works with ioredis, node-redis (with thin adapters), or any compatible driver.
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 on disk:
Each document lives under its own directory so it can coexist with nested sub-resources (e.g. a chat directory contains both index.json and a message/ directory).
Highlights:
setdoesmkdir -pthenwriteFile.listreadsmtimeMsfor each child'sindex.jsonand sorts DESC.unsetisrm -rfon the path's directory. Idempotent.clearisrm -rfon the whole base directory.
Implementing a custom engine¶
Two ready-to-tweak stubs follow. Both honour the full contract; only set needs to refresh the per-path mtime so list orders correctly.
In-memory engine (tests, 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();
}
}
SQLite engine¶
A single-table schema is enough — keep (mtime DESC) and prefix-friendly indexes on the path column.
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();
}
}
Edge cases worth testing
unseton a missing path returnstrue(idempotent).listof a path with no children returns[], never throws.setof an existing path overwrites and bumps the mtime — old positions inlistshould disappear and the new value should appear at the top.- Path normalization:
chat/x,/chat/x, and/chat//x/all hit the same record.
Multi-account: one process, several engines¶
Each WhatsApp instance owns exactly one Engine. To run several accounts concurrently in the same process, give each its own engine — possibly of different types:
import IORedis from 'ioredis';
import { join } from 'node:path';
import {
FileSystemEngine,
RedisEngine,
WhatsApp,
} from '@arcaelas/whatsapp';
const redis = new IORedis(process.env.REDIS_URL!);
// Account A — Redis-backed (hot, multi-instance friendly)
const wa_a = new WhatsApp({
engine: new RedisEngine(redis, 'wa:584144709840'),
phone: 584144709840,
});
// Account B — local filesystem (single-host bot, easy to inspect)
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)),
]);
Two rules to remember:
- Never share an engine instance between two
WhatsAppclients. State would collide under the same paths. With Redis, give each account a uniqueprefix. With FileSystem, give each account a unique base directory. - The engine must already be wired when you construct
WhatsApp— it is read by the constructor and used immediately onconnect().
autoclean and loggedOut¶
When baileys reports DisconnectReason.loggedOut, the orchestrator decides what to do with the engine before emitting disconnected, so listeners always observe the final state:
autoclean value | Action on loggedOut |
|---|---|
true (default) | await engine.clear() — the entire engine namespace is wiped. |
false | await engine.unset('/session/creds') — credentials only; chats / contacts / messages are preserved. |
// Wipe everything when the user logs out from the phone
const wa1 = new WhatsApp({ engine, autoclean: true });
// Keep history; only force re-authentication on next connect
const wa2 = new WhatsApp({ engine, autoclean: false });
disconnect({ destroy: true }) also calls engine.clear(), regardless of autoclean, so a manual nuke is always one flag away:
Cleanup happens before the event
The orchestrator awaits the engine cleanup before emitting disconnected. Any handler attached via wa.on('disconnected', ...) is guaranteed to see the post-cleanup state of the store.