tina4-nodejs 3.13.11 → 3.13.14
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 +2 -2
- package/package.json +1 -1
- package/packages/core/src/logger.ts +21 -8
- package/packages/core/src/middleware.ts +24 -3
- package/packages/orm/src/adapters/mssql.ts +12 -4
- package/packages/orm/src/adapters/mysql.ts +13 -4
- package/packages/orm/src/adapters/postgres.ts +18 -8
- package/packages/orm/src/adapters/sqlite.ts +22 -2
- package/packages/orm/src/database.ts +25 -0
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/sqlTranslation.ts +16 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.14)
|
|
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.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.14 — 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
|
|
package/package.json
CHANGED
|
@@ -190,7 +190,9 @@ export class Log {
|
|
|
190
190
|
rotateKeep = isNaN(n) || n < 1 ? DEFAULT_ROTATE_KEEP : n;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
// v3.13.14: default level INFO (was DEBUG) — parity with Python/PHP/Ruby;
|
|
194
|
+
// surfaces request/startup/warn/error without debug noise in deploys.
|
|
195
|
+
const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "INFO").toUpperCase();
|
|
194
196
|
const minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
|
|
195
197
|
|
|
196
198
|
const fmt = (process.env.TINA4_LOG_FORMAT ?? "text").trim().toLowerCase();
|
|
@@ -385,16 +387,27 @@ export class Log {
|
|
|
385
387
|
const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
|
|
386
388
|
const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart}${fnPart} ${message}${dataPart}`;
|
|
387
389
|
|
|
388
|
-
// Build the file-format line
|
|
389
|
-
|
|
390
|
+
// Build the file-format line. v3.13.14: production always emits JSON
|
|
391
|
+
// (parity with Python/Ruby) so log aggregators get structured lines;
|
|
392
|
+
// TINA4_LOG_FORMAT=json forces it in dev too.
|
|
393
|
+
const fileLine =
|
|
394
|
+
cfg.format === "json" || Log.isProduction() ? JSON.stringify(entry) : humanLine;
|
|
390
395
|
|
|
391
396
|
const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= cfg.minLevel;
|
|
392
397
|
|
|
393
|
-
// Console output.
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
+
// Console output. v3.13.14: stdout is NOT suppressed in production —
|
|
399
|
+
// containers read PID 1 stdout (docker logs / k8s) and the old
|
|
400
|
+
// `!isProduction()` gate meant deployed apps logged nothing. In
|
|
401
|
+
// production we print the clean structured line (JSON, no ANSI) so it
|
|
402
|
+
// stays parseable; in dev we keep the coloured human-readable line.
|
|
403
|
+
// TINA4_LOG_OUTPUT="file" still opts out of stdout entirely.
|
|
404
|
+
if (shouldLog && cfg.output !== "file") {
|
|
405
|
+
if (Log.isProduction()) {
|
|
406
|
+
console.log(fileLine);
|
|
407
|
+
} else {
|
|
408
|
+
const color = COLORS[level];
|
|
409
|
+
console.log(`${color}${humanLine}${RESET}`);
|
|
410
|
+
}
|
|
398
411
|
}
|
|
399
412
|
|
|
400
413
|
// File output: always teed for dev (legacy behaviour), and either always
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
2
2
|
import { validToken, getPayload } from "./auth.js";
|
|
3
|
+
import { Log } from "./logger.js";
|
|
4
|
+
import { isTruthy } from "./dotenv.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether to emit a per-request log line (v3.13.14). TINA4_LOG_REQUESTS is
|
|
8
|
+
* the explicit control (true/false); when unset, request logging follows
|
|
9
|
+
* dev mode (on under TINA4_DEBUG, off in production). Same contract across
|
|
10
|
+
* all four frameworks.
|
|
11
|
+
*/
|
|
12
|
+
function requestLoggingEnabled(): boolean {
|
|
13
|
+
const val = process.env.TINA4_LOG_REQUESTS;
|
|
14
|
+
if (val !== undefined && val !== "") return isTruthy(val);
|
|
15
|
+
return isTruthy(process.env.TINA4_DEBUG);
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
export class MiddlewareChain {
|
|
5
19
|
private middlewares: Middleware[] = [];
|
|
@@ -588,18 +602,25 @@ export class CsrfMiddleware {
|
|
|
588
602
|
}
|
|
589
603
|
}
|
|
590
604
|
|
|
591
|
-
// Built-in request logger middleware
|
|
605
|
+
// Built-in request logger middleware.
|
|
606
|
+
//
|
|
607
|
+
// v3.13.14: routes through the Tina4 Log (was a bare console.log) so the
|
|
608
|
+
// line gets the same timestamp/level treatment as every other log — human
|
|
609
|
+
// in dev, structured JSON in production — and is gated by
|
|
610
|
+
// requestLoggingEnabled() (on by default in dev, opt-in in prod via
|
|
611
|
+
// TINA4_LOG_REQUESTS). Line format matches Python/PHP/Ruby:
|
|
612
|
+
// METHOD /path -> STATUS (Nms)
|
|
592
613
|
export function requestLogger(): Middleware {
|
|
593
614
|
return (req, res, next) => {
|
|
594
615
|
const start = Date.now();
|
|
595
616
|
|
|
596
617
|
res.raw.on("finish", () => {
|
|
618
|
+
if (!requestLoggingEnabled()) return;
|
|
597
619
|
const duration = Date.now() - start;
|
|
598
620
|
const status = res.raw.statusCode;
|
|
599
621
|
const method = req.method ?? "?";
|
|
600
622
|
const url = req.url ?? "/";
|
|
601
|
-
|
|
602
|
-
console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
|
|
623
|
+
Log.info(`${method} ${url} -> ${status} (${duration}ms)`);
|
|
603
624
|
});
|
|
604
625
|
|
|
605
626
|
next();
|
|
@@ -344,14 +344,18 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
347
|
+
// v3.13.14 (#48): honour a schema-qualified name ("dbo.widget"); a bare
|
|
348
|
+
// name matches in any schema (NULL guard skips the schema filter).
|
|
349
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
347
350
|
const rows = await this.queryAsync<{
|
|
348
351
|
COLUMN_NAME: string;
|
|
349
352
|
DATA_TYPE: string;
|
|
350
353
|
IS_NULLABLE: string;
|
|
351
354
|
COLUMN_DEFAULT: string | null;
|
|
352
355
|
}>(
|
|
353
|
-
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS
|
|
354
|
-
|
|
356
|
+
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS " +
|
|
357
|
+
"WHERE TABLE_NAME = ? AND (? IS NULL OR TABLE_SCHEMA = ?)",
|
|
358
|
+
[tbl, schema, schema],
|
|
355
359
|
);
|
|
356
360
|
return rows.map((r) => ({
|
|
357
361
|
name: r.COLUMN_NAME,
|
|
@@ -378,9 +382,13 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
378
382
|
}
|
|
379
383
|
|
|
380
384
|
async tableExistsAsync(name: string): Promise<boolean> {
|
|
385
|
+
// v3.13.14 (#48): honour a schema-qualified name ("dbo.widget"); a bare
|
|
386
|
+
// name matches in any schema (NULL guard skips the schema filter).
|
|
387
|
+
const [schema, tbl] = SQLTranslator.splitSchema(name);
|
|
381
388
|
const rows = await this.queryAsync<{ cnt: number }>(
|
|
382
|
-
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES
|
|
383
|
-
|
|
389
|
+
"SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES " +
|
|
390
|
+
"WHERE TABLE_NAME = ? AND (? IS NULL OR TABLE_SCHEMA = ?)",
|
|
391
|
+
[tbl, schema, schema],
|
|
384
392
|
);
|
|
385
393
|
return (rows[0]?.cnt ?? 0) > 0;
|
|
386
394
|
}
|
|
@@ -262,13 +262,17 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
265
|
+
// v3.13.14 (#48): a qualified name ("db.table") must back-quote each part
|
|
266
|
+
// separately, otherwise the dot is read as part of one identifier.
|
|
267
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
268
|
+
const target = schema ? `\`${schema}\`.\`${tbl}\`` : `\`${tbl}\``;
|
|
265
269
|
const rows = await this.queryAsync<{
|
|
266
270
|
Field: string;
|
|
267
271
|
Type: string;
|
|
268
272
|
Null: string;
|
|
269
273
|
Default: string | null;
|
|
270
274
|
Key: string;
|
|
271
|
-
}>(`DESCRIBE
|
|
275
|
+
}>(`DESCRIBE ${target}`);
|
|
272
276
|
return rows.map((r) => ({
|
|
273
277
|
name: r.Field,
|
|
274
278
|
type: r.Type,
|
|
@@ -294,9 +298,14 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
294
298
|
}
|
|
295
299
|
|
|
296
300
|
async tableExistsAsync(name: string): Promise<boolean> {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
301
|
+
// v3.13.14 (#48): MySQL's "schema" is the database. A qualified name
|
|
302
|
+
// ("otherdb.table") checks that catalog; a bare name defaults to the
|
|
303
|
+
// connection's current database via DATABASE().
|
|
304
|
+
const [schema, tbl] = SQLTranslator.splitSchema(name);
|
|
305
|
+
const rows = await this.queryAsync<Record<string, unknown>>(
|
|
306
|
+
"SELECT 1 FROM information_schema.tables " +
|
|
307
|
+
"WHERE table_schema = COALESCE(?, DATABASE()) AND table_name = ?",
|
|
308
|
+
[schema, tbl],
|
|
300
309
|
);
|
|
301
310
|
return rows.length > 0;
|
|
302
311
|
}
|
|
@@ -249,10 +249,16 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
async tablesAsync(): Promise<string[]> {
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// v3.13.14 (#48): list every user schema; public tables stay bare, others
|
|
253
|
+
// are returned schema-qualified.
|
|
254
|
+
const rows = await this.queryAsync<{ schemaname: string; tablename: string }>(
|
|
255
|
+
"SELECT schemaname, tablename FROM pg_tables " +
|
|
256
|
+
"WHERE schemaname NOT IN ('pg_catalog', 'information_schema') " +
|
|
257
|
+
"ORDER BY schemaname, tablename",
|
|
258
|
+
);
|
|
259
|
+
return rows.map((r) =>
|
|
260
|
+
r.schemaname === "public" ? r.tablename : `${r.schemaname}.${r.tablename}`,
|
|
254
261
|
);
|
|
255
|
-
return rows.map((r) => r.tablename);
|
|
256
262
|
}
|
|
257
263
|
|
|
258
264
|
columns(table: string): ColumnInfo[] {
|
|
@@ -260,14 +266,16 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
260
266
|
}
|
|
261
267
|
|
|
262
268
|
async columnsAsync(table: string): Promise<ColumnInfo[]> {
|
|
269
|
+
// v3.13.14 (#48): honour a schema-qualified name; default to public.
|
|
270
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
263
271
|
const rows = await this.queryAsync<{
|
|
264
272
|
column_name: string;
|
|
265
273
|
data_type: string;
|
|
266
274
|
is_nullable: string;
|
|
267
275
|
column_default: string | null;
|
|
268
276
|
}>(
|
|
269
|
-
"SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1",
|
|
270
|
-
[
|
|
277
|
+
"SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND table_schema = $2",
|
|
278
|
+
[tbl, schema ?? "public"],
|
|
271
279
|
);
|
|
272
280
|
return rows.map((r) => ({
|
|
273
281
|
name: r.column_name,
|
|
@@ -294,11 +302,13 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
294
302
|
}
|
|
295
303
|
|
|
296
304
|
async tableExistsAsync(name: string): Promise<boolean> {
|
|
297
|
-
|
|
298
|
-
|
|
305
|
+
// v3.13.14 (#48): to_regclass resolves a (possibly schema-qualified)
|
|
306
|
+
// relation name and search_path like a FROM clause; null if absent.
|
|
307
|
+
const row = await this.fetchOneAsync<{ oid: string | null }>(
|
|
308
|
+
"SELECT to_regclass($1) AS oid",
|
|
299
309
|
[name],
|
|
300
310
|
);
|
|
301
|
-
return row?.
|
|
311
|
+
return (row?.oid ?? null) !== null;
|
|
302
312
|
}
|
|
303
313
|
|
|
304
314
|
createTable(name: string, columns: Record<string, FieldDefinition>): void {
|
|
@@ -2,6 +2,12 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
|
|
5
|
+
import { SQLTranslator } from "../sqlTranslation.js";
|
|
6
|
+
|
|
7
|
+
/** A safe-to-interpolate SQL identifier (no quoting/escaping needed). */
|
|
8
|
+
function isIdentifier(str: string): boolean {
|
|
9
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(str);
|
|
10
|
+
}
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Resolve a SQLite path argument against the project root (cwd).
|
|
@@ -209,7 +215,14 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
209
215
|
}
|
|
210
216
|
|
|
211
217
|
columns(table: string): ColumnInfo[] {
|
|
212
|
-
|
|
218
|
+
// v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
|
|
219
|
+
// PRAGMA accepts a schema prefix when both parts are plain identifiers.
|
|
220
|
+
const [schema, tbl] = SQLTranslator.splitSchema(table);
|
|
221
|
+
const pragma =
|
|
222
|
+
schema && isIdentifier(schema) && isIdentifier(tbl)
|
|
223
|
+
? `PRAGMA ${schema}.table_info("${tbl}")`
|
|
224
|
+
: `PRAGMA table_info("${table}")`;
|
|
225
|
+
const rows = this.db.prepare(pragma).all() as Array<{
|
|
213
226
|
name: string; type: string; notnull: number; dflt_value: unknown; pk: number;
|
|
214
227
|
}>;
|
|
215
228
|
return rows.map((r) => ({
|
|
@@ -221,7 +234,14 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
221
234
|
close(): void { this.db.close(); }
|
|
222
235
|
|
|
223
236
|
tableExists(name: string): boolean {
|
|
224
|
-
|
|
237
|
+
// v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
|
|
238
|
+
// Query that database's own sqlite_master when the prefix is a plain
|
|
239
|
+
// identifier; otherwise treat the whole string as a bare table name.
|
|
240
|
+
const [schema, tbl] = SQLTranslator.splitSchema(name);
|
|
241
|
+
const master = schema && isIdentifier(schema) ? `${schema}.sqlite_master` : "sqlite_master";
|
|
242
|
+
const result = this.db
|
|
243
|
+
.prepare(`SELECT name FROM ${master} WHERE type='table' AND name=?`)
|
|
244
|
+
.get(tbl);
|
|
225
245
|
return !!result;
|
|
226
246
|
}
|
|
227
247
|
|
|
@@ -2,6 +2,26 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
2
2
|
import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult } from "./types.js";
|
|
3
3
|
import { DatabaseResult } from "./databaseResult.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* v3.13.12 — strip trailing `;` and whitespace from user-supplied SQL
|
|
7
|
+
* before the framework wraps it with COUNT(*) subqueries or appends
|
|
8
|
+
* LIMIT/OFFSET clauses. Without this, `"SELECT * FROM t;"` becomes
|
|
9
|
+
* `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error on every
|
|
10
|
+
* engine. Internal semicolons (in string literals, between meaningful
|
|
11
|
+
* statements) are left alone; drivers reject those if the engine
|
|
12
|
+
* doesn't support multi-statement.
|
|
13
|
+
*
|
|
14
|
+
* Exported so adapters and external tooling can compose it.
|
|
15
|
+
*/
|
|
16
|
+
export function stripTrailingSemicolons(sql: string): string {
|
|
17
|
+
if (!sql) return sql;
|
|
18
|
+
let stripped = sql.replace(/\s+$/, "");
|
|
19
|
+
while (stripped.endsWith(";")) {
|
|
20
|
+
stripped = stripped.slice(0, -1).replace(/\s+$/, "");
|
|
21
|
+
}
|
|
22
|
+
return stripped;
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
let activeAdapter: DatabaseAdapter | null = null;
|
|
6
26
|
const namedAdapters: Map<string, DatabaseAdapter> = new Map();
|
|
7
27
|
|
|
@@ -404,6 +424,10 @@ export class Database {
|
|
|
404
424
|
|
|
405
425
|
/** Query rows with optional pagination. Returns a DatabaseResult wrapper. */
|
|
406
426
|
fetch(sql: string, params?: unknown[], limit?: number, offset?: number): DatabaseResult {
|
|
427
|
+
// v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
|
|
428
|
+
// or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
|
|
429
|
+
// `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
|
|
430
|
+
sql = stripTrailingSemicolons(sql);
|
|
407
431
|
const adapter = this.getNextAdapter();
|
|
408
432
|
const rows = adapter.fetch<Record<string, unknown>>(sql, params, limit, offset);
|
|
409
433
|
return new DatabaseResult(rows, undefined, undefined, limit, offset, adapter, sql);
|
|
@@ -411,6 +435,7 @@ export class Database {
|
|
|
411
435
|
|
|
412
436
|
/** Fetch a single row or null. */
|
|
413
437
|
fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
|
|
438
|
+
sql = stripTrailingSemicolons(sql);
|
|
414
439
|
return this.getNextAdapter().fetchOne<T>(sql, params);
|
|
415
440
|
}
|
|
416
441
|
|
|
@@ -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, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool } from "./database.js";
|
|
17
|
+
export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons } from "./database.js";
|
|
18
18
|
export type { DatabaseConfig, ParsedDatabaseUrl } from "./database.js";
|
|
19
19
|
export { discoverModels } from "./model.js";
|
|
20
20
|
export type { DiscoveredModel } from "./model.js";
|
|
@@ -156,6 +156,22 @@ export class SQLTranslator {
|
|
|
156
156
|
columns,
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* v3.13.14 (#48): split a possibly-qualified table name into [schema, table].
|
|
162
|
+
*
|
|
163
|
+
* A model whose table name is qualified — PostgreSQL "gift_cards.gift_card",
|
|
164
|
+
* MSSQL "dbo.widget", MySQL "otherdb.table", SQLite "attached.table" — lives
|
|
165
|
+
* in that schema/catalog, not the default. Adapters use this so tableExists /
|
|
166
|
+
* getColumns query the right namespace instead of matching the whole dotted
|
|
167
|
+
* string as one flat name. Returns [null, name] for a bare name. Splits on the
|
|
168
|
+
* first dot. Firebird has no schemas, so its adapter ignores this.
|
|
169
|
+
*/
|
|
170
|
+
static splitSchema(name: string): [string | null, string] {
|
|
171
|
+
const idx = name.indexOf(".");
|
|
172
|
+
if (idx === -1) return [null, name];
|
|
173
|
+
return [name.slice(0, idx), name.slice(idx + 1)];
|
|
174
|
+
}
|
|
159
175
|
}
|
|
160
176
|
|
|
161
177
|
// ── Query Cache ──────────────────────────────────────────────
|