tina4-nodejs 3.13.23 → 3.13.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,28 +2,53 @@
2
2
  * Multi-backend response cache for GET requests.
3
3
  *
4
4
  * Backends are selected via the TINA4_CACHE_BACKEND env var:
5
- * memory — in-process LRU cache (default, zero deps)
6
- * redis Redis / Valkey (uses `ioredis` or raw RESP over TCP)
7
- * file JSON files in data/cache/
5
+ * memory — in-process LRU cache (default, zero deps)
6
+ * file JSON files in data/cache/
7
+ * redis Redis (raw RESP over TCP, or the `redis` package when present)
8
+ * valkey — Valkey (Redis wire protocol — reuses the Redis backend)
9
+ * memcached — Memcached (zero-dep text protocol over TCP; SHA-256-hashed keys)
10
+ * mongodb — MongoDB TTL collection (optional `mongodb` driver, loaded dynamically)
11
+ * database — a `tina4_cache` table in any Tina4-supported DB (via @tina4/orm)
8
12
  *
9
- * Usage:
13
+ * Usage (the KV/module API is ASYNC on Node — Node is async-everywhere):
10
14
  * import { responseCache, cacheGet, cacheSet, cacheDelete, cacheClear, cacheStats } from "./cache.js";
11
15
  *
12
16
  * // As middleware — caches GET responses for ttl seconds
13
17
  * middleware.use(responseCache({ ttl: 60 }));
14
18
  *
15
- * // Direct usage (same across all 4 languages)
16
- * cacheSet("key", {"data": "value"}, 120);
17
- * const value = cacheGet("key");
18
- * cacheDelete("key");
19
- * cacheClear();
20
- * const stats = cacheStats();
19
+ * // Direct usage (await — same semantics as the other 3 languages, async transport)
20
+ * await cacheSet("key", {"data": "value"}, 120);
21
+ * const value = await cacheGet("key");
22
+ * await cacheDelete("key");
23
+ * await cacheClear();
24
+ * const stats = await cacheStats();
21
25
  *
22
- * Environment:
23
- * TINA4_CACHE_BACKEND — memory | redis | file (default: memory)
24
- * TINA4_CACHE_URL — redis://localhost:6379 (redis only)
26
+ * Availability + file-fallback (mirrors the Python master):
27
+ * Each network/driver backend reports availability (redis/valkey connect+AUTH+PING,
28
+ * memcached VERSION, mongo connect+ping, database connect). When the configured
29
+ * backend's service is unreachable (or its driver is missing / credentials are
30
+ * wrong), createBackend() logs a warning and falls back to the `file` backend —
31
+ * a real, persistent cache, never a silent no-op. The probe is asynchronous, so
32
+ * createBackend() returns a Promise.
33
+ *
34
+ * Asynchronous, NATIVE network I/O (NO child process):
35
+ * The CacheBackend interface is async (get/set/delete/clear/stats return
36
+ * Promises). The network backends use native async Node I/O — redis/valkey speak
37
+ * RESP over a node:net socket (with AUTH + SELECT db), memcached speaks its text
38
+ * protocol over node:net, and mongodb uses the optional `mongodb` driver. No
39
+ * execFileSync, no child processes — connections are pooled per backend instance
40
+ * so each cache op is a single async round-trip (~sub-ms locally), not a ~30-80ms
41
+ * process spawn. Local backends (memory/file/database-sqlite) resolve immediately.
42
+ *
43
+ * Environment (LOCKED — matches Python exactly):
44
+ * TINA4_CACHE_BACKEND — memory | file | redis | valkey | memcached | mongodb | database (default: memory)
45
+ * TINA4_CACHE_URL — connection URL (redis/valkey/memcached/mongo), or a SQL URL for `database`
46
+ * (database falls back to TINA4_DATABASE_URL)
25
47
  * TINA4_CACHE_TTL — default TTL in seconds (default: 60)
26
48
  * TINA4_CACHE_MAX_ENTRIES — max entries (default: 1000)
49
+ * TINA4_CACHE_DIR — file backend directory (default: data/cache)
50
+ * TINA4_CACHE_USERNAME — credentials when not embedded in the URL
51
+ * TINA4_CACHE_PASSWORD — credentials when not embedded in the URL
27
52
  */
28
53
 
29
54
  import type { Middleware } from "./types.js";
@@ -32,6 +57,63 @@ import * as path from "node:path";
32
57
  import * as crypto from "node:crypto";
33
58
  import * as net from "node:net";
34
59
 
