/** * Tina4 Cached Database — Transparent query cache decorator for DatabaseAdapter. * * Wraps any DatabaseAdapter and caches SELECT results from fetch() and fetchOne() * (plus their *Async variants). Write operations (insert, update, delete, execute, * createTable, addColumn) flush the entire cache when caching is enabled. * * One store, two layers (mirrors the Python master — tina4_python/database/connection.py): * * • request-scoped (DEFAULT OFF, opt-in TINA4_AUTO_CACHING=true) — dedupes * identical SELECTs to protect the DB from rapid repeat reads. Cleared at the * START of every HTTP request (via Database.resetRequestCaches()) AND on any * write, with a short safety TTL (TINA4_AUTO_CACHING_TTL, default 5s) for * non-request contexts (scripts/workers). Default OFF because a request-scoped * cache defaulting ON is a footgun — a read-after-write in one request (e.g. * SELECT MAX(id) then INSERT) returns a cached pre-write value. Opt in for * read-heavy endpoints. * • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache that is * NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL (default 30s). * * enabled = persistent || requestScoped * mode = persistent ? "persistent" : (requestScoped ? "request" : "off") * ttl = persistent ? 30 : 5 (env-overridable) * * Usage (the framework wires this automatically at the adapter bind path): * import { CachedDatabaseAdapter } from "@tina4/orm"; * import { SQLiteAdapter } from "./adapters/sqlite.js"; * * const raw = new SQLiteAdapter("./data/app.db"); * const db = new CachedDatabaseAdapter(raw); * db.fetch("SELECT * FROM users"); // cached on second call * db.cacheStats(); // { enabled, mode, hits, misses, size, ttl } */ import { QueryCache } from "./sqlTranslator.js"; import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "./types.js"; /** * Options for wrapping an adapter with a query cache. When several pooled * connections must share one cache store (so a write on any connection * invalidates reads cached by all of them), pass the same `sharedCache`. */ export interface CachedAdapterOptions { /** Force-enable the persistent (cross-request) layer. Defaults to TINA4_DB_CACHE. */ persistent?: boolean; /** Force-enable the request-scoped layer. Defaults to TINA4_AUTO_CACHING (default false / opt-in). */ requestScoped?: boolean; /** Override the effective TTL (seconds). Defaults to the mode-appropriate env var. */ ttl?: number; /** Share a single QueryCache store across multiple wrappers (pooled connections). */ sharedCache?: QueryCache; } export declare class CachedDatabaseAdapter implements DatabaseAdapter { /** * Live wrappers, so the request dispatcher can clear the request-scoped cache * on every connection at the start of each HTTP request. Mirrors Python's * `Database._instances` WeakSet. A WeakSet lets closed connections be GC'd. */ private static instances; private adapter; private cache; /** Persistent (cross-request) layer — TINA4_DB_CACHE. */ private cachePersistent; /** Request-scoped layer — TINA4_AUTO_CACHING (default OFF / opt-in). */ private cacheRequestScoped; private enabled; private ttl; private hits; private misses; /** * Persistent-mode distributed backend (TINA4_DB_CACHE=true). Built lazily from * the SAME unified `createBackend()` factory the response/KV cache uses, so * multiple Database instances share one cache with global write-invalidation * (parity with Python's connection.py, which routes the persistent DB cache * through `_create_backend`). The read path (`fetchAsync`/`fetchOneAsync`/ * `queryAsync`) is async, so the backend's async get/set work directly — no * sync-path restriction. Request-scoped mode keeps the in-process QueryCache * above (ephemeral, fastest, never serialized). * * `null` until the first async read builds it; a `memory` backend (the * default) means the persistent layer behaves in-process exactly as before, so * default behaviour is unchanged and only an explicit redis/etc. backend * distributes. */ private backend; private backendPromise; private backendName; /** * WHICH DATABASE this wrapper caches for, folded into every cache key. * Empty only for an adapter built outside the URL/config funnels, which then * behaves exactly as before rather than colliding with a tagged one. * * Optional-chained on the ADAPTER, not just the property. `setAdapter(null)` * is the documented reset idiom (migrateCli.test.ts uses it to clear ORM * state between cases) and it reaches here through wrapWithCache. Before the * identity field existed the constructor only STORED the adapter, so a null * passed through harmlessly; reading `adapter.cacheIdentity` turned that * reset into "Cannot read properties of null". */ private readonly identity; constructor(adapter: DatabaseAdapter, options?: CachedAdapterOptions); /** * Whether the persistent layer should use a distributed/serialised backend. * For the default `memory` backend we keep the in-process QueryCache (fast, * no serialisation) so behaviour is identical to before; only an explicit * non-memory backend (redis/valkey/memcached/mongodb/database/file) routes * through the unified async backend for cross-instance sharing. */ private usesPersistentBackend; /** Lazily build (and memoise) the persistent backend via createBackend(). */ private getBackend; /** Current cache mode: "persistent" | "request" | "off". */ cacheMode(): "persistent" | "request" | "off"; /** Whether either cache layer is active. */ cacheEnabled(): boolean; /** * Clear the request-scoped cache at the start of an HTTP request. * No-op in persistent mode (cross-request entries survive to their TTL). * Cumulative hit/miss counters are preserved. Mirrors Python's * `Database.cache_new_request()`. */ cacheNewRequest(): void; /** * Clear the request-scoped cache on every live wrapper. The request * dispatcher calls this at the start of each HTTP request so request-scoped * caching never serves rows across requests. Persistent-mode connections are * left alone. Mirrors Python's `Database.reset_request_caches()` classmethod. */ static resetRequestCaches(): void; cacheStats(): { enabled: boolean; mode: "persistent" | "request" | "off"; hits: number; misses: number; size: number; ttl: number; backend?: string; }; /** Flush the query cache and reset counters. Mirrors Python `cache_clear()`. */ cacheClear(): void; /** Clear the entire query cache (called on writes). */ private invalidate; /** Async write-invalidation — awaits the distributed backend clear. */ private invalidateAsync; private backendGetRows; private backendSetRows; private backendGetOne; private backendSetOne; /** ADR-0044 required capability — delegates to the wrapped adapter. */ connect(): void | Promise; /** ADR-0044 required capability — delegates to the wrapped adapter. */ getDatabaseType(): string; /** * ADR-0044 required capability — a native boolean, readable and writable. * A getter/setter pair (not a plain field) so it genuinely delegates to the * wrapped adapter rather than drifting out of sync with its real setting. */ get autocommit(): boolean; set autocommit(value: boolean); get supportsAtomicBatch(): boolean; set supportsAtomicBatch(value: boolean); execute(sql: string, params?: unknown[]): unknown; executeMany(sql: string, paramsList: unknown[][]): import("./types.js").DatabaseResult | { totalAffected: number; lastId?: number | bigint; }; query>(sql: string, params?: unknown[]): T[]; fetch>(sql: string, params?: unknown[], limit?: number, skip?: number, noCache?: boolean): T[]; fetchOne>(sql: string, params?: unknown[], noCache?: boolean): T | null; insert(table: string, data: Record | Record[]): DatabaseResult; update(table: string, data: Record, filter: Record | string, params?: unknown[]): DatabaseResult; delete(table: string, filter: Record | string | Record[], params?: unknown[]): DatabaseResult; startTransaction(): void; commit(): void; rollback(): void; getTables(): string[]; getColumns(table: string): ColumnInfo[]; lastInsertId(): number | bigint | string | null; close(): void; tableExists(name: string): boolean; createTable(name: string, columns: Record): void; getTableColumns?(name: string): Array<{ name: string; type: string; }>; addColumn?(table: string, colName: string, def: FieldDefinition): void; fetchAsync>(sql: string, params?: unknown[], limit?: number, skip?: number, noCache?: boolean): Promise; fetchOneAsync>(sql: string, params?: unknown[], noCache?: boolean): Promise; queryAsync>(sql: string, params?: unknown[]): Promise; executeAsync(sql: string, params?: unknown[]): Promise; /** * ADR-0044: the async passthrough executeMany() itself was missing (unlike * its executeAsync/insertAsync siblings above), so adapterExecuteMany()'s * `(adapter as any).executeManyAsync` check found nothing on THIS wrapper * and fell through to the synchronous executeMany() below — which forwards * to the wrapped adapter's OWN sync executeMany(), the throwing "Use * executeManyAsync()" stub on every async-native adapter (Postgres/MySQL/ * MSSQL/Firebird/Mongo). Real bug, caught by executeManyFacadeTxn.test.ts. */ executeManyAsync(sql: string, paramsList: unknown[][]): Promise; insertAsync(table: string, data: Record | Record[]): Promise; updateAsync(table: string, data: Record, filter: Record | string, params?: unknown[]): Promise; deleteAsync(table: string, filter: Record | string | Record[], params?: unknown[]): Promise; startTransactionAsync(): Promise; commitAsync(): Promise; rollbackAsync(): Promise; tableExistsAsync(name: string): Promise; tablesAsync(): Promise; columnsAsync(table: string): Promise; createTableAsync(name: string, columns: Record): Promise; /** * Access the underlying (unwrapped) adapter directly. */ getAdapter(): DatabaseAdapter; }