tina4-nodejs 3.13.21 → 3.13.23

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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.21)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.23)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.21 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.23 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -589,8 +589,12 @@ db.getColumns(table): { name, type, nullable?, default?, primaryKey? }[]
589
589
  // auto-creates Postgres sequences, and uses native Firebird generators.
590
590
  db.getNextId(table, pkColumn?, generatorName?): number
591
591
 
592
- // Query cache (TINA4_DB_CACHE=true)
593
- db.cacheStats(): { enabled, size, ttl }
592
+ // DB query cache — request-scoped auto cache is ON by default (TINA4_AUTO_CACHING=true,
593
+ // TTL TINA4_AUTO_CACHING_TTL=5s): dedupes identical db.fetch()/ORM reads within a request,
594
+ // flushed on any write. Persistent cross-request cache opt-in via TINA4_DB_CACHE=true
595
+ // (TTL TINA4_DB_CACHE_TTL=30s). cacheStats()/cacheClear() are now real (the DB query cache
596
+ // is wired — previously db.cacheStats() hardcoded size:0 and db.cacheClear() was a no-op).
597
+ db.cacheStats(): { enabled, size, ttl, mode } // mode: "request" | "persistent" | "off"
594
598
  db.cacheClear(): void
595
599
 
596
600
  // Connection pool access (null when pooling disabled)
@@ -895,13 +899,14 @@ const u = cacheGet("user:1");
895
899
  cacheDelete("user:1");
896
900
  cacheClear();
897
901
 
898
- cacheStats(); // { hits, misses, size, backend }
902
+ cacheStats(); // { hits, misses, size, backend } — now reflects the real KV backend
903
+ // (previously it wrongly read the response-cache middleware store)
899
904
  ```
900
905
 
901
906
  Environment:
902
907
  - `TINA4_CACHE_BACKEND` — `memory` | `redis` | `file` (default: `memory`)
903
908
  - `TINA4_CACHE_URL` — `redis://localhost:6379` (redis backend only)
904
- - `TINA4_CACHE_TTL` — default TTL seconds (default: `0` = disabled)
909
+ - `TINA4_CACHE_TTL` — default TTL seconds (default: `60`)
905
910
  - `TINA4_CACHE_MAX_ENTRIES` — max entries (default: `1000`)
906
911
 
907
912
  ## Firebird-Specific Rules
@@ -1098,12 +1103,12 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1098
1103
  ## v3 Features Summary
1099
1104
 
1100
1105
  - **45 built-in features**, zero third-party dependencies
1101
- - **3,684 tests** passing across all modules
1106
+ - **3,708 tests** passing across all modules
1102
1107
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1103
1108
  - **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
1104
1109
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
1105
1110
  - **`npx tina4nodejs generate`**: model, route, migration, middleware scaffolding