60
+ // ── Credential parsing (WHATWG URL or TINA4_CACHE_USERNAME / _PASSWORD) ──
61
+
62
+ interface ParsedCacheUrl {
63
+ host: string;
64
+ port: number;
65
+ db: number;
66
+ username: string | null;
67
+ password: string | null;
68
+ /** Path component after the host (used by mongo for db/collection). */
69
+ pathName: string;
70
+ }
71
+
72
+ /**
73
+ * Parse a redis/valkey/memcached/mongodb connection URL.
74
+ *
75
+ * Credentials come from the URL (`scheme://user:pass@host`, `scheme://:pass@host`)
76
+ * via the WHATWG URL parser, OR from TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD
77
+ * (parity with TINA4_DATABASE_USERNAME / _PASSWORD). Mirrors the Python master's
78
+ * _RedisBackend / _MongoBackend credential handling.
79
+ */
80
+ function parseCacheUrl(url: string, defaultPort: number): ParsedCacheUrl {
81
+ // Ensure a scheme so the WHATWG URL parser accepts it.
82
+ const withScheme = url.includes("://") ? url : `redis://${url}`;
83
+ let host = "localhost";
84
+ let port = defaultPort;
85
+ let db = 0;
86
+ let username: string | null = null;
87
+ let password: string | null = null;
88
+ let pathName = "";
89
+ try {
90
+ const u = new URL(withScheme);
91
+ host = u.hostname || "localhost";
92
+ port = u.port ? parseInt(u.port, 10) : defaultPort;
93
+ pathName = u.pathname || "";
94
+ const dbPath = pathName.replace(/^\//, "");
95
+ if (/^\d+$/.test(dbPath)) db = parseInt(dbPath, 10);
96
+ // URL may percent-encode credentials — decode them.
97
+ username = u.username ? decodeURIComponent(u.username) : null;
98
+ password = u.password ? decodeURIComponent(u.password) : null;
99
+ } catch {
100
+ // Best-effort fallback for malformed URLs.
101
+ const cleaned = withScheme.replace(/^\w+:\/\//, "");
102
+ const hostPort = cleaned.split("/")[0].split("@").pop() ?? cleaned;
103
+ const parts = hostPort.split(":");
104
+ host = parts[0] || "localhost";
105
+ port = parts[1] ? parseInt(parts[1], 10) || defaultPort : defaultPort;
106
+ }
107
+ // Env-var credentials only fill the gap left by the URL (URL wins).
108
+ if (username === null && process.env.TINA4_CACHE_USERNAME) {
109
+ username = process.env.TINA4_CACHE_USERNAME;
110
+ }
111
+ if (password === null && process.env.TINA4_CACHE_PASSWORD) {
112
+ password = process.env.TINA4_CACHE_PASSWORD;
113
+ }
114
+ return { host, port, db, username, password, pathName };
115
+ }
116
+
35
117
  // ── Types ─────────────────────────────────────────────────────────
36
118
 
37
119
  interface CacheEntry {
@@ -64,12 +146,23 @@ export interface ResponseCacheConfig {
64
146
  // ── Backend interface ─────────────────────────────────────────────
65
147
 
66
148
  interface CacheBackend {
67
- get(key: string): unknown | undefined;
68
- set(key: string, value: unknown, ttl: number): void;
69
- delete(key: string): boolean;
70
- clear(): void;
71
- stats(): { hits: number; misses: number; size: number; backend: string };
149
+ get(key: string): Promise<unknown | undefined>;
150
+ set(key: string, value: unknown, ttl: number): Promise<void>;
151
+ delete(key: string): Promise<boolean>;
152
+ clear(): Promise<void>;
153
+ stats(): Promise<{ hits: number; misses: number; size: number; backend: string }>;
72
154
  name(): string;
155
+ /**
156
+ * Whether this backend is actually usable (driver present + service
157
+ * reachable). Local backends (memory/file) are always available; network /
158
+ * driver backends override this so the factory can fall back to the file
159
+ * backend. Mirrors the Python master's `is_available()`. Probed asynchronously
160
+ * (connect/AUTH/PING/VERSION/ping) so no child process is spawned.
161
+ */
162
+ isAvailable?(): Promise<boolean>;
163
+ /** One-time async connect/probe. Resolves once the backend has decided
164
+ * availability; createBackend() awaits this before falling back to file. */
165
+ ready?(): Promise<void>;
73
166
  }
74
167
 
75
168
  // ── Memory backend ────────────────────────────────────────────────
@@ -84,7 +177,7 @@ class MemoryBackend implements CacheBackend {
84
177
  this.maxEntries = maxEntries;
85
178
  }
86
179
 
87
- get(key: string): unknown | undefined {
180
+ async get(key: string): Promise<unknown | undefined> {
88
181
  const entry = this.store.get(key);
89
182
  if (!entry) {
90
183
  this.misses++;
@@ -102,7 +195,7 @@ class MemoryBackend implements CacheBackend {
102
195
  return entry.value;
103
196
  }
104
197
 
105
- set(key: string, value: unknown, ttl: number): void {
198
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
106
199
  const expiresAt = ttl > 0 ? Date.now() + ttl * 1000 : 0;
107
200
  this.store.delete(key); // remove to re-insert at end
108
201
  this.store.set(key, { value, expiresAt });
@@ -113,17 +206,17 @@ class MemoryBackend implements CacheBackend {
113
206
  }
114
207
  }
115
208
 
116
- delete(key: string): boolean {
209
+ async delete(key: string): Promise<boolean> {
117
210
  return this.store.delete(key);
118
211
  }
119
212
 
120
- clear(): void {
213
+ async clear(): Promise<void> {
121
214
  this.store.clear();
122
215
  this.hits = 0;
123
216
  this.misses = 0;
124
217
  }
125
218
 
126
- stats() {
219
+ async stats() {
127
220
  // Sweep expired
128
221
  const now = Date.now();
129
222
  for (const [key, entry] of this.store) {
@@ -135,108 +228,320 @@ class MemoryBackend implements CacheBackend {
135
228
  name() { return "memory"; }
136
229
  }
137
230
 
138
- // ── Redis backend ─────────────────────────────────────────────────
231
+ // ── Zero-dep async RESP client over node:net (no child process) ──────
139
232
 
140
- class RedisBackend implements CacheBackend {
233
+ /** A RESP reply: string (simple/bulk/integer), array, null (nil), or Error. */
234
+ type RespReply = string | RespReply[] | null | Error;
235
+
236
+ /**
237
+ * Minimal async RESP client over a single persistent node:net socket. Commands
238
+ * are pipelined and replies dequeued in order (Redis preserves command order on
239
+ * one connection). Zero dependencies — preferred over adding redis/ioredis as a
240
+ * hard dep. Handles AUTH + SELECT on connect so wrong credentials surface as a
241
+ * failed handshake (→ file fallback). Replaces the old execFileSync transport.
242
+ */
243
+ class RespClient {
141
244
  private host: string;
142
245
  private port: number;
143
246
  private db: number;
144
- private prefix = "tina4:cache:";
145
- private hits = 0;
146
- private misses = 0;
147
- private maxEntries: number;
247
+ private username: string | null;
248
+ private password: string | null;
249
+ private sock: net.Socket | null = null;
250
+ private buffer = Buffer.alloc(0);
251
+ private waiters: Array<{ resolve: (r: RespReply) => void; reject: (e: Error) => void }> = [];
252
+ private connecting: Promise<void> | null = null;
253
+ private connected = false;
254
+ private brokenError: Error | null = null;
255
+
256
+ constructor(opts: { host: string; port: number; db: number; username: string | null; password: string | null }) {
257
+ this.host = opts.host;
258
+ this.port = opts.port;
259
+ this.db = opts.db;
260
+ this.username = opts.username;
261
+ this.password = opts.password;
262
+ }
148
263
 
149
- constructor(url = "redis://localhost:6379", maxEntries = 1000) {
150
- this.maxEntries = maxEntries;
151
- const cleaned = url.replace("redis://", "");
152
- const parts = cleaned.split(":");
153
- this.host = parts[0] || "localhost";
154
- const portAndDb = parts[1] ? parts[1].split("/") : ["6379"];
155
- this.port = parseInt(portAndDb[0], 10) || 6379;
156
- this.db = portAndDb[1] ? parseInt(portAndDb[1], 10) : 0;
264
+ /** Encode a RESP command (array of bulk strings). */
265
+ private static encode(args: (string | number)[]): Buffer {
266
+ let cmd = `*${args.length}\r\n`;
267
+ for (const a of args) {
268
+ const s = String(a);
269
+ cmd += `$${Buffer.byteLength(s)}\r\n${s}\r\n`;
270
+ }
271
+ return Buffer.from(cmd, "utf-8");
157
272
  }
158
273
 
159
- private respCommand(...args: string[]): Promise<string | null> {
160
- return new Promise((resolve) => {
161
- try {
162
- let cmd = `*${args.length}\r\n`;
163
- for (const arg of args) {
164
- cmd += `$${Buffer.byteLength(arg)}\r\n${arg}\r\n`;
274
+ /** Connect once, run the AUTH/SELECT handshake. Idempotent. */
275
+ private connect(): Promise<void> {
276
+ if (this.connected) return Promise.resolve();
277
+ if (this.connecting) return this.connecting;
278
+ this.connecting = new Promise<void>((resolve, reject) => {
279
+ const sock = net.createConnection({ host: this.host, port: this.port });
280
+ sock.setNoDelay(true);
281
+ const onError = (err: Error) => {
282
+ this.brokenError = err;
283
+ // Fail every pending waiter so callers don't hang.
284
+ for (const w of this.waiters.splice(0)) w.reject(err);
285
+ this.connected = false;
286
+ try { sock.destroy(); } catch { /* noop */ }
287
+ reject(err);
288
+ };
289
+ sock.once("error", onError);
290
+ sock.on("data", (chunk) => this.onData(chunk));
291
+ sock.on("close", () => {
292
+ this.connected = false;
293
+ const err = this.brokenError ?? new Error("redis connection closed");
294
+ for (const w of this.waiters.splice(0)) w.reject(err);
295
+ });
296
+ sock.once("connect", async () => {
297
+ this.sock = sock;
298
+ this.connected = true;
299
+ try {
300
+ if (this.password) {
301
+ const authArgs = this.username
302
+ ? ["AUTH", this.username, this.password]
303
+ : ["AUTH", this.password];
304
+ const r = await this.raw(authArgs);
305
+ if (r instanceof Error) throw r;
306
+ }
307
+ if (this.db !== 0) {
308
+ const r = await this.raw(["SELECT", String(this.db)]);
309
+ if (r instanceof Error) throw r;
310
+ }
311
+ // Swap the bootstrap error handler for a non-rejecting one.
312
+ sock.removeListener("error", onError);
313
+ sock.on("error", (e: Error) => { this.brokenError = e; });
314
+ resolve();
315
+ } catch (e) {
316
+ onError(e as Error);
165
317
  }
318
+ });
319
+ });
320
+ return this.connecting;
321
+ }
166
322
 
167
- const sock = new net.Socket();
168
- sock.setTimeout(5000);
169
- let response = "";
323
+ /** Feed incoming bytes through the RESP parser and settle waiters in order. */
324
+ private onData(chunk: Buffer): void {
325
+ this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
326
+ // Parse as many complete replies as the buffer holds.
327
+ // eslint-disable-next-line no-constant-condition
328
+ while (true) {
329
+ const parsed = RespClient.parse(this.buffer, 0);
330
+ if (!parsed) break;
331
+ this.buffer = this.buffer.subarray(parsed.next);
332
+ const w = this.waiters.shift();
333
+ if (w) {
334
+ if (parsed.value instanceof Error) w.resolve(parsed.value); // surface as reply, caller decides
335
+ else w.resolve(parsed.value);
336
+ }
337
+ }
338
+ }
170
339
 
171
- sock.connect(this.port, this.host, () => {
172
- if (this.db > 0) {
173
- const selectCmd = `*2\r\n$6\r\nSELECT\r\n$${String(this.db).length}\r\n${this.db}\r\n`;
174
- sock.write(selectCmd);
175
- }
176
- sock.write(cmd);
177
- });
340
+ /** Parse one RESP value at offset. Returns null if more bytes are needed. */
341
+ private static parse(buf: Buffer, offset: number): { value: RespReply; next: number } | null {
342
+ if (offset >= buf.length) return null;
343
+ const type = buf[offset];
344
+ const crlf = buf.indexOf("\r\n", offset + 1, "utf-8");
345
+ if (crlf === -1) return null;
346
+ const line = buf.toString("utf-8", offset + 1, crlf);
347
+ const after = crlf + 2;
348
+ switch (type) {
349
+ case 0x2b: // '+' simple string
350
+ return { value: line, next: after };
351
+ case 0x3a: // ':' integer
352
+ return { value: line, next: after };
353
+ case 0x2d: // '-' error
354
+ return { value: new Error(line), next: after };
355
+ case 0x24: { // '$' bulk string
356
+ const len = parseInt(line, 10);
357
+ if (len === -1) return { value: null, next: after };
358
+ if (after + len + 2 > buf.length) return null; // need more
359
+ const str = buf.toString("utf-8", after, after + len);
360
+ return { value: str, next: after + len + 2 };
361
+ }
362
+ case 0x2a: { // '*' array
363
+ const count = parseInt(line, 10);
364
+ if (count === -1) return { value: null, next: after };
365
+ const arr: RespReply[] = [];
366
+ let pos = after;
367
+ for (let i = 0; i < count; i++) {
368
+ const el = RespClient.parse(buf, pos);
369
+ if (!el) return null; // need more
370
+ arr.push(el.value);
371
+ pos = el.next;
372
+ }
373
+ return { value: arr, next: pos };
374
+ }
375
+ default:
376
+ // Unknown type byte — treat the line as a raw reply.
377
+ return { value: line, next: after };
378
+ }
379
+ }
178
380
 
179
- sock.on("data", (data) => {
180
- response += data.toString();
181
- sock.end();
182
- });
381
+ /** Send one command and await its reply (assumes socket is up). */
382
+ private raw(args: (string | number)[]): Promise<RespReply> {
383
+ return new Promise<RespReply>((resolve, reject) => {
384
+ if (!this.sock || this.sock.destroyed) {
385
+ reject(this.brokenError ?? new Error("redis socket not connected"));
386
+ return;
387
+ }
388
+ this.waiters.push({ resolve, reject });
389
+ this.sock.write(RespClient.encode(args));
390
+ });
391
+ }
183
392
 
184
- sock.on("end", () => {
185
- if (response.startsWith("+")) {
186
- resolve(response.slice(1).trim());
187
- } else if (response.startsWith("$-1")) {
188
- resolve(null);
189
- } else if (response.startsWith("$")) {
190
- const lines = response.split("\r\n");
191
- resolve(lines[1] ?? null);
192
- } else if (response.startsWith(":")) {
193
- resolve(response.slice(1).trim());
194
- } else {
195
- resolve(null);
196
- }
197
- });
393
+ /** Public: connect-if-needed then send one command. */
394
+ async command(...args: (string | number)[]): Promise<RespReply> {
395
+ await this.connect();
396
+ return this.raw(args);
397
+ }
198
398
 
199
- sock.on("error", () => resolve(null));
200
- sock.on("timeout", () => { sock.destroy(); resolve(null); });
201
- } catch {
202
- resolve(null);
203
- }
399
+ close(): void {
400
+ try { this.sock?.destroy(); } catch { /* noop */ }
401
+ this.sock = null;
402
+ this.connected = false;
403
+ }
404
+ }
405
+
406
+ // ── Redis backend (native async RESP over node:net — no child process) ──
407
+
408
+ /**
409
+ * Redis / Valkey backend. Speaks RESP over a single persistent node:net socket
410
+ * via the zero-dep RespClient above — no execFileSync, no child process. Each
411
+ * cache op is one async round-trip. AUTH + SELECT db run once on connect so a
412
+ * dead port OR wrong credentials fail the availability probe and fall back to
413
+ * file. Mirrors the Python master's _RedisBackend semantics (prefix, SETEX,
414
+ * scoped KEYS+DEL clear, DBSIZE stats).
415
+ */
416
+ class RedisBackend implements CacheBackend {
417
+ protected host: string;
418
+ protected port: number;
419
+ protected db: number;
420
+ protected username: string | null;
421
+ protected password: string | null;
422
+ protected prefix = "tina4:cache:";
423
+ protected hits = 0;
424
+ protected misses = 0;
425
+ protected maxEntries: number;
426
+ protected _name: string;
427
+ protected client: RespClient;
428
+ protected available = false;
429
+ protected readyPromise: Promise<void>;
430
+
431
+ constructor(url = "redis://localhost:6379", maxEntries = 1000, name = "redis") {
432
+ this.maxEntries = maxEntries;
433
+ this._name = name;
434
+ // Valkey speaks the Redis wire protocol — normalise its scheme.
435
+ const parsed = parseCacheUrl(url.replace(/^valkey:\/\//, "redis://"), 6379);
436
+ this.host = parsed.host;
437
+ this.port = parsed.port;
438
+ this.db = parsed.db;
439
+ this.username = parsed.username;
440
+ this.password = parsed.password;
441
+ this.client = new RespClient({
442
+ host: this.host, port: this.port, db: this.db,
443
+ username: this.username, password: this.password,
204
444
  });
445
+ // Real connect + AUTH + PING handshake so a dead port OR wrong credentials
446
+ // fall back to file. Probed asynchronously (await ready() in the factory).
447
+ this.readyPromise = this.probe();
205
448
  }
206
449
 
207
- get(key: string): unknown | undefined {
208
- // Synchronous fallback — Redis is async, so direct API uses sync memory store
209
- // For the response cache middleware, this returns undefined and falls through
210
- this.misses++;
211
- return undefined;
450
+ private async probe(): Promise<void> {
451
+ try {
452
+ const r = await this.client.command("PING");
453
+ this.available = r === "PONG";
454
+ } catch {
455
+ this.available = false;
456
+ }
457
+ if (!this.available) this.client.close();
458
+ }
459
+
460
+ ready(): Promise<void> {
461
+ return this.readyPromise;
212
462
  }
213
463
 
214
- set(key: string, value: unknown, ttl: number): void {
464
+ async isAvailable(): Promise<boolean> {
465
+ await this.readyPromise;
466
+ return this.available;
467
+ }
468
+
469
+ /** One RESP command → reply string, or null on nil/error/connection failure. */
470
+ protected async respCommand(...args: string[]): Promise<string | null> {
471
+ try {
472
+ const r = await this.client.command(...args);
473
+ if (r === null || r === undefined) return null;
474
+ if (r instanceof Error) return null;
475
+ if (Array.isArray(r)) return null; // array replies handled by callers that need them
476
+ return String(r);
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+
482
+ async get(key: string): Promise<unknown | undefined> {
483
+ const raw = await this.respCommand("GET", this.prefix + key);
484
+ if (raw === null) {
485
+ this.misses++;
486
+ return undefined;
487
+ }
488
+ this.hits++;
489
+ try {
490
+ return JSON.parse(raw);
491
+ } catch {
492
+ return raw;
493
+ }
494
+ }
495
+
496
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
215
497
  const fullKey = this.prefix + key;
216
498
  const serialized = JSON.stringify(value);
217
499
  if (ttl > 0) {
218
- this.respCommand("SETEX", fullKey, String(ttl), serialized).catch(() => {});
500
+ await this.respCommand("SETEX", fullKey, String(ttl), serialized);
219
501
  } else {
220
- this.respCommand("SET", fullKey, serialized).catch(() => {});
502
+ await this.respCommand("SET", fullKey, serialized);
221
503
  }
222
504
  }
223
505
 
224
- delete(key: string): boolean {
225
- const fullKey = this.prefix + key;
226
- this.respCommand("DEL", fullKey).catch(() => {});
227
- return true; // best-effort
506
+ async delete(key: string): Promise<boolean> {
507
+ const result = await this.respCommand("DEL", this.prefix + key);
508
+ return result === "1";
228
509
  }
229
510
 
230
- clear(): void {
511
+ async clear(): Promise<void> {
231
512
  this.hits = 0;
232
513
  this.misses = 0;
514
+ // Scoped KEYS + DEL of our namespace only (never FLUSHALL — the harness is
515
+ // shared with other agents, and so are real deployments).
516
+ try {
517
+ const keys = await this.client.command("KEYS", this.prefix + "*");
518
+ if (Array.isArray(keys) && keys.length > 0) {
519
+ const flat = keys.filter((k): k is string => typeof k === "string");
520
+ if (flat.length > 0) await this.client.command("DEL", ...flat);
521
+ }
522
+ } catch {
523
+ /* best effort */
524
+ }
233
525
  }
234
526
 
235
- stats() {
236
- return { hits: this.hits, misses: this.misses, size: 0, backend: "redis" };
527
+ async stats() {
528
+ let size = 0;
529
+ const keys = await this.respCommand("DBSIZE");
530
+ // DBSIZE counts the whole DB index; with our isolated index that's accurate
531
+ // enough for the size figure. Fall back to 0 on error.
532
+ if (keys !== null && /^\d+$/.test(keys)) size = parseInt(keys, 10);
533
+ return { hits: this.hits, misses: this.misses, size, backend: this._name };
237
534
  }
238
535
 
239
- name() { return "redis"; }
536
+ name() { return this._name; }
537
+ }
538
+
539
+ // ── Valkey backend (Redis wire protocol — reuses RedisBackend) ─────
540
+
541
+ class ValkeyBackend extends RedisBackend {
542
+ constructor(url = "valkey://localhost:6379", maxEntries = 1000) {
543
+ super(url.replace(/^valkey:\/\//, "redis://"), maxEntries, "valkey");
544
+ }
240
545
  }
241
546
 
242
547
  // ── File backend ──────────────────────────────────────────────────
@@ -260,7 +565,7 @@ class FileBackend implements CacheBackend {
260
565
  return path.join(this.dir, `${safe}.json`);
261
566
  }
262
567
 
263
- get(key: string): unknown | undefined {
568
+ async get(key: string): Promise<unknown | undefined> {
264
569
  const p = this.keyPath(key);
265
570
  try {
266
571
  if (!fs.existsSync(p)) {
@@ -281,7 +586,7 @@ class FileBackend implements CacheBackend {
281
586
  }
282
587
  }
283
588
 
284
- set(key: string, value: unknown, ttl: number): void {
589
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
285
590
  try {
286
591
  fs.mkdirSync(this.dir, { recursive: true });
287
592
  // Evict oldest if at capacity
@@ -301,7 +606,7 @@ class FileBackend implements CacheBackend {
301
606
  } catch {}
302
607
  }
303
608
 
304
- delete(key: string): boolean {
609
+ async delete(key: string): Promise<boolean> {
305
610
  const p = this.keyPath(key);
306
611
  try {
307
612
  if (fs.existsSync(p)) {
@@ -312,7 +617,7 @@ class FileBackend implements CacheBackend {
312
617
  return false;
313
618
  }
314
619
 
315
- clear(): void {
620
+ async clear(): Promise<void> {
316
621
  this.hits = 0;
317
622
  this.misses = 0;
318
623
  try {
@@ -323,7 +628,7 @@ class FileBackend implements CacheBackend {
323
628
  } catch {}
324
629
  }
325
630
 
326
- stats() {
631
+ async stats() {
327
632
  // Sweep expired
328
633
  const now = Date.now() / 1000;
329
634
  let count = 0;
@@ -346,29 +651,550 @@ class FileBackend implements CacheBackend {
346
651
  name() { return "file"; }
347
652
  }
348
653
 
654
+ // ── Memcached client (zero-dep async text protocol over node:net) ───
655
+
656
+ /**
657
+ * Minimal async memcached client over a single persistent node:net socket. One
658
+ * command in flight at a time, replies read until a terminator. Zero deps, no
659
+ * child process. Memcached is unauthenticated.
660
+ */
661
+ class MemcachedClient {
662
+ private host: string;
663
+ private port: number;
664
+ private sock: net.Socket | null = null;
665
+ private buffer = Buffer.alloc(0);
666
+ private pending: { terminator: string; resolve: (s: string) => void } | null = null;
667
+ private connecting: Promise<void> | null = null;
668
+ private connected = false;
669
+
670
+ constructor(host: string, port: number) {
671
+ this.host = host;
672
+ this.port = port;
673
+ }
674
+
675
+ private connect(): Promise<void> {
676
+ if (this.connected) return Promise.resolve();
677
+ if (this.connecting) return this.connecting;
678
+ this.connecting = new Promise<void>((resolve, reject) => {
679
+ const sock = net.createConnection({ host: this.host, port: this.port });
680
+ sock.setNoDelay(true);
681
+ sock.once("error", (err) => { this.connected = false; reject(err); });
682
+ sock.once("connect", () => {
683
+ this.sock = sock;
684
+ this.connected = true;
685
+ sock.on("data", (chunk) => this.onData(chunk));
686
+ sock.on("error", () => { /* surfaced per-command via rejection on next write */ });
687
+ sock.on("close", () => {
688
+ this.connected = false;
689
+ if (this.pending) { const p = this.pending; this.pending = null; p.resolve(this.buffer.toString("utf-8")); }
690
+ });
691
+ resolve();
692
+ });
693
+ });
694
+ return this.connecting;
695
+ }
696
+
697
+ private onData(chunk: Buffer): void {
698
+ this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
699
+ if (this.pending && this.buffer.toString("utf-8").includes(this.pending.terminator)) {
700
+ const p = this.pending;
701
+ this.pending = null;
702
+ const out = this.buffer.toString("utf-8");
703
+ this.buffer = Buffer.alloc(0);
704
+ p.resolve(out);
705
+ }
706
+ }
707
+
708
+ /** Send one command, resolve with the reply read until `terminator`. */
709
+ async command(payload: string, terminator: string): Promise<string> {
710
+ await this.connect();
711
+ if (!this.sock || this.sock.destroyed) return "";
712
+ return new Promise<string>((resolve) => {
713
+ this.buffer = Buffer.alloc(0);
714
+ this.pending = { terminator, resolve };
715
+ const timer = setTimeout(() => {
716
+ if (this.pending && this.pending.resolve === resolve) {
717
+ this.pending = null;
718
+ resolve(this.buffer.toString("utf-8"));
719
+ }
720
+ }, 4000);
721
+ if (timer.unref) timer.unref();
722
+ this.sock!.write(payload);
723
+ });
724
+ }
725
+
726
+ close(): void {
727
+ try { this.sock?.destroy(); } catch { /* noop */ }
728
+ this.sock = null;
729
+ this.connected = false;
730
+ }
731
+ }
732
+
733
+ // ── Memcached backend (native async text protocol — no child process) ──
734
+
735
+ /**
736
+ * Memcached backend using the zero-dependency text protocol over a persistent
737
+ * node:net socket (no execFileSync). Keys are SHA-256-hashed (memcached keys
738
+ * must be <=250 chars, no spaces/control bytes). Memcached is unauthenticated.
739
+ * Availability is probed asynchronously with `version`. Mirrors the Python
740
+ * master's _MemcachedBackend.
741
+ */
742
+ class MemcachedBackend implements CacheBackend {
743
+ private host: string;
744
+ private port: number;
745
+ private prefix = "tina4:cache:";
746
+ private hits = 0;
747
+ private misses = 0;
748
+ private maxEntries: number;
749
+ private client: MemcachedClient;
750
+ private available = false;
751
+ private readyPromise: Promise<void>;
752
+
753
+ constructor(url = "memcached://localhost:11211", maxEntries = 1000) {
754
+ this.maxEntries = maxEntries;
755
+ const cleaned = url.replace(/^memcached:\/\//, "").replace(/^memcache:\/\//, "");
756
+ const parts = cleaned.split("/")[0].split(":");
757
+ this.host = parts[0] || "localhost";
758
+ this.port = parts[1] ? parseInt(parts[1], 10) || 11211 : 11211;
759
+ this.client = new MemcachedClient(this.host, this.port);
760
+ this.readyPromise = this.probe();
761
+ }
762
+
763
+ private async probe(): Promise<void> {
764
+ try {
765
+ const r = await this.client.command(`version\r\n`, "\r\n");
766
+ this.available = r.startsWith("VERSION");
767
+ } catch {
768
+ this.available = false;
769
+ }
770
+ if (!this.available) this.client.close();
771
+ }
772
+
773
+ ready(): Promise<void> {
774
+ return this.readyPromise;
775
+ }
776
+
777
+ async isAvailable(): Promise<boolean> {
778
+ await this.readyPromise;
779
+ return this.available;
780
+ }
781
+
782
+ private mcKey(key: string): string {
783
+ return this.prefix + crypto.createHash("sha256").update(key).digest("hex");
784
+ }
785
+
786
+ async get(key: string): Promise<unknown | undefined> {
787
+ const resp = await this.client.command(`get ${this.mcKey(key)}\r\n`, "END\r\n");
788
+ if (resp.startsWith("VALUE")) {
789
+ try {
790
+ const idx = resp.indexOf("\r\n");
791
+ const header = resp.slice(0, idx);
792
+ const nbytes = parseInt(header.split(/\s+/)[3], 10);
793
+ const body = resp.slice(idx + 2, idx + 2 + nbytes);
794
+ this.hits++;
795
+ return JSON.parse(body);
796
+ } catch {
797
+ /* fall through to miss */
798
+ }
799
+ }
800
+ this.misses++;
801
+ return undefined;
802
+ }
803
+
804
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
805
+ const data = JSON.stringify(value);
806
+ const exptime = ttl > 0 ? ttl : 0;
807
+ const payload = `set ${this.mcKey(key)} 0 ${exptime} ${Buffer.byteLength(data)}\r\n${data}\r\n`;
808
+ await this.client.command(payload, "\r\n");
809
+ }
810
+
811
+ async delete(key: string): Promise<boolean> {
812
+ const resp = await this.client.command(`delete ${this.mcKey(key)}\r\n`, "\r\n");
813
+ return resp.startsWith("DELETED");
814
+ }
815
+
816
+ async clear(): Promise<void> {
817
+ this.hits = 0;
818
+ this.misses = 0;
819
+ // No flush_all — the harness is shared with other agents. We rely on TTL
820
+ // expiry + per-key deletes (parity with the Python master's clear, which
821
+ // also avoids destructive flushes when keys can't be enumerated cheaply).
822
+ }
823
+
824
+ async stats() {
825
+ let size = 0;
826
+ const resp = await this.client.command(`stats\r\n`, "END\r\n");
827
+ for (const line of resp.split("\r\n")) {
828
+ if (line.startsWith("STAT curr_items ")) {
829
+ const n = parseInt(line.split(/\s+/)[2], 10);
830
+ if (!isNaN(n)) size = n;
831
+ }
832
+ }
833
+ return { hits: this.hits, misses: this.misses, size, backend: "memcached" };
834
+ }
835
+
836
+ name() { return "memcached"; }
837
+ }
838
+
839
+ // ── MongoDB backend (TTL collection, optional `mongodb` driver) ────
840
+
841
+ /**
842
+ * MongoDB backend backed by a TTL collection, using the OPTIONAL `mongodb`
843
+ * driver loaded dynamically and used ASYNC (no child process). Absent driver →
844
+ * unavailable → file-fallback, never a hard import-time crash (parity with
845
+ * Python's pymongo). One pooled client per backend instance. db/collection
846
+ * default to `tina4_cache` (the harness/tests use `tina4_cache_node` for
847
+ * isolation, set in the URL path).
848
+ */
849
+ class MongoBackend implements CacheBackend {
850
+ private url: string;
851
+ private dbName: string;
852
+ private collName = "tina4_cache";
853
+ private hits = 0;
854
+ private misses = 0;
855
+ private maxEntries: number;
856
+ private available = false;
857
+ private readyPromise: Promise<void>;
858
+ // The mongodb driver types aren't depended on at compile time (optional dep).
859
+ private client: any = null;
860
+ private coll: any = null;
861
+
862
+ constructor(url = "mongodb://localhost:27017", maxEntries = 1000) {
863
+ this.maxEntries = maxEntries;
864
+ // Credentials: embedded in the URL, or TINA4_CACHE_USERNAME / _PASSWORD.
865
+ let effectiveUrl = url;
866
+ if (!url.includes("@")) {
867
+ const u = process.env.TINA4_CACHE_USERNAME;
868
+ const p = process.env.TINA4_CACHE_PASSWORD;
869
+ if (u || p) {
870
+ effectiveUrl = url.replace(
871
+ /^mongodb(\+srv)?:\/\//,
872
+ (m) => `${m}${encodeURIComponent(u ?? "")}:${encodeURIComponent(p ?? "")}@`,
873
+ );
874
+ }
875
+ }
876
+ this.url = effectiveUrl;
877
+ // db / collection from the URL path: mongodb://host/db[/collection]
878
+ try {
879
+ const parsed = new URL(effectiveUrl);
880
+ const segs = (parsed.pathname || "").split("/").filter(Boolean);
881
+ this.dbName = segs[0] || "tina4_cache";
882
+ if (segs[1]) this.collName = segs[1];
883
+ } catch {
884
+ this.dbName = "tina4_cache";
885
+ }
886
+ this.readyPromise = this.probe();
887
+ }
888
+
889
+ /** Connect once via the optional driver; ping + ensure the TTL index. */
890
+ private async probe(): Promise<void> {
891
+ try {
892
+ // Dynamic import so @tina4/core has no hard dependency on `mongodb` and an
893
+ // absent driver degrades to file-fallback (never an import-time crash).
894
+ const mod: any = await import("mongodb").catch(() => null);
895
+ if (!mod || !mod.MongoClient) { this.available = false; return; }
896
+ const client = new mod.MongoClient(this.url, { serverSelectionTimeoutMS: 4000 });
897
+ await client.connect();
898
+ await client.db(this.dbName).command({ ping: 1 });
899
+ const coll = client.db(this.dbName).collection(this.collName);
900
+ await coll.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
901
+ this.client = client;
902
+ this.coll = coll;
903
+ this.available = true;
904
+ } catch {
905
+ this.available = false;
906
+ try { await this.client?.close(); } catch { /* noop */ }
907
+ this.client = null;
908
+ this.coll = null;
909
+ }
910
+ }
911
+
912
+ ready(): Promise<void> {
913
+ return this.readyPromise;
914
+ }
915
+
916
+ async isAvailable(): Promise<boolean> {
917
+ await this.readyPromise;
918
+ return this.available;
919
+ }
920
+
921
+ async get(key: string): Promise<unknown | undefined> {
922
+ if (!this.coll) { this.misses++; return undefined; }
923
+ try {
924
+ const doc = await this.coll.findOne({ _id: key });
925
+ if (!doc) { this.misses++; return undefined; }
926
+ // TTL index sweeps lazily; enforce expiry on read for determinism.
927
+ if (doc.expiresAt && new Date(doc.expiresAt).getTime() < Date.now()) {
928
+ await this.coll.deleteOne({ _id: key });
929
+ this.misses++;
930
+ return undefined;
931
+ }
932
+ this.hits++;
933
+ return JSON.parse(doc.value);
934
+ } catch {
935
+ this.misses++;
936
+ return undefined;
937
+ }
938
+ }
939
+
940
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
941
+ if (!this.coll) return;
942
+ try {
943
+ const doc: Record<string, unknown> = { _id: key, value: JSON.stringify(value) };
944
+ if (ttl > 0) doc.expiresAt = new Date(Date.now() + ttl * 1000);
945
+ await this.coll.replaceOne({ _id: key }, doc, { upsert: true });
946
+ } catch { /* best effort */ }
947
+ }
948
+
949
+ async delete(key: string): Promise<boolean> {
950
+ if (!this.coll) return false;
951
+ try {
952
+ const r = await this.coll.deleteOne({ _id: key });
953
+ return r.deletedCount > 0;
954
+ } catch {
955
+ return false;
956
+ }
957
+ }
958
+
959
+ async clear(): Promise<void> {
960
+ this.hits = 0;
961
+ this.misses = 0;
962
+ if (!this.coll) return;
963
+ try { await this.coll.deleteMany({}); } catch { /* best effort */ }
964
+ }
965
+
966
+ async stats() {
967
+ let size = 0;
968
+ if (this.coll) {
969
+ try { size = await this.coll.countDocuments({}); } catch { /* keep 0 */ }
970
+ }
971
+ return { hits: this.hits, misses: this.misses, size, backend: "mongodb" };
972
+ }
973
+
974
+ name() { return "mongodb"; }
975
+ }
976
+
977
+ // ── Database backend (tina4_cache table via @tina4/orm) ────────────
978
+
979
+ /**
980
+ * Database backend — stores entries in a `tina4_cache` table in any
981
+ * Tina4-supported database. Zero extra infrastructure: it reuses the ORM
982
+ * `Database` layer. Its own connection has query caching DISABLED (both
983
+ * TINA4_AUTO_CACHING and TINA4_DB_CACHE) so the cache's own reads/writes never
984
+ * recurse back into the cache. Mirrors the Python master's _DatabaseBackend.
985
+ *
986
+ * The ORM is loaded with a dynamic `import("@tina4/orm")` (no hard dependency at
987
+ * @tina4/core import time) and used ASYNC — no child process / execFileSync.
988
+ * SQLite is synchronous under the hood; we still expose an async surface so the
989
+ * unified CacheBackend contract holds for every backend.
990
+ */
991
+ class DatabaseBackend implements CacheBackend {
992
+ private url: string;
993
+ private hits = 0;
994
+ private misses = 0;
995
+ private maxEntries: number;
996
+ private available = false;
997
+ private readyPromise: Promise<void>;
998
+ // @tina4/orm Database instance (optional dep, loaded dynamically).
999
+ private db: any = null;
1000
+
1001
+ constructor(url?: string, maxEntries = 1000) {
1002
+ this.maxEntries = maxEntries;
1003
+ // database reads TINA4_CACHE_URL as a SQL URL, falling back to the app's own
1004
+ // TINA4_DATABASE_URL — cache in the DB you already run. NO TINA4_CACHE_DB_URL.
1005
+ this.url = url
1006
+ ?? process.env.TINA4_CACHE_URL
1007
+ ?? process.env.TINA4_DATABASE_URL
1008
+ ?? "sqlite:///data/tina4.db";
1009
+ this.readyPromise = this.probe();
1010
+ }
1011
+
1012
+ /** Connect once and ensure the cache table exists. */
1013
+ private async probe(): Promise<void> {
1014
+ // The cache's own DB connection must NOT itself cache (no recursion).
1015
+ const prevAuto = process.env.TINA4_AUTO_CACHING;
1016
+ const prevDb = process.env.TINA4_DB_CACHE;
1017
+ process.env.TINA4_AUTO_CACHING = "false";
1018
+ process.env.TINA4_DB_CACHE = "false";
1019
+ try {
1020
+ const mod: any = await import("@tina4/orm").catch(() => null);
1021
+ if (!mod || !mod.initDatabase) { this.available = false; return; }
1022
+ const db = await mod.initDatabase({ url: this.url });
1023
+ await db.execute(
1024
+ "CREATE TABLE IF NOT EXISTS tina4_cache " +
1025
+ "(cache_key VARCHAR(255) PRIMARY KEY, value TEXT, expires_at DOUBLE PRECISION)",
1026
+ );
1027
+ try { db.commit(); } catch { /* sqlite autocommits DDL */ }
1028
+ this.db = db;
1029
+ this.available = true;
1030
+ } catch {
1031
+ this.available = false;
1032
+ } finally {
1033
+ if (prevAuto === undefined) delete process.env.TINA4_AUTO_CACHING; else process.env.TINA4_AUTO_CACHING = prevAuto;
1034
+ if (prevDb === undefined) delete process.env.TINA4_DB_CACHE; else process.env.TINA4_DB_CACHE = prevDb;
1035
+ }
1036
+ }
1037
+
1038
+ ready(): Promise<void> {
1039
+ return this.readyPromise;
1040
+ }
1041
+
1042
+ async isAvailable(): Promise<boolean> {
1043
+ await this.readyPromise;
1044
+ return this.available;
1045
+ }
1046
+
1047
+ async get(key: string): Promise<unknown | undefined> {
1048
+ if (!this.db) { this.misses++; return undefined; }
1049
+ try {
1050
+ const row = await this.db.fetchOne("SELECT value, expires_at FROM tina4_cache WHERE cache_key = ?", [key]);
1051
+ if (!row) { this.misses++; return undefined; }
1052
+ const exp = row.expires_at;
1053
+ if (exp && Number(exp) > 0 && Date.now() / 1000 > Number(exp)) {
1054
+ await this.db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key]);
1055
+ try { this.db.commit(); } catch { /* noop */ }
1056
+ this.misses++;
1057
+ return undefined;
1058
+ }
1059
+ this.hits++;
1060
+ try {
1061
+ return JSON.parse(row.value);
1062
+ } catch {
1063
+ return row.value;
1064
+ }
1065
+ } catch {
1066
+ this.misses++;
1067
+ return undefined;
1068
+ }
1069
+ }
1070
+
1071
+ async set(key: string, value: unknown, ttl: number): Promise<void> {
1072
+ if (!this.db) return;
1073
+ const exp = ttl > 0 ? Date.now() / 1000 + ttl : 0;
1074
+ try {
1075
+ await this.db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key]);
1076
+ await this.db.execute(
1077
+ "INSERT INTO tina4_cache (cache_key, value, expires_at) VALUES (?, ?, ?)",
1078
+ [key, JSON.stringify(value), exp],
1079
+ );
1080
+ try { this.db.commit(); } catch { /* noop */ }
1081
+ } catch { /* best effort */ }
1082
+ }
1083
+
1084
+ async delete(key: string): Promise<boolean> {
1085
+ if (!this.db) return false;
1086
+ try {
1087
+ const row = await this.db.fetchOne("SELECT 1 AS x FROM tina4_cache WHERE cache_key = ?", [key]);
1088
+ await this.db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key]);
1089
+ try { this.db.commit(); } catch { /* noop */ }
1090
+ return row != null;
1091
+ } catch {
1092
+ return false;
1093
+ }
1094
+ }
1095
+
1096
+ async clear(): Promise<void> {
1097
+ this.hits = 0;
1098
+ this.misses = 0;
1099
+ if (!this.db) return;
1100
+ try {
1101
+ await this.db.execute("DELETE FROM tina4_cache");
1102
+ try { this.db.commit(); } catch { /* noop */ }
1103
+ } catch { /* best effort */ }
1104
+ }
1105
+
1106
+ async stats() {
1107
+ let size = 0;
1108
+ if (this.db) {
1109
+ try {
1110
+ const row = await this.db.fetchOne("SELECT COUNT(*) AS c FROM tina4_cache");
1111
+ if (row && row.c != null) size = Number(row.c);
1112
+ } catch { /* keep 0 */ }
1113
+ }
1114
+ return { hits: this.hits, misses: this.misses, size, backend: "database" };
1115
+ }
1116
+
1117
+ name() { return "database"; }
1118
+ }
1119
+
349
1120
  // ── Backend factory ───────────────────────────────────────────────
350
1121
 
351
- function createBackend(config?: {
1122
+ /** Public shape of a unified cache backend (for cross-package reuse). */
1123
+ export type { CacheBackend };
1124
+
1125
+ /**
1126
+ * Build a unified cache backend from explicit params or env vars.
1127
+ *
1128
+ * Backends: memory (default) | file | redis | valkey | memcached | mongodb |
1129
+ * database. Unreachable network/driver backends fall back to the file backend.
1130
+ * ASYNC because availability is probed asynchronously (connect/AUTH/PING/ping)
1131
+ * — no child process. Exported so @tina4/orm can route its persistent DB query
1132
+ * cache through the SAME backends (shared cross-instance) without duplicating
1133
+ * the implementation. Callers `await createBackend(...)`.
1134
+ */
1135
+ export async function createBackend(config?: {
352
1136
  backend?: string;
353
1137
  cacheUrl?: string;
354
1138
  cacheDir?: string;
355
1139
  maxEntries?: number;
356
- }): CacheBackend {
1140
+ }): Promise<CacheBackend> {
357
1141
  const backendName = (config?.backend ?? process.env.TINA4_CACHE_BACKEND ?? "memory").toLowerCase().trim();
358
1142
  const maxEntries = config?.maxEntries ?? (process.env.TINA4_CACHE_MAX_ENTRIES ? parseInt(process.env.TINA4_CACHE_MAX_ENTRIES, 10) : 1000);
1143
+ const cacheDir = () => config?.cacheDir ?? process.env.TINA4_CACHE_DIR ?? "data/cache";
359
1144
 
1145
+ let backend: CacheBackend;
360
1146
  switch (backendName) {
361
1147
  case "redis": {
362
1148
  const url = config?.cacheUrl ?? process.env.TINA4_CACHE_URL ?? "redis://localhost:6379";
363
- return new RedisBackend(url, maxEntries);
1149
+ backend = new RedisBackend(url, maxEntries);
1150
+ break;
1151
+ }
1152
+ case "valkey": {
1153
+ const url = config?.cacheUrl ?? process.env.TINA4_CACHE_URL ?? "valkey://localhost:6379";
1154
+ backend = new ValkeyBackend(url, maxEntries);
1155
+ break;
1156
+ }
1157
+ case "memcached":
1158
+ case "memcache": {
1159
+ const url = config?.cacheUrl ?? process.env.TINA4_CACHE_URL ?? "memcached://localhost:11211";
1160
+ backend = new MemcachedBackend(url, maxEntries);
1161
+ break;
364
1162
  }
365
- case "file": {
366
- const dir = config?.cacheDir ?? process.env.TINA4_CACHE_DIR ?? "data/cache";
367
- return new FileBackend(dir, maxEntries);
1163
+ case "mongodb":
1164
+ case "mongo": {
1165
+ const url = config?.cacheUrl ?? process.env.TINA4_CACHE_URL ?? "mongodb://localhost:27017";
1166
+ backend = new MongoBackend(url, maxEntries);
1167
+ break;
368
1168
  }
1169
+ case "database":
1170
+ case "db": {
1171
+ backend = new DatabaseBackend(config?.cacheUrl, maxEntries);
1172
+ break;
1173
+ }
1174
+ case "file":
1175
+ return new FileBackend(cacheDir(), maxEntries);
369
1176
  default:
370
1177
  return new MemoryBackend(maxEntries);
371
1178
  }
1179
+
1180
+ // Wait for the async connect/probe to settle before deciding availability.
1181
+ if (typeof backend.ready === "function") {
1182
+ try { await backend.ready(); } catch { /* isAvailable() reflects the failure */ }
1183
+ }
1184
+
1185
+ // Graceful degradation: if the configured backend's driver is missing or the
1186
+ // service is unreachable / credentials are wrong, fall back to the file
1187
+ // backend (persistent, zero-dep, always available) rather than silently
1188
+ // degrading to a no-op cache. Mirrors the Python master.
1189
+ if (typeof backend.isAvailable === "function" && !(await backend.isAvailable())) {
1190
+ // eslint-disable-next-line no-console
1191
+ console.warn(
1192
+ `[tina4] Cache backend '${backendName}' is unavailable ` +
1193
+ `(driver missing or service unreachable) — falling back to 'file'.`,
1194
+ );
1195
+ return new FileBackend(cacheDir(), maxEntries);
1196
+ }
1197
+ return backend;
372
1198
  }
373
1199
 
374
1200
  // ── Response cache store (for middleware) ──────────────────────────
@@ -459,21 +1285,32 @@ export function clearCache(): void {
459
1285
  * Get KV cache stats — reports the same backend that cacheGet/cacheSet/cacheDelete use,
460
1286
  * so a value stored via cacheSet() is reflected here. Mirrors cache_stats() in the
461
1287
  * Python / PHP / Ruby frameworks. (Identical to cacheBackendStats(), kept for parity naming.)
1288
+ * ASYNC on Node — callers `await cacheStats()`.
462
1289
  */
463
- export function cacheStats(): { hits: number; misses: number; size: number; backend: string } {
464
- return _getBackend().stats();
1290
+ export async function cacheStats(): Promise<{ hits: number; misses: number; size: number; backend: string }> {
1291
+ return (await _getBackend()).stats();
465
1292
  }
466
1293
 
467
- // ── Module-level direct cache API (backend-aware) ─────────────────
1294
+ // ── Module-level direct cache API (backend-aware, async) ───────────
468
1295
 
469
1296
  let _defaultBackend: CacheBackend | null = null;
1297
+ let _defaultBackendPromise: Promise<CacheBackend> | null = null;
470
1298
  let _defaultTtl: number | null = null;
471
1299
 
472
- function _getBackend(): CacheBackend {
473
- if (!_defaultBackend) {
474
- _defaultBackend = createBackend();
1300
+ /**
1301
+ * Lazily build the module-level default backend. Async because createBackend()
1302
+ * probes the configured network backend before resolving. The built backend is
1303
+ * memoised so subsequent calls reuse the same pooled connection.
1304
+ */
1305
+ async function _getBackend(): Promise<CacheBackend> {
1306
+ if (_defaultBackend) return _defaultBackend;
1307
+ if (!_defaultBackendPromise) {
1308
+ _defaultBackendPromise = createBackend().then((b) => {
1309
+ _defaultBackend = b;
1310
+ return b;
1311
+ });
475
1312
  }
476
- return _defaultBackend;
1313
+ return _defaultBackendPromise;
477
1314
  }
478
1315
 
479
1316
  function _getDefaultTtl(): number {
@@ -485,29 +1322,29 @@ function _getDefaultTtl(): number {
485
1322
  }
486
1323
 
487
1324
  /** Get a value from the cache by key. Returns undefined on miss. */
488
- export function cacheGet(key: string): unknown | undefined {
489
- return _getBackend().get(key);
1325
+ export async function cacheGet(key: string): Promise<unknown | undefined> {
1326
+ return (await _getBackend()).get(key);
490
1327
  }
491
1328
 
492
1329
  /** Store a value in the cache with optional TTL (seconds). */
493
- export function cacheSet(key: string, value: unknown, ttl?: number): void {
1330
+ export async function cacheSet(key: string, value: unknown, ttl?: number): Promise<void> {
494
1331
  const effectiveTtl = ttl ?? _getDefaultTtl();
495
- _getBackend().set(key, value, effectiveTtl);
1332
+ await (await _getBackend()).set(key, value, effectiveTtl);
496
1333
  }
497
1334
 
498
1335
  /** Delete a key from the cache. Returns true if it existed. */
499
- export function cacheDelete(key: string): boolean {
500
- return _getBackend().delete(key);
1336
+ export async function cacheDelete(key: string): Promise<boolean> {
1337
+ return (await _getBackend()).delete(key);
501
1338
  }
502
1339
 
503
1340
  /** Clear all entries from the cache. */
504
- export function cacheClear(): void {
505
- _getBackend().clear();
1341
+ export async function cacheClear(): Promise<void> {
1342
+ await (await _getBackend()).clear();
506
1343
  }
507
1344
 
508
1345
  /** Remove expired entries from the cache. Returns count removed. */
509
- export function sweep(): number {
510
- const backend = _getBackend();
1346
+ export async function sweep(): Promise<number> {
1347
+ const backend = await _getBackend();
511
1348
  if (typeof (backend as any).sweep === "function") {
512
1349
  return (backend as any).sweep();
513
1350
  }
@@ -515,12 +1352,16 @@ export function sweep(): number {
515
1352
  }
516
1353
 
517
1354
  /** Return cache statistics from the active backend. */
518
- export function cacheBackendStats(): { hits: number; misses: number; size: number; backend: string } {
519
- return _getBackend().stats();
1355
+ export async function cacheBackendStats(): Promise<{ hits: number; misses: number; size: number; backend: string }> {
1356
+ return (await _getBackend()).stats();
520
1357
  }
521
1358
 
522
- /** Reset the default backend (for testing). */
1359
+ /** Reset the default backend (for testing). Closes any pooled connection. */
523
1360
  export function _resetBackend(): void {
1361
+ const b = _defaultBackend as any;
1362
+ if (b && typeof b.client?.close === "function") { try { b.client.close(); } catch { /* noop */ } }
1363
+ if (b && typeof b.client?.close !== "function" && typeof b.db?.close === "function") { /* leave shared ORM db */ }
524
1364
  _defaultBackend = null;
1365
+ _defaultBackendPromise = null;
525
1366
  _defaultTtl = null;
526
1367
  }