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.
@@ -67,8 +67,8 @@ export {
67
67
  export type { WebSocketClient } from "./websocket.js";
68
68
  export { ServiceRunner, Tina4Service, matchCronField, matchesCron } from "./service.js";
69
69
  export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from "./service.js";
70
- export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, _resetBackend } from "./cache.js";
71
- export type { ResponseCacheConfig } from "./cache.js";
70
+ export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, createBackend, _resetBackend } from "./cache.js";
71
+ export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
72
72
  export { Api } from "./api.js";
73
73
  export type { ApiResult } from "./api.js";
74
74
  export { Events } from "./events.js";
@@ -774,6 +774,9 @@ function verifyNodeSyntax(absPath: string, relPath: string): string | null {
774
774
  return stripPath(lines[0]);
775
775
  }
776
776
 
777
+ /** Latest resolved KV cache stats snapshot (the async API resolves into this). */
778
+ let _lastCacheStats: Record<string, unknown> | null = null;
779
+
777
780
  /**
778
781
  * Register all 24 built-in dev tools on the given McpServer.
779
782
  */
@@ -1158,9 +1161,18 @@ export function registerDevTools(server: McpServer): void {
1158
1161
  server.registerTool(
1159
1162
  "cache_stats",
1160
1163
  (_args) => {
1164
+ // The KV cache API is async on Node (cacheStats() returns a Promise) and
1165
+ // the MCP dispatch is synchronous, so we resolve the stats and return the
1166
+ // latest snapshot once available. The very first call may report the
1167
+ // pending placeholder; subsequent calls return live figures.
1161
1168
  try {
1162
- const { cacheStats } = require("@tina4/core");
1163
- return cacheStats?.() ?? {};
1169
+ const mod = require("@tina4/core");
1170
+ const stats = mod.cacheStats?.();
1171
+ if (stats && typeof stats.then === "function") {
1172
+ stats.then((s: unknown) => { _lastCacheStats = s as Record<string, unknown>; }).catch(() => {});
1173
+ return _lastCacheStats ?? { hits: 0, misses: 0, size: 0, backend: "pending" };
1174
+ }
1175
+ return stats ?? {};
1164
1176
  } catch (e) {
1165
1177
  return { error: (e as Error).message };
1166
1178
  }
@@ -610,6 +610,12 @@ function deployGallery(name) {
610
610
  // Allows handle() to route requests without requiring a reference to the server.
611
611
  let _dispatchFn: ((rawReq: IncomingMessage, rawRes: ServerResponse) => Promise<void>) | null = null;
612
612
 
613
+ // Lazily-resolved Database.resetRequestCaches binding (or null if the ORM is
614
+ // not installed). Memoised so the dynamic import happens once, then every
615
+ // request reuses the resolved function — see the request-scoped cache boundary
616
+ // in dispatch().
617
+ let _resetRequestCaches: Promise<(() => void) | null> | undefined;
618
+
613
619
  /** Module-level server handle for start()/stop() parity. */
614
620
  let _serverHandle: { close: () => void; router: Router; port: number } | null = null;
615
621
 
@@ -914,6 +920,25 @@ ${reset}
914
920
  const req = createRequest(rawReq);
915
921
  const res = createResponse(rawRes);
916
922
 
923
+ // Request-scoped DB query cache boundary: clear the request-scoped cache on
924
+ // every live connection at the START of each request so it never serves
925
+ // rows across requests (persistent-mode connections are left alone). The
926
+ // ORM is loaded lazily and may be absent — failures here must never break a
927
+ // request, so this is best-effort. Mirrors Python's dispatcher calling
928
+ // Database.reset_request_caches(). The cached() promise resolves once on
929
+ // first use; subsequent requests reuse the resolved module.
930
+ if (_resetRequestCaches === undefined) {
931
+ _resetRequestCaches = import("../../orm/src/index.js")
932
+ .then((orm) => orm.resetRequestCaches as () => void)
933
+ .catch(() => null);
934
+ }
935
+ try {
936
+ const reset = await _resetRequestCaches;
937
+ if (reset) reset();
938
+ } catch {
939
+ /* ORM not installed / cache unavailable — non-fatal */
940
+ }
941
+
917
942
  // RFC 9110 §9.3.2: the server MUST NOT send content in a HEAD response.
918
943
  // Intercept rawRes.write / rawRes.end so every code path — explicit
919
944
  // Router.head() handler, GET auto-fallback, 405 / 404 responses — drops
@@ -1,21 +1,32 @@
1
1
  /**
2
2
  * Tina4 Cached Database — Transparent query cache decorator for DatabaseAdapter.
3
3
  *
4
- * Wraps any DatabaseAdapter and caches SELECT results from fetch() and fetchOne().
5
- * Write operations (insert, update, delete, execute) invalidate the entire cache.
4
+ * Wraps any DatabaseAdapter and caches SELECT results from fetch() and fetchOne()
5
+ * (plus their *Async variants). Write operations (insert, update, delete, execute,
6
+ * createTable, addColumn) flush the entire cache when caching is enabled.
6
7
  *
7
- * Opt-in via .env:
8
- * TINA4_DB_CACHE=true # enable (default: false)
9
- * TINA4_DB_CACHE_TTL=30 # TTL in seconds (default: 30)
8
+ * One store, two layers (mirrors the Python master — tina4_python/database/connection.py):
10
9
  *
11
- * Usage:
10
+ * request-scoped (DEFAULT ON, off-switch TINA4_AUTO_CACHING=false) — dedupes
11
+ * identical SELECTs to protect the DB from rapid repeat reads. Cleared at the
12
+ * START of every HTTP request (via Database.resetRequestCaches()) AND on any
13
+ * write, with a short safety TTL (TINA4_AUTO_CACHING_TTL, default 5s) for
14
+ * non-request contexts (scripts/workers).
15
+ * • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache that is
16
+ * NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL (default 30s).
17
+ *
18
+ * enabled = persistent || requestScoped
19
+ * mode = persistent ? "persistent" : (requestScoped ? "request" : "off")
20
+ * ttl = persistent ? 30 : 5 (env-overridable)
21
+ *
22
+ * Usage (the framework wires this automatically at the adapter bind path):
12
23
  * import { CachedDatabaseAdapter } from "@tina4/orm";
13
24
  * import { SQLiteAdapter } from "./adapters/sqlite.js";
14
25
  *
15
26
  * const raw = new SQLiteAdapter("./data/app.db");
16
27
  * const db = new CachedDatabaseAdapter(raw);
17
28
  * db.fetch("SELECT * FROM users"); // cached on second call
18
- * db.cacheStats(); // { enabled: true, hits: 1, ... }
29
+ * db.cacheStats(); // { enabled, mode, hits, misses, size, ttl }
19
30
  */
20
31
 
21
32
  import { QueryCache } from "./sqlTranslation.js";
@@ -25,44 +36,160 @@ function isTruthy(val: string | undefined): boolean {
25
36
  return ["true", "1", "yes", "on"].includes((val ?? "").trim().toLowerCase());
26
37
  }
27
38
 
39
+ /** Network backends that cannot serve a SYNCHRONOUS db.fetch() path. */
40
+ const NETWORK_DB_CACHE_BACKENDS = new Set(["redis", "valkey", "memcached", "memcache", "mongodb", "mongo"]);
41
+
42
+ /** One-time guard so the distributed-DB-cache warning logs once per process. */
43
+ let _warnedNetworkDbCache = false;
44
+
45
+ /**
46
+ * Options for wrapping an adapter with a query cache. When several pooled
47
+ * connections must share one cache store (so a write on any connection
48
+ * invalidates reads cached by all of them), pass the same `sharedCache`.
49
+ */
50
+ export interface CachedAdapterOptions {
51
+ /** Force-enable the persistent (cross-request) layer. Defaults to TINA4_DB_CACHE. */
52
+ persistent?: boolean;
53
+ /** Force-enable the request-scoped layer. Defaults to TINA4_AUTO_CACHING (default true). */
54
+ requestScoped?: boolean;
55
+ /** Override the effective TTL (seconds). Defaults to the mode-appropriate env var. */
56
+ ttl?: number;
57
+ /** Share a single QueryCache store across multiple wrappers (pooled connections). */
58
+ sharedCache?: QueryCache;
59
+ }
60
+
28
61
  export class CachedDatabaseAdapter implements DatabaseAdapter {
62
+ /**
63
+ * Live wrappers, so the request dispatcher can clear the request-scoped cache
64
+ * on every connection at the start of each HTTP request. Mirrors Python's
65
+ * `Database._instances` WeakSet. A WeakSet lets closed connections be GC'd.
66
+ */
67
+ private static instances: Set<CachedDatabaseAdapter> = new Set();
68
+
29
69
  private adapter: DatabaseAdapter;
30
70
  private cache: QueryCache;
71
+ /** Persistent (cross-request) layer — TINA4_DB_CACHE. */
72
+ private cachePersistent: boolean;
73
+ /** Request-scoped layer — TINA4_AUTO_CACHING (default ON). */
74
+ private cacheRequestScoped: boolean;
31
75
  private enabled: boolean;
32
76
  private ttl: number;
33
77
  private hits: number = 0;
34
78
  private misses: number = 0;
35
79
 
36
- constructor(adapter: DatabaseAdapter, enabled?: boolean, ttl?: number) {
80
+ constructor(adapter: DatabaseAdapter, options: CachedAdapterOptions = {}) {
37
81
  this.adapter = adapter;
38
- this.enabled = enabled ?? isTruthy(process.env.TINA4_DB_CACHE);
39
- this.ttl = ttl ?? parseInt(process.env.TINA4_DB_CACHE_TTL ?? "30", 10);
40
- this.cache = new QueryCache({ defaultTtl: this.ttl, maxSize: 10000 });
82
+ this.cachePersistent = options.persistent ?? isTruthy(process.env.TINA4_DB_CACHE);
83
+ this.cacheRequestScoped = options.requestScoped
84
+ ?? (process.env.TINA4_AUTO_CACHING === undefined ? true : isTruthy(process.env.TINA4_AUTO_CACHING));
85
+ this.enabled = this.cachePersistent || this.cacheRequestScoped;
86
+
87
+ if (options.ttl !== undefined) {
88
+ this.ttl = options.ttl;
89
+ } else if (this.cachePersistent) {
90
+ this.ttl = parseInt(process.env.TINA4_DB_CACHE_TTL ?? "30", 10);
91
+ } else {
92
+ this.ttl = parseInt(process.env.TINA4_AUTO_CACHING_TTL ?? "5", 10);
93
+ }
94
+
95
+ this.cache = options.sharedCache ?? new QueryCache({ defaultTtl: this.ttl, maxSize: 10000 });
96
+
97
+ // Persistent mode runs on the SYNCHRONOUS db.fetch() path, which cannot
98
+ // await an async cache backend. So the persistent DB-query cache is always
99
+ // the in-process QueryCache above (synchronous, no serialization-over-the-
100
+ // wire). If the operator selects a NETWORK backend via TINA4_DB_CACHE_BACKEND
101
+ // (redis/valkey/memcached/mongodb), warn once and fall back to in-process
102
+ // memory — distributed DB-query caching isn't available on Node's sync
103
+ // fetch path; use the response cache (cacheGet/cacheSet) for distributed
104
+ // caching. (File/database/memory selections also resolve to the in-process
105
+ // store here — the unified network backends live behind the async KV API.)
106
+ if (this.cachePersistent) {
107
+ const backendName = (process.env.TINA4_DB_CACHE_BACKEND ?? "memory").toLowerCase().trim();
108
+ if (NETWORK_DB_CACHE_BACKENDS.has(backendName) && !_warnedNetworkDbCache) {
109
+ _warnedNetworkDbCache = true;
110
+ // eslint-disable-next-line no-console
111
+ console.warn(
112
+ `[tina4] TINA4_DB_CACHE_BACKEND='${backendName}' selects a distributed cache, ` +
113
+ `but Node's db.fetch() is synchronous so distributed DB-query caching is not ` +
114
+ `available on that path — falling back to the in-process memory store. ` +
115
+ `For distributed caching use the response cache (cacheGet/cacheSet).`,
116
+ );
117
+ }
118
+ }
119
+
120
+ CachedDatabaseAdapter.instances.add(this);
121
+ }
122
+
123
+ // ── Cache mode helpers ────────────────────────────────────
124
+
125
+ /** Current cache mode: "persistent" | "request" | "off". */
126
+ cacheMode(): "persistent" | "request" | "off" {
127
+ return this.cachePersistent ? "persistent" : (this.cacheRequestScoped ? "request" : "off");
41
128
  }
42
129
 
43
- // ── Cache helpers ─────────────────────────────────────────
130
+ /** Whether either cache layer is active. */
131
+ cacheEnabled(): boolean {
132
+ return this.enabled;
133
+ }
44
134
 
45
- cacheStats(): { enabled: boolean; hits: number; misses: number; size: number; ttl: number } {
135
+ /**
136
+ * Clear the request-scoped cache at the start of an HTTP request.
137
+ * No-op in persistent mode (cross-request entries survive to their TTL).
138
+ * Cumulative hit/miss counters are preserved. Mirrors Python's
139
+ * `Database.cache_new_request()`.
140
+ */
141
+ cacheNewRequest(): void {
142
+ if (this.cacheRequestScoped && !this.cachePersistent) {
143
+ this.cache.clear();
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Clear the request-scoped cache on every live wrapper. The request
149
+ * dispatcher calls this at the start of each HTTP request so request-scoped
150
+ * caching never serves rows across requests. Persistent-mode connections are
151
+ * left alone. Mirrors Python's `Database.reset_request_caches()` classmethod.
152
+ */
153
+ static resetRequestCaches(): void {
154
+ for (const inst of CachedDatabaseAdapter.instances) {
155
+ try {
156
+ inst.cacheNewRequest();
157
+ } catch {
158
+ /* a closed/broken wrapper must not break the request boundary */
159
+ }
160
+ }
161
+ }
162
+
163
+ // ── Cache stats / management ──────────────────────────────
164
+
165
+ cacheStats(): {
166
+ enabled: boolean; mode: "persistent" | "request" | "off";
167
+ hits: number; misses: number; size: number; ttl: number; backend?: string;
168
+ } {
46
169
  return {
47
170
  enabled: this.enabled,
171
+ mode: this.cacheMode(),
48
172
  hits: this.hits,
49
173
  misses: this.misses,
50
174
  size: this.cache.size(),
51
175
  ttl: this.ttl,
176
+ backend: "memory",
52
177
  };
53
178
  }
54
179
 
180
+ /** Flush the query cache and reset counters. Mirrors Python `cache_clear()`. */
55
181
  cacheClear(): void {
56
182
  this.cache.clear();
57
183
  this.hits = 0;
58
184
  this.misses = 0;
59
185
  }
60
186
 
187
+ /** Clear the entire query cache (called on writes). */
61
188
  private invalidate(): void {
62
189
  this.cache.clear();
63
190
  }
64
191
 
65
- // ── DatabaseAdapter interface ─────────────────────────────
192
+ // ── DatabaseAdapter interface — writes flush, reads cache ──
66
193
 
67
194
  execute(sql: string, params?: unknown[]): unknown {
68
195
  if (this.enabled) this.invalidate();
@@ -75,6 +202,22 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
75
202
  }
76
203
 
77
204
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
205
+ // The Node ORM reads most rows through query() (find/where/all/relationships),
206
+ // so caching here is what makes ORM reads dedupe — matching the Python master
207
+ // where every ORM read flows through the cached db.fetch(). Same store, same
208
+ // counters, flushed on writes.
209
+ if (this.enabled) {
210
+ const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined);
211
+ const cached = this.cache.get<T[]>(key);
212
+ if (cached !== undefined) {
213
+ this.hits++;
214
+ return cached;
215
+ }
216
+ const result = this.adapter.query<T>(sql, params);
217
+ this.cache.set(key, result, this.ttl);
218
+ this.misses++;
219
+ return result;
220
+ }
78
221
  return this.adapter.query(sql, params);
79
222
  }
80
223
 
@@ -150,6 +293,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
150
293
  }
151
294
 
152
295
  close(): void {
296
+ CachedDatabaseAdapter.instances.delete(this);
153
297
  this.adapter.close();
154
298
  }
155
299
 
@@ -171,8 +315,138 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
171
315
  this.adapter.addColumn?.(table, colName, def);
172
316
  }
173
317
 
318
+ // ── Async passthroughs (PostgreSQL/MySQL/MSSQL/Firebird/Mongo) ──
319
+ //
320
+ // The async adapters implement *Async methods; the Database wrapper and the
321
+ // ORM read/write path prefer those when present. We mirror them here so the
322
+ // cache sits in front of the async path too. Reads cache; writes flush.
323
+
324
+ async fetchAsync<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): Promise<T[]> {
325
+ const run = async (): Promise<T[]> => (this.adapter as any).fetchAsync
326
+ ? await (this.adapter as any).fetchAsync(sql, params, limit, skip)
327
+ : this.adapter.fetch<T>(sql, params, limit, skip);
328
+ if (this.enabled) {
329
+ const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined);
330
+ const cached = this.cache.get<T[]>(key);
331
+ if (cached !== undefined) {
332
+ this.hits++;
333
+ return cached;
334
+ }
335
+ const result = await run();
336
+ this.cache.set(key, result, this.ttl);
337
+ this.misses++;
338
+ return result;
339
+ }
340
+ return run();
341
+ }
342
+
343
+ async fetchOneAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null> {
344
+ const run = async (): Promise<T | null> => (this.adapter as any).fetchOneAsync
345
+ ? await (this.adapter as any).fetchOneAsync(sql, params)
346
+ : this.adapter.fetchOne<T>(sql, params);
347
+ if (this.enabled) {
348
+ const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined);
349
+ const cached = this.cache.get<T | null>(key);
350
+ if (cached !== undefined) {
351
+ this.hits++;
352
+ return cached;
353
+ }
354
+ const result = await run();
355
+ this.cache.set(key, result, this.ttl);
356
+ this.misses++;
357
+ return result;
358
+ }
359
+ return run();
360
+ }
361
+
362
+ async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
363
+ const run = async (): Promise<T[]> => (this.adapter as any).queryAsync
364
+ ? await (this.adapter as any).queryAsync(sql, params)
365
+ : this.adapter.query<T>(sql, params);
366
+ if (this.enabled) {
367
+ const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined);
368
+ const cached = this.cache.get<T[]>(key);
369
+ if (cached !== undefined) {
370
+ this.hits++;
371
+ return cached;
372
+ }
373
+ const result = await run();
374
+ this.cache.set(key, result, this.ttl);
375
+ this.misses++;
376
+ return result;
377
+ }
378
+ return run();
379
+ }
380
+
381
+ async executeAsync(sql: string, params?: unknown[]): Promise<unknown> {
382
+ if (this.enabled) this.invalidate();
383
+ return (this.adapter as any).executeAsync
384
+ ? await (this.adapter as any).executeAsync(sql, params)
385
+ : this.adapter.execute(sql, params);
386
+ }
387
+
388
+ async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
389
+ if (this.enabled) this.invalidate();
390
+ return (this.adapter as any).insertAsync
391
+ ? await (this.adapter as any).insertAsync(table, data)
392
+ : this.adapter.insert(table, data);
393
+ }
394
+
395
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): Promise<DatabaseResult> {
396
+ if (this.enabled) this.invalidate();
397
+ return (this.adapter as any).updateAsync
398
+ ? await (this.adapter as any).updateAsync(table, data, filter, params)
399
+ : this.adapter.update(table, data, filter);
400
+ }
401
+
402
+ async deleteAsync(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseResult> {
403
+ if (this.enabled) this.invalidate();
404
+ return (this.adapter as any).deleteAsync
405
+ ? await (this.adapter as any).deleteAsync(table, filter, params)
406
+ : this.adapter.delete(table, filter as Record<string, unknown>);
407
+ }
408
+
409
+ async startTransactionAsync(): Promise<void> {
410
+ if ((this.adapter as any).startTransactionAsync) await (this.adapter as any).startTransactionAsync();
411
+ else this.adapter.startTransaction();
412
+ }
413
+
414
+ async commitAsync(): Promise<void> {
415
+ if ((this.adapter as any).commitAsync) await (this.adapter as any).commitAsync();
416
+ else this.adapter.commit();
417
+ }
418
+
419
+ async rollbackAsync(): Promise<void> {
420
+ if ((this.adapter as any).rollbackAsync) await (this.adapter as any).rollbackAsync();
421
+ else this.adapter.rollback();
422
+ }
423
+
424
+ async tableExistsAsync(name: string): Promise<boolean> {
425
+ return (this.adapter as any).tableExistsAsync
426
+ ? await (this.adapter as any).tableExistsAsync(name)
427
+ : this.adapter.tableExists(name);
428
+ }
429
+
430
+ async tablesAsync(): Promise<string[]> {
431
+ return (this.adapter as any).tablesAsync
432
+ ? await (this.adapter as any).tablesAsync()
433
+ : this.adapter.tables();
434
+ }
435
+
436
+ async columnsAsync(table: string): Promise<ColumnInfo[]> {
437
+ return (this.adapter as any).columnsAsync
438
+ ? await (this.adapter as any).columnsAsync(table)
439
+ : this.adapter.columns(table);
440
+ }
441
+
442
+ async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
443
+ if (this.enabled) this.invalidate();
444
+ if ((this.adapter as any).createTableAsync) await (this.adapter as any).createTableAsync(name, columns);
445
+ else this.adapter.createTable(name, columns);
446
+ }
447
+
174
448
  /**
175
- * Access the underlying adapter directly.
449
+ * Access the underlying (unwrapped) adapter directly.
176
450
  */
177
451
  getAdapter(): DatabaseAdapter {
178
452
  return this.adapter;