tina4-nodejs 3.13.94 → 3.13.95

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.
Files changed (115) hide show
  1. package/CLAUDE.md +157 -28
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/packages/cli/dist/bin.js +32418 -29638
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +32364 -29501
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/dispatchPipeline.ts +285 -0
  14. package/packages/core/src/dotenv.ts +185 -40
  15. package/packages/core/src/index.ts +5 -4
  16. package/packages/core/src/logger.ts +257 -36
  17. package/packages/core/src/mcp.ts +1 -1
  18. package/packages/core/src/messenger.ts +9 -13
  19. package/packages/core/src/metrics.ts +199 -961
  20. package/packages/core/src/middleware.ts +390 -123
  21. package/packages/core/src/queue.ts +188 -32
  22. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  23. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  24. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  25. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  26. package/packages/core/src/rateLimiter.ts +10 -5
  27. package/packages/core/src/request.ts +6 -9
  28. package/packages/core/src/response.ts +46 -1
  29. package/packages/core/src/router.ts +29 -4
  30. package/packages/core/src/server.ts +751 -414
  31. package/packages/core/src/session.ts +244 -27
  32. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  33. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  34. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  36. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  37. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  38. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  39. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  40. package/packages/core/src/testClient.ts +18 -5
  41. package/packages/core/src/trustedProxy.ts +249 -0
  42. package/packages/core/src/types.ts +29 -5
  43. package/packages/core/src/websocket.ts +66 -0
  44. package/packages/orm/dist/index.js +22367 -19504
  45. package/packages/orm/src/adapters/firebird.ts +183 -56
  46. package/packages/orm/src/adapters/mongodb.ts +25 -4
  47. package/packages/orm/src/adapters/mssql.ts +114 -29
  48. package/packages/orm/src/adapters/mysql.ts +103 -40
  49. package/packages/orm/src/adapters/odbc.ts +44 -21
  50. package/packages/orm/src/adapters/postgres.ts +118 -26
  51. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  52. package/packages/orm/src/adapters/sqlite.ts +60 -24
  53. package/packages/orm/src/baseModel.ts +135 -40
  54. package/packages/orm/src/cachedDatabase.ts +43 -19
  55. package/packages/orm/src/connectTimeout.ts +265 -0
  56. package/packages/orm/src/database.ts +237 -197
  57. package/packages/orm/src/databaseResult.ts +65 -13
  58. package/packages/orm/src/databaseUrl.ts +484 -0
  59. package/packages/orm/src/docstore.ts +386 -145
  60. package/packages/orm/src/index.ts +13 -3
  61. package/packages/orm/src/migration.ts +18 -3
  62. package/packages/orm/src/queryBuilder.ts +38 -4
  63. package/packages/orm/src/sqlTranslator.ts +310 -4
  64. package/packages/orm/src/types.ts +15 -4
  65. package/types/core/src/ai.d.ts +1 -1
  66. package/types/core/src/auth.d.ts +28 -5
  67. package/types/core/src/background.d.ts +3 -3
  68. package/types/core/src/cache.d.ts +15 -12
  69. package/types/core/src/dispatchPipeline.d.ts +117 -0
  70. package/types/core/src/dotenv.d.ts +38 -16
  71. package/types/core/src/index.d.ts +5 -6
  72. package/types/core/src/logger.d.ts +93 -16
  73. package/types/core/src/messenger.d.ts +2 -2
  74. package/types/core/src/metrics.d.ts +25 -61
  75. package/types/core/src/middleware.d.ts +134 -11
  76. package/types/core/src/queue.d.ts +54 -5
  77. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  78. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  79. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  80. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  81. package/types/core/src/router.d.ts +14 -3
  82. package/types/core/src/server.d.ts +15 -0
  83. package/types/core/src/session.d.ts +87 -2
  84. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  85. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  86. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  87. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  88. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  89. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  90. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  91. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  92. package/types/core/src/trustedProxy.d.ts +44 -0
  93. package/types/core/src/types.d.ts +28 -5
  94. package/types/core/src/websocket.d.ts +26 -0
  95. package/types/orm/src/adapters/firebird.d.ts +55 -10
  96. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  97. package/types/orm/src/adapters/mssql.d.ts +18 -11
  98. package/types/orm/src/adapters/mysql.d.ts +11 -10
  99. package/types/orm/src/adapters/odbc.d.ts +9 -12
  100. package/types/orm/src/adapters/postgres.d.ts +11 -10
  101. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  102. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  103. package/types/orm/src/baseModel.d.ts +45 -9
  104. package/types/orm/src/cachedDatabase.d.ts +18 -5
  105. package/types/orm/src/connectTimeout.d.ts +100 -0
  106. package/types/orm/src/database.d.ts +72 -26
  107. package/types/orm/src/databaseResult.d.ts +24 -0
  108. package/types/orm/src/databaseUrl.d.ts +125 -0
  109. package/types/orm/src/docstore.d.ts +102 -43
  110. package/types/orm/src/index.d.ts +5 -2
  111. package/types/orm/src/queryBuilder.d.ts +23 -3
  112. package/types/orm/src/sqlTranslator.d.ts +126 -2
  113. package/types/orm/src/types.d.ts +14 -4
  114. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  115. package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