1106
- - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`)
1111
+ - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **on by default** (`TINA4_AUTO_CACHING=true`, TTL `TINA4_AUTO_CACHING_TTL=5`s) dedupes identical `db.fetch()`/ORM reads within a request and flushes on writes; persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s). `db.cacheStats()`/`db.cacheClear()` are now real (the DB query cache is wired; was dead code)
1107
1112
  - **Sessions**: file backend (default). `TINA4_SESSION_SAMESITE` env var (default: Lax)
1108
1113
  - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1109
1114
  - **Cache**: memory/Redis/file backends
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.21",
6
+ "version": "3.13.23",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -22,7 +22,7 @@
22
22
  * Environment:
23
23
  * TINA4_CACHE_BACKEND — memory | redis | file (default: memory)
24
24
  * TINA4_CACHE_URL — redis://localhost:6379 (redis only)
25
- * TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
25
+ * TINA4_CACHE_TTL — default TTL in seconds (default: 60)
26
26
  * TINA4_CACHE_MAX_ENTRIES — max entries (default: 1000)
27
27
  */
28
28
 
@@ -455,9 +455,13 @@ export function clearCache(): void {
455
455
  store.clear();
456
456
  }
457
457
 
458
- /** Get cache stats (middleware store) */
459
- export function cacheStats(): { size: number; keys: string[]; backend: string } {
460
- return { size: store.size, keys: [...store.keys()], backend: _getBackend().name() };
458
+ /**
459
+ * Get KV cache stats reports the same backend that cacheGet/cacheSet/cacheDelete use,
460
+ * so a value stored via cacheSet() is reflected here. Mirrors cache_stats() in the
461
+ * Python / PHP / Ruby frameworks. (Identical to cacheBackendStats(), kept for parity naming.)
462
+ */
463
+ export function cacheStats(): { hits: number; misses: number; size: number; backend: string } {
464
+ return _getBackend().stats();
461
465
  }
462
466
 
463
467
  // ── Module-level direct cache API (backend-aware) ─────────────────
@@ -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,26 +36,109 @@ function isTruthy(val: string | undefined): boolean {
25
36
  return ["true", "1", "yes", "on"].includes((val ?? "").trim().toLowerCase());
26
37
  }
27
38
 
39
+ /**
40
+ * Options for wrapping an adapter with a query cache. When several pooled
41
+ * connections must share one cache store (so a write on any connection
42
+ * invalidates reads cached by all of them), pass the same `sharedCache`.
43
+ */
44
+ export interface CachedAdapterOptions {
45
+ /** Force-enable the persistent (cross-request) layer. Defaults to TINA4_DB_CACHE. */
46
+ persistent?: boolean;
47
+ /** Force-enable the request-scoped layer. Defaults to TINA4_AUTO_CACHING (default true). */
48
+ requestScoped?: boolean;
49
+ /** Override the effective TTL (seconds). Defaults to the mode-appropriate env var. */
50
+ ttl?: number;
51
+ /** Share a single QueryCache store across multiple wrappers (pooled connections). */
52
+ sharedCache?: QueryCache;
53
+ }
54
+
28
55
  export class CachedDatabaseAdapter implements DatabaseAdapter {
56
+ /**
57
+ * Live wrappers, so the request dispatcher can clear the request-scoped cache
58
+ * on every connection at the start of each HTTP request. Mirrors Python's
59
+ * `Database._instances` WeakSet. A WeakSet lets closed connections be GC'd.
60
+ */
61
+ private static instances: Set<CachedDatabaseAdapter> = new Set();
62
+
29
63
  private adapter: DatabaseAdapter;
30
64
  private cache: QueryCache;
65
+ /** Persistent (cross-request) layer — TINA4_DB_CACHE. */
66
+ private cachePersistent: boolean;
67
+ /** Request-scoped layer — TINA4_AUTO_CACHING (default ON). */
68
+ private cacheRequestScoped: boolean;
31
69
  private enabled: boolean;
32
70
  private ttl: number;
33
71
  private hits: number = 0;
34
72
  private misses: number = 0;
35
73
 
36
- constructor(adapter: DatabaseAdapter, enabled?: boolean, ttl?: number) {
74
+ constructor(adapter: DatabaseAdapter, options: CachedAdapterOptions = {}) {
37
75
  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 });
76
+ this.cachePersistent = options.persistent ?? isTruthy(process.env.TINA4_DB_CACHE);
77
+ this.cacheRequestScoped = options.requestScoped
78
+ ?? (process.env.TINA4_AUTO_CACHING === undefined ? true : isTruthy(process.env.TINA4_AUTO_CACHING));
79
+ this.enabled = this.cachePersistent || this.cacheRequestScoped;
80
+
81
+ if (options.ttl !== undefined) {
82
+ this.ttl = options.ttl;
83
+ } else if (this.cachePersistent) {
84
+ this.ttl = parseInt(process.env.TINA4_DB_CACHE_TTL ?? "30", 10);
85
+ } else {
86
+ this.ttl = parseInt(process.env.TINA4_AUTO_CACHING_TTL ?? "5", 10);
87
+ }
88
+
89
+ this.cache = options.sharedCache ?? new QueryCache({ defaultTtl: this.ttl, maxSize: 10000 });
90
+ CachedDatabaseAdapter.instances.add(this);
91
+ }
92
+
93
+ // ── Cache mode helpers ────────────────────────────────────
94
+
95
+ /** Current cache mode: "persistent" | "request" | "off". */
96
+ cacheMode(): "persistent" | "request" | "off" {
97
+ return this.cachePersistent ? "persistent" : (this.cacheRequestScoped ? "request" : "off");
98
+ }
99
+
100
+ /** Whether either cache layer is active. */
101
+ cacheEnabled(): boolean {
102
+ return this.enabled;
103
+ }
104
+
105
+ /**
106
+ * Clear the request-scoped cache at the start of an HTTP request.
107
+ * No-op in persistent mode (cross-request entries survive to their TTL).
108
+ * Cumulative hit/miss counters are preserved. Mirrors Python's
109
+ * `Database.cache_new_request()`.
110
+ */
111
+ cacheNewRequest(): void {
112
+ if (this.cacheRequestScoped && !this.cachePersistent) {
113
+ this.cache.clear();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Clear the request-scoped cache on every live wrapper. The request
119
+ * dispatcher calls this at the start of each HTTP request so request-scoped
120
+ * caching never serves rows across requests. Persistent-mode connections are
121
+ * left alone. Mirrors Python's `Database.reset_request_caches()` classmethod.
122
+ */
123
+ static resetRequestCaches(): void {
124
+ for (const inst of CachedDatabaseAdapter.instances) {
125
+ try {
126
+ inst.cacheNewRequest();
127
+ } catch {
128
+ /* a closed/broken wrapper must not break the request boundary */
129
+ }
130
+ }
41
131
  }
42
132
 
43
- // ── Cache helpers ─────────────────────────────────────────
133
+ // ── Cache stats / management ──────────────────────────────
44
134
 
45
- cacheStats(): { enabled: boolean; hits: number; misses: number; size: number; ttl: number } {
135
+ cacheStats(): {
136
+ enabled: boolean; mode: "persistent" | "request" | "off";
137
+ hits: number; misses: number; size: number; ttl: number;
138
+ } {
46
139
  return {
47
140
  enabled: this.enabled,
141
+ mode: this.cacheMode(),
48
142
  hits: this.hits,
49
143
  misses: this.misses,
50
144
  size: this.cache.size(),
@@ -52,17 +146,19 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
52
146
  };
53
147
  }
54
148
 
149
+ /** Flush the query cache and reset counters. Mirrors Python `cache_clear()`. */
55
150
  cacheClear(): void {
56
151
  this.cache.clear();
57
152
  this.hits = 0;
58
153
  this.misses = 0;
59
154
  }
60
155
 
156
+ /** Clear the entire query cache (called on writes). */
61
157
  private invalidate(): void {
62
158
  this.cache.clear();
63
159
  }
64
160
 
65
- // ── DatabaseAdapter interface ─────────────────────────────
161
+ // ── DatabaseAdapter interface — writes flush, reads cache ──
66
162
 
67
163
  execute(sql: string, params?: unknown[]): unknown {
68
164
  if (this.enabled) this.invalidate();
@@ -75,6 +171,22 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
75
171
  }
76
172
 
77
173
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
174
+ // The Node ORM reads most rows through query() (find/where/all/relationships),
175
+ // so caching here is what makes ORM reads dedupe — matching the Python master
176
+ // where every ORM read flows through the cached db.fetch(). Same store, same
177
+ // counters, flushed on writes.
178
+ if (this.enabled) {
179
+ const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined);
180
+ const cached = this.cache.get<T[]>(key);
181
+ if (cached !== undefined) {
182
+ this.hits++;
183
+ return cached;
184
+ }
185
+ const result = this.adapter.query<T>(sql, params);
186
+ this.cache.set(key, result, this.ttl);
187
+ this.misses++;
188
+ return result;
189
+ }
78
190
  return this.adapter.query(sql, params);
79
191
  }
80
192
 
@@ -150,6 +262,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
150
262
  }
151
263
 
152
264
  close(): void {
265
+ CachedDatabaseAdapter.instances.delete(this);
153
266
  this.adapter.close();
154
267
  }
155
268
 
@@ -171,8 +284,141 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
171
284
  this.adapter.addColumn?.(table, colName, def);
172
285
  }
173
286
 
287
+ // ── Async passthroughs (PostgreSQL/MySQL/MSSQL/Firebird/Mongo) ──
288
+ //
289
+ // The async adapters implement *Async methods; the Database wrapper and the
290
+ // ORM read/write path prefer those when present. We mirror them here so the
291
+ // cache sits in front of the async path too. Reads cache; writes flush.
292
+
293
+ async fetchAsync<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): Promise<T[]> {
294
+ if (this.enabled) {
295
+ const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined);
296
+ const cached = this.cache.get<T[]>(key);
297
+ if (cached !== undefined) {
298
+ this.hits++;
299
+ return cached;
300
+ }
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);
304
+ this.cache.set(key, result, this.ttl);
305
+ this.misses++;
306
+ return result;
307
+ }
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);
311
+ }
312
+
313
+ async fetchOneAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null> {
314
+ if (this.enabled) {
315
+ const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined);
316
+ const cached = this.cache.get<T | null>(key);
317
+ if (cached !== undefined) {
318
+ this.hits++;
319
+ return cached;
320
+ }
321
+ const result = (this.adapter as any).fetchOneAsync
322
+ ? await (this.adapter as any).fetchOneAsync(sql, params)
323
+ : this.adapter.fetchOne<T>(sql, params);
324
+ this.cache.set(key, result, this.ttl);
325
+ this.misses++;
326
+ return result;
327
+ }
328
+ return (this.adapter as any).fetchOneAsync
329
+ ? await (this.adapter as any).fetchOneAsync(sql, params)
330
+ : this.adapter.fetchOne<T>(sql, params);
331
+ }
332
+
333
+ async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
334
+ if (this.enabled) {
335
+ const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined);
336
+ const cached = this.cache.get<T[]>(key);
337
+ if (cached !== undefined) {
338
+ this.hits++;
339
+ return cached;
340
+ }
341
+ const result = (this.adapter as any).queryAsync
342
+ ? await (this.adapter as any).queryAsync(sql, params)
343
+ : this.adapter.query<T>(sql, params);
344
+ this.cache.set(key, result, this.ttl);
345
+ this.misses++;
346
+ return result;
347
+ }
348
+ return (this.adapter as any).queryAsync
349
+ ? await (this.adapter as any).queryAsync(sql, params)
350
+ : this.adapter.query<T>(sql, params);
351
+ }
352
+
353
+ async executeAsync(sql: string, params?: unknown[]): Promise<unknown> {
354
+ if (this.enabled) this.invalidate();
355
+ return (this.adapter as any).executeAsync
356
+ ? await (this.adapter as any).executeAsync(sql, params)
357
+ : this.adapter.execute(sql, params);
358
+ }
359
+
360
+ async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
361
+ if (this.enabled) this.invalidate();
362
+ return (this.adapter as any).insertAsync
363
+ ? await (this.adapter as any).insertAsync(table, data)
364
+ : this.adapter.insert(table, data);
365
+ }
366
+
367
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): Promise<DatabaseResult> {
368
+ if (this.enabled) this.invalidate();
369
+ return (this.adapter as any).updateAsync
370
+ ? await (this.adapter as any).updateAsync(table, data, filter, params)
371
+ : this.adapter.update(table, data, filter);
372
+ }
373
+
374
+ async deleteAsync(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseResult> {
375
+ if (this.enabled) this.invalidate();
376
+ return (this.adapter as any).deleteAsync
377
+ ? await (this.adapter as any).deleteAsync(table, filter, params)
378
+ : this.adapter.delete(table, filter as Record<string, unknown>);
379
+ }
380
+
381
+ async startTransactionAsync(): Promise<void> {
382
+ if ((this.adapter as any).startTransactionAsync) await (this.adapter as any).startTransactionAsync();
383
+ else this.adapter.startTransaction();
384
+ }
385
+
386
+ async commitAsync(): Promise<void> {
387
+ if ((this.adapter as any).commitAsync) await (this.adapter as any).commitAsync();
388
+ else this.adapter.commit();
389
+ }
390
+
391
+ async rollbackAsync(): Promise<void> {
392
+ if ((this.adapter as any).rollbackAsync) await (this.adapter as any).rollbackAsync();
393
+ else this.adapter.rollback();
394
+ }
395
+
396
+ async tableExistsAsync(name: string): Promise<boolean> {
397
+ return (this.adapter as any).tableExistsAsync
398
+ ? await (this.adapter as any).tableExistsAsync(name)
399
+ : this.adapter.tableExists(name);
400
+ }
401
+
402
+ async tablesAsync(): Promise<string[]> {
403
+ return (this.adapter as any).tablesAsync
404
+ ? await (this.adapter as any).tablesAsync()
405
+ : this.adapter.tables();
406
+ }
407
+
408
+ async columnsAsync(table: string): Promise<ColumnInfo[]> {
409
+ return (this.adapter as any).columnsAsync
410
+ ? await (this.adapter as any).columnsAsync(table)
411
+ : this.adapter.columns(table);
412
+ }
413
+
414
+ async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
415
+ if (this.enabled) this.invalidate();
416
+ if ((this.adapter as any).createTableAsync) await (this.adapter as any).createTableAsync(name, columns);
417
+ else this.adapter.createTable(name, columns);
418
+ }
419
+
174
420
  /**
175
- * Access the underlying adapter directly.
421
+ * Access the underlying (unwrapped) adapter directly.
176
422
  */
177
423
  getAdapter(): DatabaseAdapter {
178
424
  return this.adapter;
@@ -1,6 +1,8 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult, ColumnInfo, FieldDefinition } from "./types.js";
3
3
  import { DatabaseResult } from "./databaseResult.js";
4
+ import { CachedDatabaseAdapter, type CachedAdapterOptions } from "./cachedDatabase.js";
5
+ import { QueryCache } from "./sqlTranslation.js";
4
6
 
5
7
  /**
6
8
  * v3.13.12 — strip trailing `;` and whitespace from user-supplied SQL
@@ -128,8 +130,45 @@ export function extractLastInsertId(result: unknown): number | bigint | null {
128
130
  let activeAdapter: DatabaseAdapter | null = null;
129
131
  const namedAdapters: Map<string, DatabaseAdapter> = new Map();
130
132
 
131
- export function setAdapter(adapter: DatabaseAdapter): void {
132
- activeAdapter = adapter;
133
+ /**
134
+ * Wrap a raw adapter with the query cache so BOTH `db.fetch()` (via the
135
+ * Database wrapper) AND ORM reads (via `getAdapter()` / `getNamedAdapter()`)
136
+ * are cached through the same store and counters.
137
+ *
138
+ * Idempotent: an already-wrapped adapter is returned as-is, so re-binding the
139
+ * same adapter (or binding the adapter a Database wrapper already holds) never
140
+ * double-wraps. `options.sharedCache` backs all pooled connections with one
141
+ * store so a write on any connection invalidates reads cached by all of them.
142
+ *
143
+ * Caching is ON by default (request-scoped, TINA4_AUTO_CACHING) and additionally
144
+ * persistent when TINA4_DB_CACHE=true. Off-switch: TINA4_AUTO_CACHING=false (and
145
+ * TINA4_DB_CACHE unset) — then the wrapper passes everything straight through.
146
+ */
147
+ export function wrapWithCache(adapter: DatabaseAdapter, options?: CachedAdapterOptions): DatabaseAdapter {
148
+ if (adapter instanceof CachedDatabaseAdapter) return adapter;
149
+ return new CachedDatabaseAdapter(adapter, options);
150
+ }
151
+
152
+ /**
153
+ * Resolve the underlying wrapped adapter for a given raw adapter — used so the
154
+ * Database wrapper and `getAdapter()` end up holding the SAME
155
+ * CachedDatabaseAdapter instance (one cache, one set of counters).
156
+ */
157
+ export function setAdapter(adapter: DatabaseAdapter): DatabaseAdapter {
158
+ activeAdapter = wrapWithCache(adapter);
159
+ return activeAdapter;
160
+ }
161
+
162
+ /**
163
+ * Clear the request-scoped query cache on every live connection at the start of
164
+ * each HTTP request, so request-scoped caching never serves rows across
165
+ * requests. Persistent-mode connections (TINA4_DB_CACHE=true) are untouched.
166
+ *
167
+ * The request dispatcher calls this. Mirrors Python's
168
+ * `Database.reset_request_caches()`.
169
+ */
170
+ export function resetRequestCaches(): void {
171
+ CachedDatabaseAdapter.resetRequestCaches();
133
172
  }
134
173
 
135
174
  /**
@@ -161,7 +200,9 @@ export function bindDatabase(adapter: DatabaseAdapter, name?: string): void {
161
200
  if (name === undefined) {
162
201
  setAdapter(adapter);
163
202
  } else {
164
- namedAdapters.set(name, adapter);
203
+ // Named connections are cached too, so ORM models pointed at them with
204
+ // `static _db = name` get the same request-scoped/persistent caching.
205
+ namedAdapters.set(name, wrapWithCache(adapter));
165
206
  }
166
207
  }
167
208
 
@@ -177,7 +218,7 @@ export function getAdapter(): DatabaseAdapter {
177
218
  * Models reference it via `static _db = 'name'`.
178
219
  */
179
220
  export function setNamedAdapter(name: string, adapter: DatabaseAdapter): void {
180
- namedAdapters.set(name, adapter);
221
+ namedAdapters.set(name, wrapWithCache(adapter));
181
222
  }
182
223
 
183
224
  /**
@@ -463,29 +504,35 @@ export class Database {
463
504
  const parsed = parseDatabaseUrl(url, username, password);
464
505
 
465
506
  if (pool > 0) {
466
- // Pooled mode — create all adapters eagerly
507
+ // Pooled mode — create all adapters eagerly, then wrap each with the
508
+ // query cache backed by ONE shared store so a write on any pooled
509
+ // connection invalidates reads cached by all of them.
510
+ const sharedCache = new QueryCache({ maxSize: 10000 });
467
511
  const adapters: DatabaseAdapter[] = [];
468
512
  for (let i = 0; i < pool; i++) {
469
- adapters.push(await createAdapterFromUrl(url, username, password));
513
+ const raw = await createAdapterFromUrl(url, username, password);
514
+ adapters.push(wrapWithCache(raw, { sharedCache }));
470
515
  }
471
516
 
472
- // Set the first adapter as the global default
473
- setAdapter(adapters[0]);
517
+ // Set the first adapter as the global default (already cache-wrapped).
518
+ activeAdapter = adapters[0];
474
519
 
475
520
  const db = new Database(adapters[0]);
476
521
  db._poolSize = pool;
477
522
  db.pool = adapters;
478
523
  db.poolIndex = 0;
479
524
  db.adapter = null; // Don't use single-adapter path
480
- db.adapterFactory = () => createAdapterFromUrl(url, username, password);
525
+ db.adapterFactory = async () => wrapWithCache(await createAdapterFromUrl(url, username, password), { sharedCache });
481
526
  db.dbType = parsed.type;
482
527
  return db;
483
528
  }
484
529
 
485
- // Single-connection mode — current behavior
530
+ // Single-connection mode — wrap once and share the SAME wrapped adapter
531
+ // between getAdapter() (ORM reads) and the Database wrapper (db.fetch()),
532
+ // so both hit one cache + one set of counters.
486
533
  const adapter = await createAdapterFromUrl(url, username, password);
487
- setAdapter(adapter);
488
- const db = new Database(adapter);
534
+ const wrapped = setAdapter(adapter);
535
+ const db = new Database(wrapped);
489
536
  db.dbType = parsed.type;
490
537
  return db;
491
538
  }
@@ -780,21 +827,42 @@ export class Database {
780
827
  return this.lastError ?? null;
781
828
  }
782
829
 
783
- /** Return query cache statistics. */
784
- cacheStats(): { enabled: boolean; size: number; ttl: number } {
785
- return {
786
- enabled: process.env.TINA4_DB_CACHE === "true",
787
- size: 0,
788
- ttl: parseInt(process.env.TINA4_DB_CACHE_TTL ?? "30", 10),
789
- };
830
+ /**
831
+ * Return query cache statistics from the REAL cache backing this connection.
832
+ *
833
+ * The bound adapter is a CachedDatabaseAdapter (caching is ON by default —
834
+ * request-scoped — and additionally persistent when TINA4_DB_CACHE=true), so
835
+ * we read the live counters + size + mode from it. Mirrors Python's
836
+ * `Database.cache_stats()`: `{ enabled, mode, hits, misses, size, ttl }`.
837
+ */
838
+ cacheStats(): { enabled: boolean; mode: "persistent" | "request" | "off"; hits: number; misses: number; size: number; ttl: number } {
839
+ const adapter = this.getNextAdapter();
840
+ if (adapter instanceof CachedDatabaseAdapter) {
841
+ return adapter.cacheStats();
842
+ }
843
+ // Adapter isn't cache-wrapped (shouldn't happen via initDatabase/create) —
844
+ // report a disabled cache truthfully rather than lying about size.
845
+ return { enabled: false, mode: "off", hits: 0, misses: 0, size: 0, ttl: 0 };
790
846
  }
791
847
 
792
- /** Clear the query result cache. */
848
+ /** Flush the query cache and reset counters (mirrors Python `cache_clear()`). */
793
849
  cacheClear(): void {
794
- // Node database layer does not maintain an internal query cache at this
795
- // level (caching lives in the SQLTranslation layer). This method exists
796
- // for API parity with PHP, Python, and Ruby.
797
- // To clear the SQLTranslation query cache use: QueryCache.clear()
850
+ const adapter = this.getNextAdapter();
851
+ if (adapter instanceof CachedDatabaseAdapter) {
852
+ adapter.cacheClear();
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Clear the request-scoped cache at the START of an HTTP request on this
858
+ * connection (no-op in persistent mode). Mirrors Python's
859
+ * `Database.cache_new_request()`.
860
+ */
861
+ cacheNewRequest(): void {
862
+ const adapter = this.getNextAdapter();
863
+ if (adapter instanceof CachedDatabaseAdapter) {
864
+ adapter.cacheNewRequest();
865
+ }
798
866
  }
799
867
 
800
868
  /** Get the last auto-increment id. */
@@ -1088,6 +1156,18 @@ export namespace Database {
1088
1156
  password: opts.password,
1089
1157
  });
1090
1158
  }
1159
+
1160
+ /**
1161
+ * Clear the request-scoped query cache on every live connection.
1162
+ *
1163
+ * Static convenience mirroring Python's `Database.reset_request_caches()`
1164
+ * classmethod. The request dispatcher calls this at the start of each HTTP
1165
+ * request so request-scoped caching never serves rows across requests.
1166
+ * Persistent-mode connections (TINA4_DB_CACHE=true) are left alone.
1167
+ */
1168
+ export function resetRequestCaches(): void {
1169
+ CachedDatabaseAdapter.resetRequestCaches();
1170
+ }
1091
1171
  }
1092
1172
 
1093
1173
  export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
@@ -1106,8 +1186,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1106
1186
  return Database.create(url, resolvedUser, resolvedPassword, pool);
1107
1187
  }
1108
1188
  const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
1109
- setAdapter(adapter);
1110
- return new Database(adapter);
1189
+ return new Database(setAdapter(adapter));
1111
1190
  }
1112
1191
 
1113
1192
  // Legacy config path — normalize "sqlserver" to "mssql"
@@ -1132,8 +1211,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1132
1211
  case "sqlite": {
1133
1212
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
1134
1213
  const adapter = new SQLiteAdapter(config?.path ?? "./data/tina4.db");
1135
- setAdapter(adapter);
1136
- return new Database(adapter);
1214
+ return new Database(setAdapter(adapter));
1137
1215
  }
1138
1216
  case "postgres": {
1139
1217
  const { PostgresAdapter } = await import("./adapters/postgres.js");
@@ -1145,8 +1223,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1145
1223
  database: config?.database,
1146
1224
  });
1147
1225
  await adapter.connect();
1148
- setAdapter(adapter);
1149
- return new Database(adapter);
1226
+ return new Database(setAdapter(adapter));
1150
1227
  }
1151
1228
  case "mysql": {
1152
1229
  const { MysqlAdapter } = await import("./adapters/mysql.js");
@@ -1158,8 +1235,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1158
1235
  database: config?.database,
1159
1236
  });
1160
1237
  await adapter.connect();
1161
- setAdapter(adapter);
1162
- return new Database(adapter);
1238
+ return new Database(setAdapter(adapter));
1163
1239
  }
1164
1240
  case "mssql": {
1165
1241
  const { MssqlAdapter } = await import("./adapters/mssql.js");
@@ -1171,8 +1247,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1171
1247
  database: config?.database,
1172
1248
  });
1173
1249
  await adapter.connect();
1174
- setAdapter(adapter);
1175
- return new Database(adapter);
1250
+ return new Database(setAdapter(adapter));
1176
1251
  }
1177
1252
  case "firebird": {
1178
1253
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
@@ -1184,8 +1259,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1184
1259
  database: config?.database,
1185
1260
  });
1186
1261
  await adapter.connect();
1187
- setAdapter(adapter);
1188
- return new Database(adapter);
1262
+ return new Database(setAdapter(adapter));
1189
1263
  }
1190
1264
  case "mongodb": {
1191
1265
  const { MongodbAdapter } = await import("./adapters/mongodb.js");
@@ -1198,16 +1272,14 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1198
1272
  const connectionString = `mongodb://${creds}${host}:${port}/${database}`;
