tina4-nodejs 3.13.23 → 3.13.25

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
  }
@@ -95,18 +95,23 @@ export class MiddlewareRunner {
95
95
  * boolean indicating whether the route handler should still run.
96
96
  *
97
97
  * Short-circuits when a before method sets a status >= 400.
98
+ *
99
+ * ASYNC — each hook is awaited so middleware can perform async work (e.g.
100
+ * the distributed responseCache before-hook awaiting `backend.get`). Awaiting
101
+ * a synchronous hook that returns an array is harmless (the array resolves
102
+ * immediately), so existing sync hooks keep working unchanged.
98
103
  */
99
- static runBefore(
104
+ static async runBefore(
100
105
  classes: any[],
101
106
  req: Tina4Request,
102
107
  res: Tina4Response,
103
- ): [Tina4Request, Tina4Response, boolean] {
108
+ ): Promise<[Tina4Request, Tina4Response, boolean]> {
104
109
  for (const cls of classes) {
105
110
  const methods = Object.getOwnPropertyNames(cls).filter(
106
111
  (name) => typeof cls[name] === "function" && name.startsWith("before"),
107
112
  );
108
113
  for (const method of methods) {
109
- const result = cls[method](req, res);
114
+ const result = await cls[method](req, res);
110
115
  if (Array.isArray(result)) {
111
116
  [req, res] = result as [Tina4Request, Tina4Response];
112
117
  }
@@ -122,18 +127,22 @@ export class MiddlewareRunner {
122
127
  /**
123
128
  * Execute every afterX static method found on the supplied classes,
124
129
  * in order. Returns the (possibly mutated) request and response pair.
130
+ *
131
+ * ASYNC — each hook is awaited (e.g. the responseCache after-hook awaiting
132
+ * `backend.set`). Awaiting a synchronous hook is harmless, so existing sync
133
+ * after-hooks keep working unchanged.
125
134
  */
126
- static runAfter(
135
+ static async runAfter(
127
136
  classes: any[],
128
137
  req: Tina4Request,
129
138
  res: Tina4Response,
130
- ): [Tina4Request, Tina4Response] {
139
+ ): Promise<[Tina4Request, Tina4Response]> {
131
140
  for (const cls of classes) {
132
141
  const methods = Object.getOwnPropertyNames(cls).filter(
133
142
  (name) => typeof cls[name] === "function" && name.startsWith("after"),
134
143
  );
135
144
  for (const method of methods) {
136
- const result = cls[method](req, res);
145
+ const result = await cls[method](req, res);
137
146
  if (Array.isArray(result)) {
138
147
  [req, res] = result as [Tina4Request, Tina4Response];
139
148
  }
@@ -1142,7 +1142,7 @@ ${reset}
1142
1142
  ...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
1143
1143
  ];
1144
1144
  if (globalMiddleware.length > 0) {
1145
- const [, , proceed] = MiddlewareRunner.runBefore(globalMiddleware, req, res);
1145
+ const [, , proceed] = await MiddlewareRunner.runBefore(globalMiddleware, req, res);
1146
1146
  if (!proceed || res.raw.writableEnded) return;
1147
1147
  }
1148
1148
 
@@ -1240,7 +1240,7 @@ ${reset}
1240
1240
  // Header mutations here are no-ops once the response is flushed (Node
1241
1241
  // sends headers with the body) — set response headers in beforeX.
1242
1242
  if (globalMiddleware.length > 0) {
1243
- MiddlewareRunner.runAfter(globalMiddleware, req, res);
1243
+ await MiddlewareRunner.runAfter(globalMiddleware, req, res);
1244
1244
  }
1245
1245
 
1246
1246
  if (!res.raw.writableEnded) {
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { QueryCache } from "./sqlTranslation.js";
33
33
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "./types.js";
34
+ import type { CacheBackend } from "@tina4/core";
34
35
 
35
36
  function isTruthy(val: string | undefined): boolean {
36
37
  return ["true", "1", "yes", "on"].includes((val ?? "").trim().toLowerCase());
@@ -71,6 +72,25 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
71
72
  private hits: number = 0;
72
73
  private misses: number = 0;
73
74
 
75
+ /**
76
+ * Persistent-mode distributed backend (TINA4_DB_CACHE=true). Built lazily from
77
+ * the SAME unified `createBackend()` factory the response/KV cache uses, so
78
+ * multiple Database instances share one cache with global write-invalidation
79
+ * (parity with Python's connection.py, which routes the persistent DB cache
80
+ * through `_create_backend`). The read path (`fetchAsync`/`fetchOneAsync`/
81
+ * `queryAsync`) is async, so the backend's async get/set work directly — no
82
+ * sync-path restriction. Request-scoped mode keeps the in-process QueryCache
83
+ * above (ephemeral, fastest, never serialized).
84
+ *
85
+ * `null` until the first async read builds it; a `memory` backend (the
86
+ * default) means the persistent layer behaves in-process exactly as before, so
87
+ * default behaviour is unchanged and only an explicit redis/etc. backend
88
+ * distributes.
89
+ */
90
+ private backend: CacheBackend | null = null;
91
+ private backendPromise: Promise<CacheBackend | null> | null = null;
92
+ private backendName: string;
93
+
74
94
  constructor(adapter: DatabaseAdapter, options: CachedAdapterOptions = {}) {
75
95
  this.adapter = adapter;
76
96
  this.cachePersistent = options.persistent ?? isTruthy(process.env.TINA4_DB_CACHE);
@@ -87,9 +107,53 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
87
107
  }
88
108
 
89
109
  this.cache = options.sharedCache ?? new QueryCache({ defaultTtl: this.ttl, maxSize: 10000 });
110
+
111
+ // Persistent mode now routes through the unified async backend (the read
112
+ // path — fetchAsync/fetchOneAsync/queryAsync — is async, so the backend's
113
+ // async get/set work directly). TINA4_DB_CACHE_BACKEND + TINA4_DB_CACHE_URL
114
+ // select the backend (default `memory` = in-process, unchanged behaviour).
115
+ // The backend is built lazily on first async read so an unreachable network
116
+ // backend degrades to `file` (via createBackend's own fallback) without
117
+ // blocking construction.
118
+ this.backendName = (process.env.TINA4_DB_CACHE_BACKEND ?? "memory").toLowerCase().trim();
119
+
90
120
  CachedDatabaseAdapter.instances.add(this);
91
121
  }
92
122
 
123
+ /**
124
+ * Whether the persistent layer should use a distributed/serialised backend.
125
+ * For the default `memory` backend we keep the in-process QueryCache (fast,
126
+ * no serialisation) so behaviour is identical to before; only an explicit
127
+ * non-memory backend (redis/valkey/memcached/mongodb/database/file) routes
128
+ * through the unified async backend for cross-instance sharing.
129
+ */
130
+ private usesPersistentBackend(): boolean {
131
+ return this.cachePersistent && this.backendName !== "memory";
132
+ }
133
+
134
+ /** Lazily build (and memoise) the persistent backend via createBackend(). */
135
+ private async getBackend(): Promise<CacheBackend | null> {
136
+ if (this.backend) return this.backend;
137
+ if (!this.backendPromise) {
138
+ this.backendPromise = (async () => {
139
+ try {
140
+ // Dynamic import keeps @tina4/orm free of an import-time cycle with
141
+ // @tina4/core (whose `database` backend dynamically imports @tina4/orm).
142
+ const core: any = await import("@tina4/core");
143
+ const b: CacheBackend = await core.createBackend({
144
+ backend: this.backendName,
145
+ cacheUrl: process.env.TINA4_DB_CACHE_URL,
146
+ });
147
+ this.backend = b;
148
+ return b;
149
+ } catch {
150
+ return null; // fall back to the in-process QueryCache
151
+ }
152
+ })();
153
+ }
154
+ return this.backendPromise;
155
+ }
156
+
93
157
  // ── Cache mode helpers ────────────────────────────────────
94
158
 
95
159
  /** Current cache mode: "persistent" | "request" | "off". */
@@ -134,15 +198,21 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
134
198
 
135
199
  cacheStats(): {
136
200
  enabled: boolean; mode: "persistent" | "request" | "off";
137
- hits: number; misses: number; size: number; ttl: number;
201
+ hits: number; misses: number; size: number; ttl: number; backend?: string;
138
202
  } {
139
203
  return {
140
204
  enabled: this.enabled,
141
205
  mode: this.cacheMode(),
142
206
  hits: this.hits,
143
207
  misses: this.misses,
208
+ // `size` is the in-process figure. When a distributed persistent backend
209
+ // is active the authoritative size lives in redis/etc.; the hit/miss
210
+ // counters here are still real (tracked locally per the read path).
144
211
  size: this.cache.size(),
145
212
  ttl: this.ttl,
213
+ // Report the actually-configured persistent backend so the operator sees
214
+ // where cross-instance entries land (parity with Python's cache_stats).
215
+ backend: this.usesPersistentBackend() ? this.backendName : "memory",
146
216
  };
147
217
  }
148
218
 
@@ -151,11 +221,68 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
151
221
  this.cache.clear();
152
222
  this.hits = 0;
153
223
  this.misses = 0;
224
+ // Best-effort flush of the distributed backend too (fire-and-forget — this
225
+ // method is synchronous to keep db.cacheClear() ergonomic).
226
+ if (this.usesPersistentBackend()) {
227
+ void this.getBackend().then((b) => b?.clear()).catch(() => { /* best effort */ });
228
+ }
154
229
  }
155
230
 
156
231
  /** Clear the entire query cache (called on writes). */
157
232
  private invalidate(): void {
158
233
  this.cache.clear();
234
+ // Persistent distributed backend: clear it too so a write on ANY instance
235
+ // invalidates entries cached by ALL instances (global write-invalidation,
236
+ // parity with Python's _cache_invalidate → backend.clear()). Fire-and-forget
237
+ // on the sync write path (execute/insert/...); the async write methods below
238
+ // await invalidateAsync() instead for deterministic ordering.
239
+ if (this.usesPersistentBackend()) {
240
+ void this.getBackend().then((b) => b?.clear()).catch(() => { /* best effort */ });
241
+ }
242
+ }
243
+
244
+ /** Async write-invalidation — awaits the distributed backend clear. */
245
+ private async invalidateAsync(): Promise<void> {
246
+ this.cache.clear();
247
+ if (this.usesPersistentBackend()) {
248
+ const b = await this.getBackend();
249
+ if (b) { try { await b.clear(); } catch { /* best effort */ } }
250
+ }
251
+ }
252
+
253
+ // ── Persistent-backend get/set helpers (serialised rows) ──
254
+ //
255
+ // The persistent backend stores the adapter's row payload (T[] for fetch/
256
+ // query, {row:T|null} for fetchOne) as plain JSON — every backend (redis
257
+ // SETEX, mongo doc, db row) round-trips it. fetchOne is wrapped in an object
258
+ // so a genuine `null` row is distinguishable from a cache miss (the backend
259
+ // returns undefined on miss).
260
+
261
+ private async backendGetRows<T>(key: string): Promise<T[] | undefined> {
262
+ const b = await this.getBackend();
263
+ if (!b) return undefined;
264
+ const raw = await b.get(key);
265
+ return Array.isArray(raw) ? (raw as T[]) : undefined;
266
+ }
267
+
268
+ private async backendSetRows<T>(key: string, rows: T[]): Promise<void> {
269
+ const b = await this.getBackend();
270
+ if (b) { try { await b.set(key, rows, this.ttl); } catch { /* best effort */ } }
271
+ }
272
+
273
+ private async backendGetOne<T>(key: string): Promise<{ row: T | null } | undefined> {
274
+ const b = await this.getBackend();
275
+ if (!b) return undefined;
276
+ const raw = await b.get(key);
277
+ if (raw && typeof raw === "object" && "row" in (raw as object)) {
278
+ return raw as { row: T | null };
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ private async backendSetOne<T>(key: string, row: T | null): Promise<void> {
284
+ const b = await this.getBackend();
285
+ if (b) { try { await b.set(key, { row }, this.ttl); } catch { /* best effort */ } }
159
286
  }
160
287
 
161
288
  // ── DatabaseAdapter interface — writes flush, reads cache ──
@@ -291,88 +418,113 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
291
418
  // cache sits in front of the async path too. Reads cache; writes flush.
292
419
 
293
420
  async fetchAsync<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): Promise<T[]> {
421
+ const run = async (): Promise<T[]> => (this.adapter as any).fetchAsync
422
+ ? await (this.adapter as any).fetchAsync(sql, params, limit, skip)
423
+ : this.adapter.fetch<T>(sql, params, limit, skip);
294
424
  if (this.enabled) {
295
425
  const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined);
426
+ // Persistent distributed backend is AUTHORITATIVE (mirrors Python, where a
427
+ // configured _cache_backend bypasses the in-process dict). This keeps
428
+ // cross-instance write-invalidation deterministic: a write clears the
429
+ // shared backend, so every instance misses on its next read.
430
+ if (this.usesPersistentBackend()) {
431
+ const shared = await this.backendGetRows<T>(key);
432
+ if (shared !== undefined) { this.hits++; return shared; }
433
+ const result = await run();
434
+ await this.backendSetRows(key, result);
435
+ this.misses++;
436
+ return result;
437
+ }
296
438
  const cached = this.cache.get<T[]>(key);
297
439
  if (cached !== undefined) {
298
440
  this.hits++;
299
441
  return cached;
300
442
  }
301
- const result = (this.adapter as any).fetchAsync
302
- ? await (this.adapter as any).fetchAsync(sql, params, limit, skip)
303
- : this.adapter.fetch<T>(sql, params, limit, skip);
443
+ const result = await run();
304
444
  this.cache.set(key, result, this.ttl);
305
445
  this.misses++;
306
446
  return result;
307
447
  }
308
- return (this.adapter as any).fetchAsync
309
- ? await (this.adapter as any).fetchAsync(sql, params, limit, skip)
310
- : this.adapter.fetch<T>(sql, params, limit, skip);
448
+ return run();
311
449
  }
312
450
 
313
451
  async fetchOneAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null> {
452
+ const run = async (): Promise<T | null> => (this.adapter as any).fetchOneAsync
453
+ ? await (this.adapter as any).fetchOneAsync(sql, params)
454
+ : this.adapter.fetchOne<T>(sql, params);
314
455
  if (this.enabled) {
315
456
  const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined);
457
+ if (this.usesPersistentBackend()) {
458
+ const shared = await this.backendGetOne<T>(key);
459
+ if (shared !== undefined) { this.hits++; return shared.row; }
460
+ const result = await run();
461
+ await this.backendSetOne(key, result);
462
+ this.misses++;
463
+ return result;
464
+ }
316
465
  const cached = this.cache.get<T | null>(key);
317
466
  if (cached !== undefined) {
318
467
  this.hits++;
319
468
  return cached;
320
469
  }
321
- const result = (this.adapter as any).fetchOneAsync
322
- ? await (this.adapter as any).fetchOneAsync(sql, params)
323
- : this.adapter.fetchOne<T>(sql, params);
470
+ const result = await run();
324
471
  this.cache.set(key, result, this.ttl);
325
472
  this.misses++;
326
473
  return result;
327
474
  }
328
- return (this.adapter as any).fetchOneAsync
329
- ? await (this.adapter as any).fetchOneAsync(sql, params)
330
- : this.adapter.fetchOne<T>(sql, params);
475
+ return run();
331
476
  }
332
477
 
333
478
  async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
479
+ const run = async (): Promise<T[]> => (this.adapter as any).queryAsync
480
+ ? await (this.adapter as any).queryAsync(sql, params)
481
+ : this.adapter.query<T>(sql, params);
334
482
  if (this.enabled) {
335
483
  const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined);
484
+ if (this.usesPersistentBackend()) {
485
+ const shared = await this.backendGetRows<T>(key);
486
+ if (shared !== undefined) { this.hits++; return shared; }
487
+ const result = await run();
488
+ await this.backendSetRows(key, result);
489
+ this.misses++;
490
+ return result;
491
+ }
336
492
  const cached = this.cache.get<T[]>(key);
337
493
  if (cached !== undefined) {
338
494
  this.hits++;
339
495
  return cached;
340
496
  }
341
- const result = (this.adapter as any).queryAsync
342
- ? await (this.adapter as any).queryAsync(sql, params)
343
- : this.adapter.query<T>(sql, params);
497
+ const result = await run();
344
498
  this.cache.set(key, result, this.ttl);
345
499
  this.misses++;
346
500
  return result;
347
501
  }
348
- return (this.adapter as any).queryAsync
349
- ? await (this.adapter as any).queryAsync(sql, params)
350
- : this.adapter.query<T>(sql, params);
502
+ return run();
351
503
  }
352
504
 
353
505
  async executeAsync(sql: string, params?: unknown[]): Promise<unknown> {
354
- if (this.enabled) this.invalidate();
506
+ if (this.enabled) await this.invalidateAsync();
355
507
  return (this.adapter as any).executeAsync
356
508
  ? await (this.adapter as any).executeAsync(sql, params)
357
509
  : this.adapter.execute(sql, params);
358
510
  }
359
511
 
360
512
  async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
361
- if (this.enabled) this.invalidate();
513
+ if (this.enabled) await this.invalidateAsync();
362
514
  return (this.adapter as any).insertAsync
363
515
  ? await (this.adapter as any).insertAsync(table, data)
364
516
  : this.adapter.insert(table, data);
365
517
  }
366
518
 
367
519
  async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): Promise<DatabaseResult> {
368
- if (this.enabled) this.invalidate();
520
+ if (this.enabled) await this.invalidateAsync();
369
521
  return (this.adapter as any).updateAsync
370
522
  ? await (this.adapter as any).updateAsync(table, data, filter, params)
371
523
  : this.adapter.update(table, data, filter);
372
524
  }
373
525
 
374
526
  async deleteAsync(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseResult> {
375
- if (this.enabled) this.invalidate();
527
+ if (this.enabled) await this.invalidateAsync();
376
528
  return (this.adapter as any).deleteAsync
377
529
  ? await (this.adapter as any).deleteAsync(table, filter, params)
378
530
  : this.adapter.delete(table, filter as Record<string, unknown>);
@@ -835,7 +835,7 @@ export class Database {
835
835
  * we read the live counters + size + mode from it. Mirrors Python's
836
836
  * `Database.cache_stats()`: `{ enabled, mode, hits, misses, size, ttl }`.
837
837
  */
838
- cacheStats(): { enabled: boolean; mode: "persistent" | "request" | "off"; hits: number; misses: number; size: number; ttl: number } {
838
+ cacheStats(): { enabled: boolean; mode: "persistent" | "request" | "off"; hits: number; misses: number; size: number; ttl: number; backend?: string } {
839
839
  const adapter = this.getNextAdapter();
840
840
  if (adapter instanceof CachedDatabaseAdapter) {
841
841
  return adapter.cacheStats();