tina4-nodejs 3.13.21 → 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.
- package/CLAUDE.md +31 -16
- package/package.json +3 -2
- package/packages/core/src/cache.ts +970 -125
- package/packages/core/src/index.ts +2 -2
- package/packages/core/src/mcp.ts +14 -2
- package/packages/core/src/server.ts +25 -0
- package/packages/orm/src/cachedDatabase.ts +289 -15
- package/packages/orm/src/database.ts +112 -40
- package/packages/orm/src/index.ts +2 -1
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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)
|
|
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
|
-
// ──
|
|
231
|
+
// ── Zero-dep async RESP client over node:net (no child process) ──────
|
|
139
232
|
|
|
140
|
-
|
|
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
|
|
145
|
-
private
|
|
146
|
-
private
|
|
147
|
-
private
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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;
|
|
462
|
+
}
|
|
463
|
+
|
|
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
|
+
}
|
|
212
494
|
}
|
|
213
495
|
|
|
214
|
-
set(key: string, value: unknown, ttl: number): void {
|
|
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)
|
|
500
|
+
await this.respCommand("SETEX", fullKey, String(ttl), serialized);
|
|
219
501
|
} else {
|
|
220
|
-
this.respCommand("SET", fullKey, serialized)
|
|
502
|
+
await this.respCommand("SET", fullKey, serialized);
|
|
221
503
|
}
|
|
222
504
|
}
|
|
223
505
|
|
|
224
|
-
delete(key: string): boolean {
|
|
225
|
-
const
|
|
226
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1149
|
+
backend = new RedisBackend(url, maxEntries);
|
|
1150
|
+
break;
|
|
364
1151
|
}
|
|
365
|
-
case "
|
|
366
|
-
const
|
|
367
|
-
|
|
1152
|
+
case "valkey": {
|
|
1153
|
+
const url = config?.cacheUrl ?? process.env.TINA4_CACHE_URL ?? "valkey://localhost:6379";
|
|
1154
|
+
backend = new ValkeyBackend(url, maxEntries);
|
|
1155
|
+
break;
|
|
368
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;
|
|
1162
|
+
}
|
|
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;
|
|
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) ──────────────────────────
|
|
@@ -455,21 +1281,36 @@ export function clearCache(): void {
|
|
|
455
1281
|
store.clear();
|
|
456
1282
|
}
|
|
457
1283
|
|
|
458
|
-
/**
|
|
459
|
-
|
|
460
|
-
|
|
1284
|
+
/**
|
|
1285
|
+
* Get KV cache stats — reports the same backend that cacheGet/cacheSet/cacheDelete use,
|
|
1286
|
+
* so a value stored via cacheSet() is reflected here. Mirrors cache_stats() in the
|
|
1287
|
+
* Python / PHP / Ruby frameworks. (Identical to cacheBackendStats(), kept for parity naming.)
|
|
1288
|
+
* ASYNC on Node — callers `await cacheStats()`.
|
|
1289
|
+
*/
|
|
1290
|
+
export async function cacheStats(): Promise<{ hits: number; misses: number; size: number; backend: string }> {
|
|
1291
|
+
return (await _getBackend()).stats();
|
|
461
1292
|
}
|
|
462
1293
|
|
|
463
|
-
// ── Module-level direct cache API (backend-aware)
|
|
1294
|
+
// ── Module-level direct cache API (backend-aware, async) ───────────
|
|
464
1295
|
|
|
465
1296
|
let _defaultBackend: CacheBackend | null = null;
|
|
1297
|
+
let _defaultBackendPromise: Promise<CacheBackend> | null = null;
|
|
466
1298
|
let _defaultTtl: number | null = null;
|
|
467
1299
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
+
});
|
|
471
1312
|
}
|
|
472
|
-
return
|
|
1313
|
+
return _defaultBackendPromise;
|
|
473
1314
|
}
|
|
474
1315
|
|
|
475
1316
|
function _getDefaultTtl(): number {
|
|
@@ -481,29 +1322,29 @@ function _getDefaultTtl(): number {
|
|
|
481
1322
|
}
|
|
482
1323
|
|
|
483
1324
|
/** Get a value from the cache by key. Returns undefined on miss. */
|
|
484
|
-
export function cacheGet(key: string): unknown | undefined {
|
|
485
|
-
return _getBackend().get(key);
|
|
1325
|
+
export async function cacheGet(key: string): Promise<unknown | undefined> {
|
|
1326
|
+
return (await _getBackend()).get(key);
|
|
486
1327
|
}
|
|
487
1328
|
|
|
488
1329
|
/** Store a value in the cache with optional TTL (seconds). */
|
|
489
|
-
export function cacheSet(key: string, value: unknown, ttl?: number): void {
|
|
1330
|
+
export async function cacheSet(key: string, value: unknown, ttl?: number): Promise<void> {
|
|
490
1331
|
const effectiveTtl = ttl ?? _getDefaultTtl();
|
|
491
|
-
_getBackend().set(key, value, effectiveTtl);
|
|
1332
|
+
await (await _getBackend()).set(key, value, effectiveTtl);
|
|
492
1333
|
}
|
|
493
1334
|
|
|
494
1335
|
/** Delete a key from the cache. Returns true if it existed. */
|
|
495
|
-
export function cacheDelete(key: string): boolean {
|
|
496
|
-
return _getBackend().delete(key);
|
|
1336
|
+
export async function cacheDelete(key: string): Promise<boolean> {
|
|
1337
|
+
return (await _getBackend()).delete(key);
|
|
497
1338
|
}
|
|
498
1339
|
|
|
499
1340
|
/** Clear all entries from the cache. */
|
|
500
|
-
export function cacheClear(): void {
|
|
501
|
-
_getBackend().clear();
|
|
1341
|
+
export async function cacheClear(): Promise<void> {
|
|
1342
|
+
await (await _getBackend()).clear();
|
|
502
1343
|
}
|
|
503
1344
|
|
|
504
1345
|
/** Remove expired entries from the cache. Returns count removed. */
|
|
505
|
-
export function sweep(): number {
|
|
506
|
-
const backend = _getBackend();
|
|
1346
|
+
export async function sweep(): Promise<number> {
|
|
1347
|
+
const backend = await _getBackend();
|
|
507
1348
|
if (typeof (backend as any).sweep === "function") {
|
|
508
1349
|
return (backend as any).sweep();
|
|
509
1350
|
}
|
|
@@ -511,12 +1352,16 @@ export function sweep(): number {
|
|
|
511
1352
|
}
|
|
512
1353
|
|
|
513
1354
|
/** Return cache statistics from the active backend. */
|
|
514
|
-
export function cacheBackendStats(): { hits: number; misses: number; size: number; backend: string } {
|
|
515
|
-
return _getBackend().stats();
|
|
1355
|
+
export async function cacheBackendStats(): Promise<{ hits: number; misses: number; size: number; backend: string }> {
|
|
1356
|
+
return (await _getBackend()).stats();
|
|
516
1357
|
}
|
|
517
1358
|
|
|
518
|
-
/** Reset the default backend (for testing). */
|
|
1359
|
+
/** Reset the default backend (for testing). Closes any pooled connection. */
|
|
519
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 */ }
|
|
520
1364
|
_defaultBackend = null;
|
|
1365
|
+
_defaultBackendPromise = null;
|
|
521
1366
|
_defaultTtl = null;
|
|
522
1367
|
}
|