@@ -21,7 +21,9 @@ export {
21
21
  adapterTableExists, adapterTables, adapterColumns, adapterCreateTable,
22
22
  extractLastInsertId,
23
23
  } from "./database.js";
24
- export type { DatabaseConfig, ParsedDatabaseUrl } from "./database.js";
24
+ export type { DatabaseConfig } from "./database.js";
25
+ export { DatabaseUrl, redactCredentials } from "./databaseUrl.js";
26
+ export type { DatabaseEngine } from "./databaseUrl.js";
25
27
  export { discoverModels } from "./model.js";
26
28
  export type { DiscoveredModel } from "./model.js";
27
29
  export {
@@ -54,6 +56,14 @@ export type { ValidationError } from "./validation.js";
54
56
  export { BaseModel, snakeToCamel, camelToSnake } from "./baseModel.js";
55
57
  export { QueryBuilder } from "./queryBuilder.js";
56
58
  export { SQLTranslator, QueryCache } from "./sqlTranslator.js";
59
+ export {
60
+ DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS,
61
+ CONNECT_TIMEOUT_TOLERANCE_MS,
62
+ connectTimeoutMillis,
63
+ driverConnectTimeoutMillis,
64
+ connectTarget,
65
+ withConnectTimeout,
66
+ } from "./connectTimeout.js";
57
67
  export { CachedDatabaseAdapter } from "./cachedDatabase.js";
58
68
  export type { CachedAdapterOptions } from "./cachedDatabase.js";
59
69
  export { FakeData } from "./fakeData.js";
@@ -62,8 +72,8 @@ export type { SeedSummary, SeedOptions } from "./seeder.js";
62
72
 
63
73
  // DocStore — pymongo-style document store with a zero-config SQLite (JSON1) fallback
64
74
  export {
65
- ObjectId, InvalidId, SqliteDatabase, SqliteCollection, Cursor,
66
- getCollection, isServerless, resetDefaultStore,
75
+ ObjectId, InvalidId, DocStoreDriverMissing, SqliteDatabase, SqliteCollection, Cursor,
76
+ getCollection, isServerless, resetDefaultStore, closeDocStore,
67
77
  encodeValue, decodeValue, compileFilter,
68
78
  } from "./docstore.js";
69
79
  export type {
@@ -217,7 +217,14 @@ export async function syncModels(models: DiscoveredModel[]): Promise<void> {
217
217
  console.log(` Created table: ${tableName}`);
218
218
  } else {
219
219
  // Check for new columns. SQLite exposes the legacy getTableColumns/
220
- // addColumn helpers; other engines use columns()/ALTER TABLE.
220
+ // addColumn helpers; other engines use getColumns()/ALTER TABLE.
221
+ //
222
+ // Collapsing this into getColumns() looks obviously right and is NOT:
223
+ // it broke the legacy NOT NULL migration_id path, which is the bug that
224
+ // wedged every migration for ~20 releases (python#93). getTableColumns
225
+ // reads PRAGMA directly; getColumns goes through schema splitting and
226
+ // does not return the same thing here. Removing it needs its own change
227
+ // with that path tested, not a drive-by in an interface tidy-up.
221
228
  const existingCols = (adapter as any).getTableColumns
222
229
  ? (adapter as SQLiteAdapter).getTableColumns(tableName)
223
230
  : await adapterColumns(adapter, tableName);
@@ -508,8 +515,16 @@ async function recordApplied(
508
515
  */
509
516
  async function trackingColumns(db: DatabaseAdapter): Promise<Set<string>> {
510
517
  try {
511
- // The DatabaseAdapter contract exposes columns(), not getColumns().
512
- const cols = await (db as any).columns?.(MIGRATION_TABLE);
518
+ // The DatabaseAdapter contract exposes getColumns() (feature 3: renamed from
519
+ // columns() to match the other three frameworks and the get- prefix used
520
+ // everywhere else).
521
+ //
522
+ // This reads through `as any`, so the compiler could not catch the rename
523
+ // here - it went silently to undefined, `cols` came back empty, migration_id
524
+ // was left out of the insert and every migration failed on the legacy
525
+ // NOT NULL column. Exactly the failure python#93 caused, from the opposite
526
+ // direction. Kept optional-chained for adapters that predate the contract.
527
+ const cols = await (db as any).getColumns?.(MIGRATION_TABLE);
513
528
  if (!Array.isArray(cols)) return new Set();
514
529
  return new Set(
515
530
  cols.map((c: any) => String(c?.name ?? c ?? "").toLowerCase()).filter(Boolean),
@@ -19,6 +19,7 @@
19
19
 
20
20
  import type { DatabaseAdapter } from "./types.js";
21
21
  import { getAdapter, adapterFetch, adapterFetchOne } from "./database.js";
22
+ import { DatabaseResult } from "./databaseResult.js";
22
23
 
23
24
  export class QueryBuilder {
24
25
  private table: string;
@@ -198,22 +199,55 @@ export class QueryBuilder {
198
199
  }
199
200
 
200
201
  /**
201
- * Execute the query and return all matching rows.
202
+ * Execute the query and return a DatabaseResult.
202
203
  *
203
- * @returns Array of row objects.
204
+ * BREAKING (3.13.95, parity): this returned a bare array of rows. The other
205
+ * three frameworks all return the DatabaseResult that `db.fetch()` produces:
206
+ * Python get() -> DatabaseResult (orm/query_builder/__init__.py)
207
+ * PHP get(): mixed -> $this->db->fetch(...)
208
+ * Ruby get -> @db.fetch(...)
209
+ * Node was the odd one out, so the same builder chain returned a different
210
+ * TYPE per language and portable code could not read `.records`, `.count`,
211
+ * `.limit` or `.offset` off it.
212
+ *
213
+ * MIGRATION: read `.records` for the rows.
214
+ * before: const rows = await qb.get(); rows.length
215
+ * after: const result = await qb.get(); result.records.length
216
+ * DatabaseResult is iterable, so `for (const row of result)` and
217
+ * `[...result]` work unchanged, and `response()`/`res.json()` already
218
+ * auto-serialize it to a JSON array.
219
+ *
220
+ * No default LIMIT is applied when `.limit()` was never called (v3.13.39) --
221
+ * a silent cap here was a data-loss-on-read footgun. That is unchanged.
222
+ *
223
+ * @returns DatabaseResult carrying `.records`, `.count`, `.limit`, `.offset`.
204
224
  */
205
- async get<T = Record<string, unknown>>(): Promise<T[]> {
225
+ async get(): Promise<DatabaseResult> {
206
226
  this.ensureDb();
207
227
  const sql = this.toSql();
208
228
  const allParams = [...this.params, ...this.havingParams];
209
229
 
210
- return adapterFetch<T>(
230
+ const rows = await adapterFetch(
211
231
  this.db!,
212
232
  sql,
213
233
  allParams.length > 0 ? allParams : undefined,
214
234
  this.limitVal,
215
235
  this.offsetVal,
216
236
  );
237
+
238
+ // Constructed exactly as Database._fetchWithLimit does, so a QueryBuilder
239
+ // result and a db.fetch() result are the same object in the same state --
240
+ // including leaving `count` to default to the row count and handing the
241
+ // adapter + sql through.
242
+ return new DatabaseResult(
243
+ rows as Record<string, unknown>[],
244
+ undefined,
245
+ undefined,
246
+ this.limitVal,
247
+ this.offsetVal,
248
+ this.db!,
249
+ sql,
250
+ );
217
251
  }
218
252
 
219
253
  /**
@@ -19,6 +19,7 @@
19
19
 
20
20
  // ── SQL Translator ───────────────────────────────────────────
21
21
 
22
+ import { DatabaseUrl } from "./databaseUrl.js";
22
23
  export class SQLTranslator {
23
24
  /**
24
25
  * Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
@@ -172,6 +173,271 @@ export class SQLTranslator {
172
173
  if (idx === -1) return [null, name];
173
174
  return [name.slice(0, idx), name.slice(idx + 1)];
174
175
  }
176
+
177
+ /**
178
+ * Hard per-statement bind-parameter ceiling per engine. 0 = never collapse.
179
+ * Sourced from test/fixtures/batch_write_contract.json, byte-identical in all
180
+ * four frameworks.
181
+ */
182
+ static readonly MAX_BIND_PARAMS: Record<string, number> = {
183
+ sqlite: 999,
184
+ postgres: 65535,
185
+ mysql: 65535,
186
+ mssql: 2100,
187
+ firebird: 0,
188
+ odbc: 0,
189
+ mongodb: 0,
190
+ };
191
+
192
+ /**
193
+ * The four frameworks do not agree on what an engine calls itself — Python
194
+ * and PHP report "postgresql", Ruby and Node report "postgres". Without
195
+ * normalising, the cap lookup misses and the collapse silently does nothing
196
+ * on the engine with the largest win.
197
+ */
198
+ static readonly ENGINE_ALIASES: Record<string, string> = {
199
+ postgresql: "postgres",
200
+ pgsql: "postgres",
201
+ sqlite3: "sqlite",
202
+ sqlserver: "mssql",
203
+ sqlsrv: "mssql",
204
+ mariadb: "mysql",
205
+ };
206
+
207
+ // The `d` flag records group indices, so the head can be sliced at the exact
208
+ // start of the VALUES group rather than by hunting for a parenthesis (the
209
+ // column list has parentheses too).
210
+ private static readonly INSERT_VALUES =
211
+ /^\s*INSERT\s+INTO\s+.+?\s+VALUES\s*\(([^()]*)\)\s*$/dis;
212
+
213
+ /**
214
+ * Engines whose lastInsertId reports the FIRST generated id of a multi-row
215
+ * INSERT rather than the last. Verified live, not assumed: a 3-row insert
216
+ * into a fresh MySQL table reports 1 while MAX(id) is 3. SQLite, PostgreSQL
217
+ * and MSSQL already report the last, so collapsing does not change them.
218
+ */
219
+ static readonly FIRST_ID_ENGINES: readonly string[] = ["mysql"];
220
+
221
+ /**
222
+ * Normalise a collapsed batch's last id to the LAST row's id.
223
+ *
224
+ * A row-at-a-time batch reports the last row's id simply because the last
225
+ * statement inserted the last row. Collapsing rows into one statement changes
226
+ * that on any engine that reports the FIRST generated id, so this restores
227
+ * the contract instead of quietly redefining it. The ids in one statement are
228
+ * consecutive, so the last is `first + rows - 1`.
229
+ */
230
+ static batchLastId(reportedId: unknown, rowsInChunk: number, engine: string): unknown {
231
+ const lower = (engine ?? "").toLowerCase();
232
+ const name = SQLTranslator.ENGINE_ALIASES[lower] ?? lower;
233
+ if (!SQLTranslator.FIRST_ID_ENGINES.includes(name)) return reportedId;
234
+
235
+ const n = typeof reportedId === "bigint" ? Number(reportedId) : Number(reportedId);
236
+ if (reportedId === null || reportedId === undefined || Number.isNaN(n)) {
237
+ return reportedId; // UUID/ULID key — no successor
238
+ }
239
+ return n + Math.max(rowsInChunk, 1) - 1;
240
+ }
241
+
242
+ /**
243
+ * Collapse a row-at-a-time INSERT batch into chunked multi-row VALUES.
244
+ *
245
+ * A batch that loops one INSERT per row pays a full network round-trip per
246
+ * row, and the round-trip — not SQL building — is the entire cost of a batch
247
+ * write. Measured over 500 rows: PostgreSQL 9848ms row-at-a-time against
248
+ * 15.8ms as a single multi-row statement (625x), MySQL 216x, MSSQL 121x.
249
+ *
250
+ * PURE: no I/O and no engine contact, so the chunking rules are checkable
251
+ * without a database. The live-engine runners prove the rows land.
252
+ *
253
+ * @returns Statements to run INSTEAD of the loop, or an EMPTY array meaning
254
+ * "not collapsible — keep looping", which is always correct.
255
+ */
256
+ static buildBatchInserts(
257
+ sql: string,
258
+ paramSets: unknown[][],
259
+ engine: string,
260
+ ): Array<[string, unknown[]]> {
261
+ const rows = paramSets ?? [];
262
+ if (rows.length < 2) return [];
263
+
264
+ const lower = (engine ?? "").toLowerCase();
265
+ const name = SQLTranslator.ENGINE_ALIASES[lower] ?? lower;
266
+ const cap = SQLTranslator.MAX_BIND_PARAMS[name] ?? 0;
267
+ // Firebird has no multi-row VALUES syntax (verified against a live 5.0.4:
268
+ // -104 Token unknown); ODBC's real ceiling depends on the driver behind it.
269
+ // Emitting SQL the engine cannot parse to save a round-trip is not a trade
270
+ // worth making.
271
+ if (cap <= 0) return [];
272
+
273
+ const upper = sql.toUpperCase();
274
+ // A collapsed statement returns N rows where the caller expects one, and
275
+ // conflict arbitration changes once rows share a statement.
276
+ if (
277
+ upper.includes("RETURNING") ||
278
+ upper.includes("ON CONFLICT") ||
279
+ upper.includes("ON DUPLICATE KEY")
280
+ ) {
281
+ return [];
282
+ }
283
+
284
+ const match = SQLTranslator.INSERT_VALUES.exec(sql);
285
+ if (match === null) return [];
286
+
287
+ // Every slot must be a bare placeholder. `now()` repeated per row inside one
288
+ // statement is not the same write as `now()` evaluated per statement.
289
+ const slots = match[1].split(",").map((s) => s.trim());
290
+ if (slots.length === 0 || slots.some((s) => s !== "?")) return [];
291
+
292
+ const columns = slots.length;
293
+ if (rows.some((params) => params.length !== columns)) return [];
294
+
295
+ const chunkRows = Math.max(1, Math.floor(cap / columns));
296
+ if (chunkRows < 2) return [];
297
+
298
+ const valuesStart = match.indices?.[1]?.[0];
299
+ if (valuesStart === undefined) return [];
300
+ const head = sql.slice(0, valuesStart - 1).trimEnd();
301
+ const oneRow = `(${new Array(columns).fill("?").join(", ")})`;
302
+
303
+ const statements: Array<[string, unknown[]]> = [];
304
+ for (let start = 0; start < rows.length; start += chunkRows) {
305
+ const chunk = rows.slice(start, start + chunkRows);
306
+ const flat: unknown[] = [];
307
+ for (const params of chunk) flat.push(...params);
308
+ statements.push([`${head} ${new Array(chunk.length).fill(oneRow).join(", ")}`, flat]);
309
+ }
310
+ return statements;
311
+ }
312
+
313
+ /**
314
+ * Blank out string literals, quoted identifiers and comments, so a keyword
315
+ * search sees only real SQL. Blanks are spaces of the SAME LENGTH (newlines
316
+ * preserved), so offsets and line structure still line up with the original.
317
+ *
318
+ * This exists because "does the caller's SQL already have a LIMIT?" used to be
319
+ * `sql.toUpperCase().split("--")[0].includes("LIMIT")`, and MEASURED on a real
320
+ * 150-row table with the 100-row cap in force, every one of these returned
321
+ * ALL 150 ROWS instead of 100:
322
+ *
323
+ * SELECT * FROM t WHERE label != 'LIMIT' ORDER BY id -- literal
324
+ * SELECT * FROM t ORDER BY id -- LIMIT 5 -- line comment
325
+ * SELECT * FROM t ORDER BY id /* LIMIT 5 *\/ -- block comment
326
+ *
327
+ * A column named `rate_limit` does it too. That is a silently UNCAPPED read of
328
+ * a whole table, which is the exact production incident the row cap exists to
329
+ * prevent, reachable through an ordinary column name.
330
+ *
331
+ * @param sql Raw SQL, exactly as the caller wrote it.
332
+ * @returns The same string with literals and comments replaced by spaces.
333
+ */
334
+ static scrubSqlText(sql: string): string {
335
+ let out = "";
336
+ let i = 0;
337
+ const blank = (ch: string): string => (ch === "\n" ? "\n" : " ");
338
+
339
+ while (i < sql.length) {
340
+ const c = sql[i];
341
+ const next = sql[i + 1];
342
+
343
+ // '...' string literal, with '' as the embedded-quote escape
344
+ if (c === "'" || c === '"') {
345
+ const quote = c;
346
+ out += " ";
347
+ i++;
348
+ while (i < sql.length) {
349
+ if (sql[i] === quote) {
350
+ if (sql[i + 1] === quote) {
351
+ out += " ";
352
+ i += 2;
353
+ continue;
354
+ }
355
+ out += " ";
356
+ i++;
357
+ break;
358
+ }
359
+ out += blank(sql[i]);
360
+ i++;
361
+ }
362
+ continue;
363
+ }
364
+
365
+ // -- line comment, to end of line
366
+ if (c === "-" && next === "-") {
367
+ while (i < sql.length && sql[i] !== "\n") {
368
+ out += " ";
369
+ i++;
370
+ }
371
+ continue;
372
+ }
373
+
374
+ // /* block comment */
375
+ if (c === "/" && next === "*") {
376
+ out += " ";
377
+ i += 2;
378
+ while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
379
+ out += blank(sql[i]);
380
+ i++;
381
+ }
382
+ if (i < sql.length) {
383
+ out += " ";
384
+ i += 2;
385
+ }
386
+ continue;
387
+ }
388
+
389
+ out += c;
390
+ i++;
391
+ }
392
+
393
+ return out;
394
+ }
395
+
396
+ /**
397
+ * True when the statement ENDS with its own LIMIT clause, so appending another
398
+ * would be wrong (and on SQLite, a syntax error).
399
+ *
400
+ * Anchored to the END on purpose. A bare "contains LIMIT" test also matches a
401
+ * LIMIT inside a subquery, where the OUTER statement still needs its cap. This
402
+ * is tina4-php's `SqlNormalizerTrait::hasTrailingLimit` regex, ported verbatim
403
+ * so all four frameworks answer identically: it accepts a numeric value, `?`,
404
+ * `$1` and `:name` placeholders, MySQL's `LIMIT a, b`, and a trailing OFFSET.
405
+ *
406
+ * @param sql Raw SQL; literals and comments are scrubbed before matching.
407
+ */
408
+ static hasTrailingLimit(sql: string): boolean {
409
+ const val = String.raw`(?:\d+|\?|\$\d+|:\w+|%s)`;
410
+ const re = new RegExp(
411
+ String.raw`\bLIMIT\s+${val}(?:\s*,\s*${val})?(?:\s+OFFSET\s+${val})?\s*;?\s*$`,
412
+ "i",
413
+ );
414
+ return re.test(SQLTranslator.scrubSqlText(sql));
415
+ }
416
+
417
+ /**
418
+ * Append `LIMIT`/`OFFSET` to a statement unless it already carries its own.
419
+ *
420
+ * The clause goes on a NEW LINE. Appending it inline is the second half of the
421
+ * same bug: `SELECT * FROM t -- note` + ` LIMIT 100` puts the clause INSIDE the
422
+ * trailing comment, where SQLite silently ignores it and the whole table comes
423
+ * back. A newline cannot be commented out by a `--` that started on the line
424
+ * above. Trailing semicolons are stripped first for the same reason
425
+ * (`SELECT * FROM t;` + `LIMIT 100` is a syntax error).
426
+ *
427
+ * @param sql The caller's statement.
428
+ * @param limit Row cap to apply; a non-positive value means "no cap".
429
+ * @param offset Rows to skip; omitted or 0 emits no OFFSET.
430
+ */
431
+ static appendLimit(sql: string, limit?: number, offset?: number): string {
432
+ if (limit === undefined || limit === null || limit <= 0) return sql;
433
+ if (SQLTranslator.hasTrailingLimit(sql)) return sql;
434
+
435
+ const trimmed = sql.replace(/[\s;]+$/, "");
436
+ const suffix = offset !== undefined && offset > 0
437
+ ? `LIMIT ${limit} OFFSET ${offset}`
438
+ : `LIMIT ${limit}`;
439
+ return `${trimmed}\n${suffix}`;
440
+ }
175
441
  }
176
442
 
177
443
  // ── Query Cache ──────────────────────────────────────────────
@@ -196,14 +462,54 @@ export class QueryCache {
196
462
  }
197
463
 
198
464
  /**
199
- * Generate a cache key from a SQL query and params.
465
+ * Stable identity of the DATABASE a cache entry came from.
466
+ *
467
+ * `engine://host:port/database` - and deliberately NOTHING else.
468
+ *
469
+ * WHY IT EXISTS: the key used to be `query:${sql}:${params}` with nothing
470
+ * naming the connection, so on any SHARED backend two databases cross-served
471
+ * each other's rows. Two apps pointed at one Redis, or one app with a primary
472
+ * and an analytics connection, silently read each other's data. Identical SQL
473
+ * text across tenants is the COMMON case, not an edge case, so the collision
474
+ * was the normal outcome.
475
+ *
476
+ * WHY NO CREDENTIALS: a password in the key means every rotation silently
477
+ * cold-starts the cache, and a shared backend's key namespace is visible to
478
+ * every tenant of that backend - a secret must never be folded into it. The
479
+ * username is out for the same reason plus a second: two connections
480
+ * differing only by role read the SAME rows and should share the entry.
481
+ *
482
+ * WHY NOTHING PER-PROCESS: no pid, no object id, no salt. Those would isolate
483
+ * the databases by ACCIDENT and destroy the point of a shared cache, because
484
+ * no instance would ever hit another instance's entry.
200
485
  */
201
- static queryKey(sql: string, params?: unknown[]): string {
486
+ static cacheIdentity(url: string): string {
487
+ try {
488
+ const parsed = new DatabaseUrl(url);
489
+ return `${parsed.engine}://${parsed.host ?? ""}:${parsed.port ?? ""}/${parsed.database}`;
490
+ } catch {
491
+ // An unparseable URL still needs a STABLE identity, and falling back to a
492
+ // constant would silently restore the cross-serving bug. The raw URL is
493
+ // stable and distinct; it is only reached for a URL the connection layer
494
+ // is about to reject anyway.
495
+ return url;
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Generate a cache key from DATABASE IDENTITY + SQL + params.
501
+ *
502
+ * The NUL separators keep the three parts from running together, so a table
503
+ * named after the tail of a database name cannot forge another database's
504
+ * key. The key is not hashed here: the only backend with a key-length limit
505
+ * is memcached, and its backend already SHA-256-hashes whatever it is given.
506
+ */
507
+ static queryKey(sql: string, params?: unknown[], identity = ""): string {
202
508
  const paramStr = params ? JSON.stringify(params) : "";
203
- // Simple hash via string combination
204
- return `query:${sql}:${paramStr}`;
509
+ return `query:${identity}\u0000${sql}\u0000${paramStr}`;
205
510
  }
206
511
 
512
+
207
513
  /**
208
514
  * Get a cached value. Returns undefined if expired or missing.
209
515
  */
@@ -77,8 +77,8 @@ export interface DatabaseAdapter {
77
77
  /** Insert one or more rows into a table, returns result with lastId. */
78
78
  insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult;
79
79
 
80
- /** Update rows in a table matching filter, returns affected row count. */
81
- update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult;
80
+ /** Update rows in a table matching filter (object or string WHERE), returns affected row count. */
81
+ update(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): DatabaseResult;
82
82
 
83
83
  /** Delete rows from a table matching filter (object, string WHERE, or array of objects). */
84
84
  delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): DatabaseResult;
@@ -93,10 +93,10 @@ export interface DatabaseAdapter {
93
93
  rollback(): void;
94
94
 
95
95
  /** List all tables in the database. */
96
- tables(): string[];
96
+ getTables(): string[];
97
97
 
98
98
  /** List columns with types for a table. */
99
- columns(table: string): ColumnInfo[];
99
+ getColumns(table: string): ColumnInfo[];
100
100
 
101
101
  /** Get the last inserted id (auto-increment integer, or a UUID/string PK). */
102
102
  lastInsertId(): number | bigint | string | null;
@@ -115,6 +115,17 @@ export interface DatabaseAdapter {
115
115
 
116
116
  /** Add a column to an existing table (legacy, used by migration). */
117
117
  addColumn?(table: string, colName: string, def: FieldDefinition): void;
118
+
119
+ /**
120
+ * Stable identity of the DATABASE this adapter is connected to, as
121
+ * `engine://host:port/database` with NO credentials - set by whoever built
122
+ * the adapter from a URL or config.
123
+ *
124
+ * The query cache folds this into every key. Without it two databases sharing
125
+ * one cache backend cross-serve each other's rows, because identical SQL text
126
+ * across tenants is the common case.
127
+ */
128
+ cacheIdentity?: string;
118
129
  }
119
130
 
120
131
  export interface PaginatedResult<T = Record<string, unknown>> {
@@ -61,4 +61,4 @@ export declare function writeOrMerge(contextPath: string, contextFile: string, f
61
61
  * Generate the Tina4 context document for a specific AI tool.
62
62
  */
63
63
  export declare function generateContext(toolName?: string): string;
64
- export { AiTool as AiToolType };
64
+ export type { AiTool as AiToolType };
@@ -16,6 +16,18 @@ import type { Middleware } from "./types.js";
16
16
  * @returns The newly-generated secret, or null when nothing was generated.
17
17
  */
18
18
  export declare function ensureDevSecret(cwd?: string): string | null;
19
+ /**
20
+ * Can this runtime actually sign and verify `algorithm` right now?
21
+ *
22
+ * The cross-framework capability check — same question, same answer shape, in
23
+ * all four frameworks. HMAC answers `true` everywhere. RS256 answers `true`
24
+ * only where the runtime ships asymmetric crypto natively (node:crypto here,
25
+ * core ext-openssl in PHP, the stdlib openssl gem in Ruby) and `false` in
26
+ * tina4-python. An algorithm Tina4 does not know at all answers `false`.
27
+ */
28
+ export declare function algorithmAvailable(algorithm: string): boolean;
29
+ /** Every algorithm this runtime can sign and verify right now, in advertised order. */
30
+ export declare function availableAlgorithms(): string[];
19
31
  /**
20
32
  * Seconds of clock skew tolerated on the "nbf" (not-before) claim.
21
33
  *
@@ -27,8 +39,12 @@ export declare const JWT_LEEWAY_SECONDS = 60;
27
39
  /**
28
40
  * Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else HS256.
29
41
  *
30
- * Throws (naming the supported set and the env var) when asked for an algorithm
31
- * we cannot sign a silent downgrade to HS256 is the whole bug in python#106.
42
+ * Throws when asked for an algorithm Tina4 does not know (naming the known set,
43
+ * what is available here, and the env var), and throws again with the runtime's
44
+ * own reason and a remedy — when it knows the algorithm but this build cannot
45
+ * provide it. A silent downgrade to HS256 is the whole bug in python#106, and a
46
+ * silent downgrade from RS256 would be worse: it would quietly turn asymmetric
47
+ * verification into a shared secret.
32
48
  *
33
49
  * @param algorithm - Explicit algorithm; wins over the environment when given.
34
50
  */
@@ -37,8 +53,11 @@ export declare function resolveAlgorithm(algorithm?: string): string;
37
53
  * Create a signed JWT token.
38
54
  *
39
55
  * Secret is always read from `process.env.TINA4_SECRET`.
40
- * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256");
41
- * HS256 / HS384 / HS512 / RS256 are supported and anything else throws.
56
+ * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
57
+ * HS256 / HS384 / HS512 is the cross-framework standard; RS256 is an opt-in
58
+ * extra that Node provides from builtin node:crypto (pass the PEM private key
59
+ * as the secret). An unknown algorithm, or one this runtime cannot provide,
60
+ * throws — see `resolveAlgorithm`.
42
61
  *
43
62
  * The header's `alg` is always the algorithm that actually signed the token.
44
63
  *
@@ -117,7 +136,8 @@ export declare function refreshToken(token: string, expiresIn?: number): string
117
136
  *
118
137
  * @param headers - Object with header keys (e.g. `{ authorization: "Bearer ..." }`)
119
138
  * @param secret - HMAC secret or PEM public key
120
- * @param algorithm - "HS256" or "RS256" (default "HS256")
139
+ * @param algorithm - Omit it to honour TINA4_JWT_ALGORITHM (then HS256). HS256 /
140
+ * HS384 / HS512 everywhere; RS256 where the runtime provides it (it does here).
121
141
  * @returns Decoded payload, or null if missing/invalid
122
142
  */
123
143
  export declare function authenticateRequest(headers: Record<string, string | string[] | undefined>, secret?: string, algorithm?: string): Record<string, unknown> | null;
@@ -144,6 +164,9 @@ export declare function validateApiKey(provided: string, expected?: string): boo
144
164
  export declare class Auth {
145
165
  static getToken: typeof getToken;
146
166
  static validToken: typeof validToken;
167
+ static resolveAlgorithm: typeof resolveAlgorithm;
168
+ static algorithmAvailable: typeof algorithmAvailable;
169
+ static availableAlgorithms: typeof availableAlgorithms;
147
170
  static getPayload: typeof getPayload;
148
171
  static hashPassword: typeof hashPassword;
149
172
  static checkPassword: typeof checkPassword;
@@ -25,9 +25,9 @@ export declare function background(callback: () => unknown | Promise<unknown>, i
25
25
  stop: () => void;
26
26
  };
27
27
  /**
28
- * Clear every registered background task. Called automatically on SIGTERM/SIGINT;
29
- * also called from the server's `close()` so a manual server shutdown stops
30
- * the timer wheel along with HTTP listeners.
28
+ * Clear every registered background task. Called by the server's graceful
29
+ * shutdown (its first step on SIGTERM/SIGINT) and by its `close()`, so both a
30
+ * signal and a manual shutdown stop the timer wheel along with the listeners.
31
31
  */
32
32
  export declare function stopAllBackgroundTasks(): void;
33
33
  /** Number of currently-registered background tasks (test helper). */
@@ -70,6 +70,20 @@ interface CacheBackend {
70
70
  set(key: string, value: unknown, ttl: number): Promise<void>;
71
71
  delete(key: string): Promise<boolean>;
72
72
  clear(): Promise<void>;
73
+ /**
74
+ * Evict expired entries and return HOW MANY were actually evicted.
75
+ *
76
+ * REQUIRED, not optional. It used to be neither declared nor implemented, so
77
+ * the module-level sweep() found no backend method and returned a permanent
78
+ * 0: the one API whose job is reclaiming expired space did nothing and
79
+ * reported success. Declaring it here makes "every provider can sweep" a
80
+ * compile-time fact instead of a runtime hope.
81
+ *
82
+ * 0 is the HONEST answer on redis/valkey/memcached/mongodb - they expire
83
+ * entries server-side, so there is nothing left for us to evict. It is the
84
+ * WRONG answer for memory, file and database, which own their own expiry.
85
+ */
86
+ sweep(): Promise<number>;
73
87
  stats(): Promise<{
74
88
  hits: number;
75
89
  misses: number;
@@ -107,18 +121,7 @@ export declare function createBackend(config?: {
107
121
  cacheDir?: string;
108
122
  maxEntries?: number;
109
123
  }): Promise<CacheBackend>;
110
- /**
111
- * Response cache middleware for GET requests.
112
- * Caches the full response body, content-type, and status code through the
113
- * unified async backend. Cache key is method + url (including query string).
114
- *
115
- * The middleware is ASYNC: the before-path awaits `backend.get` (serve hit) and
116
- * the after-path awaits `backend.set` (store on the captured `res.raw.end`).
117
- * The framework's middleware chain (`runRouteMiddlewares` / `MiddlewareChain`)
118
- * already awaits middleware, so async is transparent. Honors ttl/statusCodes/
119
- * maxEntries. With the default `memory` backend behaviour is unchanged; a
120
- * redis/etc. backend distributes cross-instance.
121
- */
124
+ export declare function _getResponseBackend(config?: ResponseCacheConfig): Promise<CacheBackend>;
122
125
  export declare function responseCache(config?: ResponseCacheConfig): Middleware;
123
126
  /**
124
127
  * Clear all cached responses (the responseCache middleware backend).