1199
1273
  const adapter = new MongodbAdapter(connectionString);
1200
1274
  await adapter.connect();
1201
- setAdapter(adapter);
1202
- return new Database(adapter);
1275
+ return new Database(setAdapter(adapter));
1203
1276
  }
1204
1277
  case "odbc": {
1205
1278
  const { OdbcAdapter } = await import("./adapters/odbc.js");
1206
1279
  const connStr = config?.connectionString ?? config?.url?.replace(/^odbc:\/\/\//, "") ?? "";
1207
1280
  const adapter = new OdbcAdapter({ connectionString: connStr });
1208
1281
  await adapter.connect();
1209
- setAdapter(adapter);
1210
- return new Database(adapter);
1282
+ return new Database(setAdapter(adapter));
1211
1283
  }
1212
1284
  default:
1213
1285
  throw new Error(`Unknown database type: ${type}`);
@@ -14,7 +14,7 @@ export { FetchResult } from "./types.js";
14
14
 
15
15
  export { DatabaseResult } from "./databaseResult.js";
16
16
  export type { ColumnInfoResult } from "./databaseResult.js";
17
- export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons } from "./database.js";
17
+ export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons, wrapWithCache, resetRequestCaches } from "./database.js";
18
18
  export {
19
19
  adapterFetch, adapterQuery, adapterFetchOne, adapterExecute,
20
20
  adapterStartTransaction, adapterCommit, adapterRollback,
@@ -49,6 +49,7 @@ export { BaseModel, snakeToCamel, camelToSnake } from "./baseModel.js";
49
49
  export { QueryBuilder } from "./queryBuilder.js";
50
50
  export { SQLTranslator, QueryCache } from "./sqlTranslation.js";
51
51
  export { CachedDatabaseAdapter } from "./cachedDatabase.js";
52
+ export type { CachedAdapterOptions } from "./cachedDatabase.js";
52
53
  export { FakeData } from "./fakeData.js";
53
54
  export { seedTable, seedOrm } from "./seeder.js";
54
55