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
@@ -1,8 +1,9 @@
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 { DatabaseUrl } from "./databaseUrl.js";
4
5
  import { CachedDatabaseAdapter, type CachedAdapterOptions } from "./cachedDatabase.js";
5
- import { QueryCache } from "./sqlTranslator.js";
6
+ import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
6
7
 
7
8
  /**
8
9
  * v3.13.12 — strip trailing `;` and whitespace from user-supplied SQL
@@ -96,13 +97,13 @@ export async function adapterTableExists(adapter: DatabaseAdapter, name: string)
96
97
  export async function adapterTables(adapter: DatabaseAdapter): Promise<string[]> {
97
98
  return (adapter as any).tablesAsync
98
99
  ? await (adapter as any).tablesAsync()
99
- : adapter.tables();
100
+ : adapter.getTables();
100
101
  }
101
102
 
102
103
  export async function adapterColumns(adapter: DatabaseAdapter, table: string): Promise<ColumnInfo[]> {
103
104
  return (adapter as any).columnsAsync
104
105
  ? await (adapter as any).columnsAsync(table)
105
- : adapter.columns(table);
106
+ : adapter.getColumns(table);
106
107
  }
107
108
 
108
109
  export async function adapterCreateTable(
@@ -131,6 +132,16 @@ export function extractLastInsertId(result: unknown): number | bigint | null {
131
132
  }
132
133
 
133
134
  let activeAdapter: DatabaseAdapter | null = null;
135
+ /**
136
+ * The default row cap on every read path that advertises a `limit`.
137
+ *
138
+ * One number for the whole family (Python, PHP, Ruby and Node all default to
139
+ * this). Pagination is a default principle: an un-paginated read of a table
140
+ * that grew to a million rows is a production incident waiting to happen. A
141
+ * caller who wants more passes a bigger limit.
142
+ */
143
+ export const DEFAULT_ROW_CAP = 100;
144
+
134
145
  const namedAdapters: Map<string, DatabaseAdapter> = new Map();
135
146
 
136
147
  /**
@@ -289,166 +300,20 @@ export interface DatabaseConfig {
289
300
  /**
290
301
  * Parsed result from a TINA4_DATABASE_URL connection string.
291
302
  */
292
- export interface ParsedDatabaseUrl {
293
- type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird" | "mongodb" | "odbc";
294
- path?: string;
295
- host?: string;
296
- port?: number;
297
- user?: string;
298
- password?: string;
299
- database?: string;
300
- /** ODBC-specific: raw connection string passed to odbc.connect() */
301
- connectionString?: string;
302
- }
303
-
304
303
  /**
305
- * Parse a TINA4_DATABASE_URL connection string into its components.
304
+ * Parse a connection URL into a `DatabaseUrl` value.
306
305
  *
307
- * Supported formats:
308
- * sqlite:///path/to/db.sqlite
309
- * sqlite://./relative/path.db
310
- * postgresql://user:pass@host:port/dbname
311
- * postgres://user:pass@host:port/dbname
312
- * mysql://user:pass@host:port/dbname
306
+ * Breaking (feature 5): this returned a `ParsedDatabaseUrl` struct whose fields
307
+ * were `type`, `user` and `path`. It now returns a `DatabaseUrl`, whose fields
308
+ * are `engine`, `username` and `database` - the same names PHP, Python and Ruby
309
+ * use, and the same names as the TINA4_DATABASE_USERNAME env var they come from.
310
+ * `ParsedDatabaseUrl` is gone rather than kept as an alias.
313
311
  *
314
- * @param url - The connection URL string.
315
- * @param username - Optional username to merge when the URL has no credentials.
316
- * @param password - Optional password to merge when the URL has no credentials.
317
- * @returns Parsed database configuration.
318
- * @throws Error if the URL scheme is not supported.
312
+ * The 43-CC body that used to live here - the worst function measured anywhere
313
+ * in the audit - is now one small parser per engine inside the value type.
319
314
  */
320
- export function parseDatabaseUrl(url: string, username?: string, password?: string): ParsedDatabaseUrl {
321
- let result: ParsedDatabaseUrl;
322
-
323
- // Handle sqlite:// separately because URL class mangles the path.
324
- //
325
- // Convention (matches tina4-python, tina4-php, and the docs):
326
- // sqlite::memory: → in-memory
327
- // sqlite:///:memory: → in-memory (URL form)
328
- // sqlite:///app.db → ./app.db (relative to cwd)
329
- // sqlite:///data/app.db → ./data/app.db (relative)
330
- // sqlite:////absolute/app.db → /absolute/app.db (absolute)
331
- // sqlite:///C:/Users/app.db → C:/Users/app.db (Windows absolute)
332
- if (url === "sqlite::memory:" || url === "sqlite:///:memory:") {
333
- result = { type: "sqlite", path: ":memory:" };
334
- } else if (url.startsWith("sqlite:///")) {
335
- // Strip the "sqlite://" prefix (leaving one "/" + path)
336
- let rest = url.slice("sqlite://".length); // e.g. "/data/app.db" or "//abs/app.db" or "/C:/Users/..."
337
- // Drop exactly one leading "/"
338
- if (rest.startsWith("/")) rest = rest.slice(1);
339
- // Windows absolute: C:/Users/app.db or C:\...
340
- const isWindowsAbs = /^[A-Za-z]:[\/\\]/.test(rest);
341
- // Unix absolute: still starts with "/" after the strip (four-slash URL form)
342
- const isUnixAbs = rest.startsWith("/");
343
- result = { type: "sqlite", path: isWindowsAbs || isUnixAbs ? rest : rest };
344
- // Relative paths are resolved against cwd by the SQLite adapter at connect time;
345
- // keep the string as-is here so tests can inspect the raw form.
346
- } else if (url.startsWith("sqlite://")) {
347
- // sqlite://./relative or sqlite://relative — legacy two-slash form
348
- const path = url.slice("sqlite://".length);
349
- result = { type: "sqlite", path };
350
- } else if (url.startsWith("sqlite:")) {
351
- // sqlite:/abs/app.db (one slash = a real absolute path) or sqlite:app.db (relative).
352
- // Keep the leading slash so resolveSqlitePath's isAbsolute() sees the absolute path —
353
- // this form used to fall through and throw "unsupported scheme" (the naive-abs footgun).
354
- const path = url.slice("sqlite:".length);
355
- result = { type: "sqlite", path };
356
- } else if (url.startsWith("mssql://") || url.startsWith("sqlserver://")) {
357
- // Handle mssql:// and sqlserver:// with custom parsing (URL class doesn't know these schemes)
358
- const match = url.match(/(?:mssql|sqlserver):\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
359
- if (!match) throw new Error(`Invalid MSSQL URL: ${url}`);
360
- result = {
361
- type: "mssql",
362
- user: match[1] ? decodeURIComponent(match[1]) : undefined,
363
- password: match[2] ? decodeURIComponent(match[2]) : undefined,
364
- host: match[3],
365
- port: match[4] ? parseInt(match[4], 10) : undefined,
366
- database: match[5],
367
- };
368
- } else if (url.startsWith("firebird://")) {
369
- const match = url.match(/firebird:\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
370
- if (!match) throw new Error(`Invalid Firebird URL: ${url}`);
371
- result = {
372
- type: "firebird",
373
- user: match[1] ? decodeURIComponent(match[1]) : undefined,
374
- password: match[2] ? decodeURIComponent(match[2]) : undefined,
375
- host: match[3],
376
- port: match[4] ? parseInt(match[4], 10) : undefined,
377
- database: "/" + match[5],
378
- };
379
- } else if (url.startsWith("odbc:///")) {
380
- // odbc:///DSN=MyDSN or odbc:///DRIVER={driver};SERVER=host;DATABASE=db
381
- // Strip the "odbc:///" prefix and pass the rest directly as the connection string
382
- const connectionString = url.slice("odbc:///".length);
383
- result = { type: "odbc", connectionString };
384
- } else if (url.startsWith("mongodb://") || url.startsWith("mongodb+srv://")) {
385
- // Pass through as-is; MongodbAdapter handles the full connection string
386
- let parsed: URL;
387
- try {
388
- parsed = new URL(url);
389
- } catch {
390
- throw new Error(`Invalid MongoDB URL: ${url}`);
391
- }
392
- const database = parsed.pathname.replace(/^\//, "") || "tina4";
393
- result = {
394
- type: "mongodb",
395
- host: parsed.hostname || undefined,
396
- port: parsed.port ? parseInt(parsed.port, 10) : undefined,
397
- user: parsed.username ? decodeURIComponent(parsed.username) : undefined,
398
- password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
399
- database,
400
- };
401
- } else {
402
- // Normalize postgres:// and pgsql:// (the PDO/Laravel/Doctrine scheme
403
- // name, issue #58) to postgresql:// for URL parsing.
404
- const normalizedUrl = /^(postgres|pgsql):\/\//.test(url)
405
- ? url.replace(/^(postgres|pgsql):\/\//, "postgresql://")
406
- : url;
407
-
408
- let parsed: URL;
409
- try {
410
- parsed = new URL(normalizedUrl);
411
- } catch {
412
- throw new Error(`Invalid database URL: ${url}`);
413
- }
414
-
415
- const scheme = parsed.protocol.replace(/:$/, "");
416
- let type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird";
417
-
418
- switch (scheme) {
419
- case "postgresql":
420
- type = "postgres";
421
- break;
422
- case "mysql":
423
- type = "mysql";
424
- break;
425
- default:
426
- throw new Error(`Unsupported database URL scheme: "${scheme}". Supported: sqlite, postgres/postgresql, mysql, mssql/sqlserver, firebird.`);
427
- }
428
-
429
- const database = parsed.pathname.startsWith("/")
430
- ? parsed.pathname.slice(1)
431
- : parsed.pathname;
432
-
433
- result = {
434
- type,
435
- host: parsed.hostname || undefined,
436
- port: parsed.port ? parseInt(parsed.port, 10) : undefined,
437
- user: parsed.username ? decodeURIComponent(parsed.username) : undefined,
438
- password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
439
- database: database || undefined,
440
- };
441
- }
442
-
443
- // Merge separate username/password when the URL contained no credentials
444
- if (!result.user && username) {
445
- result.user = username;
446
- }
447
- if (!result.password && password) {
448
- result.password = password;
449
- }
450
-
451
- return result;
315
+ export function parseDatabaseUrl(url: string, username?: string, password?: string): DatabaseUrl {
316
+ return new DatabaseUrl(url, username, password);
452
317
  }
453
318
 
454
319
  /**
@@ -574,7 +439,7 @@ export class Database {
574
439
  db.poolIndex = 0;
575
440
  db.adapter = null; // Don't use single-adapter path
576
441
  db.adapterFactory = async () => wrapWithCache(await createAdapterFromUrl(url, username, password), { sharedCache });
577
- db.dbType = parsed.type;
442
+ db.dbType = parsed.engine;
578
443
  return exposeDb(db);
579
444
  }
580
445
 
@@ -584,7 +449,7 @@ export class Database {
584
449
  const adapter = await createAdapterFromUrl(url, username, password);
585
450
  const wrapped = setAdapter(adapter);
586
451
  const db = new Database(wrapped);
587
- db.dbType = parsed.type;
452
+ db.dbType = parsed.engine;
588
453
  return exposeDb(db);
589
454
  }
590
455
 
@@ -675,7 +540,31 @@ export class Database {
675
540
  * the fallback resolves instantly). This is the breaking change that makes
676
541
  * the wrapper work uniformly across every engine.
677
542
  */
543
+ /**
544
+ * Fetch rows with pagination, capped at DEFAULT_ROW_CAP (100) when the
545
+ * caller does not pass a limit.
546
+ *
547
+ * The cap is the one row-cap number the whole family shares (Python, PHP and
548
+ * Ruby all default `fetch` to 100). Node was the outlier: `limit` was
549
+ * optional with NO default, so a bare `db.fetch("select * from big_table")`
550
+ * returned every row.
551
+ *
552
+ * `fetchAll` deliberately does NOT inherit the cap — see below.
553
+ */
678
554
  async fetch(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
555
+ return this._fetchWithLimit(sql, params, limit ?? DEFAULT_ROW_CAP, offset, opts);
556
+ }
557
+
558
+ /**
559
+ * The shared read body. `limit` is passed through VERBATIM: `undefined`
560
+ * means "no LIMIT clause at all", which is how `fetchAll` stays uncapped.
561
+ *
562
+ * This exists because Node's adapters treat `limit: 0` as `LIMIT 0` (zero
563
+ * rows), not as the "no truncation" sentinel Python and PHP use — so the cap
564
+ * cannot live on the parameter default, or `fetchAll()` would silently
565
+ * inherit it and stop returning every row.
566
+ */
567
+ private async _fetchWithLimit(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
679
568
  // v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
680
569
  // or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
681
570
  // `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
@@ -686,7 +575,8 @@ export class Database {
686
575
  // no store, run directly (mirrors the Python master's `no_cache`).
687
576
  const rows = await adapterFetch(adapter, sql, params, limit, offset, opts?.noCache);
688
577
  this.lastError = null;
689
- return new DatabaseResult(rows, undefined, undefined, limit, offset, adapter, sql);
578
+ const total = await this.countProbe(adapter, sql, params, limit);
579
+ return new DatabaseResult(rows, undefined, total, limit, offset, adapter, sql);
690
580
  } catch (e: any) {
691
581
  // v3.13.11 #49.2: fetch() records last_error like execute() does.
692
582
  this.lastError = e?.message ?? String(e);
@@ -694,6 +584,63 @@ export class Database {
694
584
  }
695
585
  }
696
586
 
587
+ /**
588
+ * The true row count for `sql`, ignoring the pagination we appended.
589
+ *
590
+ * `count` is the TRUE TOTAL for the filter, not the number of rows this page
591
+ * returned. Node and Ruby used to populate it with `records.length` while
592
+ * Python and PHP populated it from a probe, so `db.fetch(sql).count` answered
593
+ * 20 here and 250 there for one query against one table, and every paginated
594
+ * response built on it under-reported. MEASURED 2026-08-05 on a 250-row table
595
+ * read with limit=20: Node reported total 20 over 2 pages against Python's
596
+ * 250 over 13.
597
+ *
598
+ * Only probed when a limit was actually applied. With no limit the rows
599
+ * returned ARE the whole answer for this SQL, so `records.length` is already
600
+ * the true total and a second round-trip would buy nothing — which is also
601
+ * what keeps `fetchAll()` at one query.
602
+ *
603
+ * BEST EFFORT, and it can never mask a real failure: it runs AFTER the main
604
+ * query (which has already thrown on bad SQL) and returns undefined on any
605
+ * error. `undefined` — not 0 — is the miss value, so DatabaseResult falls
606
+ * back to records.length, a true lower bound. Reporting 0 next to 100 real
607
+ * records would be the same "states a wrong number authoritatively" defect
608
+ * this change exists to remove.
609
+ *
610
+ * The closing paren goes on its OWN LINE: appended inline, a trailing
611
+ * `-- comment` in the caller's SQL comments it out and the probe dies with
612
+ * "incomplete input". Postgres, MySQL and MSSQL additionally require a name
613
+ * for the derived table; SQLite and Firebird do not, and Firebird rejects
614
+ * `AS` there — so the alias comes from the adapter, not an assumption.
615
+ */
616
+ private async countProbe(
617
+ adapter: DatabaseAdapter,
618
+ sql: string,
619
+ params: unknown[] | undefined,
620
+ limit: number | undefined,
621
+ ): Promise<number | undefined> {
622
+ if (limit === undefined || limit <= 0) return undefined;
623
+ try {
624
+ const alias = (adapter as any).countSubqueryAlias as string | undefined;
625
+ const suffix = alias ? ` AS ${alias}` : "";
626
+ const rows = await adapterFetch(
627
+ adapter,
628
+ `SELECT COUNT(*) AS tina4_total FROM (${sql}\n)${suffix}`,
629
+ params,
630
+ undefined,
631
+ undefined,
632
+ true,
633
+ );
634
+ const row = Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined;
635
+ if (!row) return undefined;
636
+ const value = row["tina4_total"] ?? row["TINA4_TOTAL"] ?? Object.values(row)[0];
637
+ const n = Number(value);
638
+ return Number.isFinite(n) ? n : undefined;
639
+ } catch {
640
+ return undefined;
641
+ }
642
+ }
643
+
697
644
  /**
698
645
  * Fetch a single row or null.
699
646
  *
@@ -741,7 +688,10 @@ export class Database {
741
688
  * SEPARATE trailing argument, never the params array.
742
689
  */
743
690
  async fetchAll<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<T[]> {
744
- return (await this.fetch(sql, params, limit, offset, opts)).records as T[];
691
+ // Routes through _fetchWithLimit, NOT fetch(), so `limit` stays verbatim.
692
+ // Going through fetch() would apply the 100-row cap and make a method
693
+ // called "fetchAll" quietly stop returning them all.
694
+ return (await this._fetchWithLimit(sql, params, limit, offset, opts)).records as T[];
745
695
  }
746
696
 
747
697
  /**
@@ -835,13 +785,52 @@ export class Database {
835
785
  * the WHERE clause. With neither a filter nor a primary key in `data` this
836
786
  * throws rather than silently changing nothing (audit feature 4, P1).
837
787
  */
838
- async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>, params?: unknown[]): Promise<DatabaseWriteResult> {
839
- let effectiveFilter = filter ?? {};
788
+ async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseWriteResult> {
789
+ let effectiveFilter: Record<string, unknown> | string = filter ?? {};
840
790
  let effectiveData = data;
841
791
 
842
- if (Object.keys(effectiveFilter).length === 0) {
792
+ // A string filter is the OTHER documented form ("id = ?" + params), so it
793
+ // must be tested as a string: Object.keys("id = ?") is ["0",..,"5"], which
794
+ // is non-empty by accident rather than by meaning — and an EMPTY string
795
+ // filter would then be treated as a real filter instead of falling through
796
+ // to the primary key.
797
+ const filterIsEmpty = typeof effectiveFilter === "string"
798
+ ? effectiveFilter.trim() === ""
799
+ : Object.keys(effectiveFilter).length === 0;
800
+
801
+ if (filterIsEmpty) {
843
802
  const pkColumns = await this.primaryKey(table);
844
- const missing = pkColumns.filter((c) => !(c in data));
803
+ // Resolve each key column to the caller's OWN key for it, matched
804
+ // case-insensitively.
805
+ //
806
+ // The engines disagree about identifier case BY DESIGN and always will:
807
+ // Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
808
+ // LOWER, MySQL and SQLite preserve what was typed. Introspection returns
809
+ // the ENGINE's spelling while `data` carries the caller's, so `c in data`
810
+ // failed on whichever engine folds the other way. A case-sensitivity bug,
811
+ // not a Firebird quirk - Firebird just made it visible first.
812
+ //
813
+ // Deliberately does NOT lower-case introspection output: that would
814
+ // special-case one engine and break a genuinely quoted mixed-case table.
815
+ // The WHERE is built from the ENGINE's column name and the CALLER's value.
816
+ const resolved: Record<string, string> = {};
817
+ const missing: string[] = [];
818
+ for (const col of pkColumns) {
819
+ const folded = String(col).toLowerCase();
820
+ const matches = Object.keys(data).filter((k) => k.toLowerCase() === folded);
821
+ if (matches.length > 1) {
822
+ // Ambiguity is refused, never guessed - choosing wrong here writes the
823
+ // WHERE clause of an UPDATE.
824
+ throw new Error(
825
+ `update was given more than one key for the primary-key column ${col}: ` +
826
+ `[${matches.slice().sort().join(", ")}] (table=${table}). These differ ` +
827
+ `only by case, so which one identifies the row is ambiguous - pass ` +
828
+ `exactly one, or pass an explicit filter.`,
829
+ );
830
+ }
831
+ if (matches.length === 1) resolved[col] = matches[0];
832
+ else missing.push(col);
833
+ }
845
834
  if (pkColumns.length === 0 || missing.length > 0) {
846
835
  throw new Error(
847
836
  `update requires a filter or the complete primary key in the data; pass ` +
@@ -856,8 +845,9 @@ export class Database {
856
845
  effectiveData = { ...data };
857
846
  const keyed: Record<string, unknown> = {};
858
847
  for (const col of pkColumns) {
859
- keyed[col] = effectiveData[col];
860
- delete effectiveData[col];
848
+ const callerKey = resolved[col];
849
+ keyed[col] = effectiveData[callerKey];
850
+ delete effectiveData[callerKey];
861
851
  }
862
852
  if (Object.keys(effectiveData).length === 0) {
863
853
  throw new Error(
@@ -879,10 +869,19 @@ export class Database {
879
869
  }
880
870
 
881
871
  /** Delete rows. A filterless delete throws; use truncate() to empty a table. */
882
- async delete(table: string, filter?: Record<string, unknown>, params?: unknown[]): Promise<DatabaseWriteResult> {
872
+ async delete(table: string, filter?: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseWriteResult> {
883
873
  const effectiveFilter = filter ?? {};
884
- if (!Array.isArray(effectiveFilter) && typeof effectiveFilter !== "string"
885
- && Object.keys(effectiveFilter).length === 0) {
874
+ // A BLANK string counts as no filter. The old guard skipped the emptiness
875
+ // test for anything typed string, so `delete(t, "")` fell through to the
876
+ // adapter, which renders an empty WHERE as `DELETE FROM "t"` — a silent
877
+ // whole-table delete through the very method that exists to make that
878
+ // impossible. truncate() is the explicit spelling.
879
+ const filterIsEmpty = Array.isArray(effectiveFilter)
880
+ ? effectiveFilter.length === 0
881
+ : typeof effectiveFilter === "string"
882
+ ? effectiveFilter.trim() === ""
883
+ : Object.keys(effectiveFilter).length === 0;
884
+ if (filterIsEmpty) {
886
885
  throw new Error(
887
886
  `delete requires a filter (table=${table}). To remove every row use truncate(${table}).`,
888
887
  );
@@ -1075,9 +1074,35 @@ export class Database {
1075
1074
  // (Database.execute_many delegating to adapter.execute_many's owns_txn guard).
1076
1075
  const owns = !this.inExplicitTransaction();
1077
1076
  if (owns) await adapterStartTransaction(adapter);
1077
+
1078
+ // ONE round-trip per CHUNK instead of one per ROW. Looping execute() here
1079
+ // pays a full network round-trip for every row: 500 rows took 9848ms on
1080
+ // PostgreSQL against 15.8ms as a single multi-row VALUES (625x), MySQL 216x,
1081
+ // MSSQL 121x. buildBatchInserts returns an empty array for anything it
1082
+ // cannot collapse safely — RETURNING, upserts, non-INSERT statements, ragged
1083
+ // rows, Firebird — and the row-at-a-time loop then runs unchanged.
1084
+ const batched = SQLTranslator.buildBatchInserts(sql, paramSets, this.dbType ?? "");
1085
+
1078
1086
  try {
1079
- for (const params of paramSets) {
1080
- results.push(await adapterExecute(adapter, sql, params));
1087
+ if (batched.length > 0) {
1088
+ let row = 0;
1089
+ for (const [chunkSql, chunkParams] of batched) {
1090
+ const result = await adapterExecute(adapter, chunkSql, chunkParams);
1091
+ // executeMany's contract is ONE RESULT PER ROW, and callers index into
1092
+ // it. Collapsing rows into chunks must not shorten the array, so each
1093
+ // row reports the result of the statement that actually wrote it.
1094
+ // Node is the only one of the four returning per-row results — Python,
1095
+ // PHP and Ruby return a count or a single DatabaseResult — so this is
1096
+ // the one place the collapse could have been observable.
1097
+ const rowsInChunk = chunkParams.length / (paramSets[0]?.length || 1);
1098
+ for (let i = 0; i < rowsInChunk && row < paramSets.length; i++, row++) {
1099
+ results.push(result);
1100
+ }
1101
+ }
1102
+ } else {
1103
+ for (const params of paramSets) {
1104
+ results.push(await adapterExecute(adapter, sql, params));
1105
+ }
1081
1106
  }
1082
1107
  if (owns) await adapterCommit(adapter);
1083
1108
  } catch (e) {
@@ -1416,21 +1441,31 @@ export class Database {
1416
1441
  * connected; SQLite connects lazily.
1417
1442
  */
1418
1443
  export async function createAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
1444
+ const adapter = await buildAdapterFromUrl(url, username, password);
1445
+ // Tag the adapter with WHICH DATABASE it is connected to. The query cache
1446
+ // folds this into every key, so two databases sharing one cache backend
1447
+ // cannot serve each other's rows. Set here because this is the single funnel
1448
+ // where a URL becomes an adapter.
1449
+ adapter.cacheIdentity = QueryCache.cacheIdentity(url);
1450
+ return adapter;
1451
+ }
1452
+
1453
+ async function buildAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
1419
1454
  const parsed = parseDatabaseUrl(url, username, password);
1420
1455
 
1421
- switch (parsed.type) {
1456
+ switch (parsed.engine) {
1422
1457
  case "sqlite": {
1423
1458
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
1424
- return new SQLiteAdapter(parsed.path ?? "./data/tina4.db");
1459
+ return new SQLiteAdapter(parsed.database || "./data/tina4.db");
1425
1460
  }
1426
1461
  case "postgres": {
1427
1462
  const { PostgresAdapter } = await import("./adapters/postgres.js");
1428
1463
  const adapter = new PostgresAdapter({
1429
- host: parsed.host,
1430
- port: parsed.port,
1431
- user: parsed.user,
1432
- password: parsed.password,
1433
- database: parsed.database,
1464
+ host: parsed.host ?? undefined,
1465
+ port: parsed.port ?? undefined,
1466
+ user: parsed.username ?? undefined,
1467
+ password: parsed.password ?? undefined,
1468
+ database: parsed.database || undefined,
1434
1469
  });
1435
1470
  await adapter.connect();
1436
1471
  return adapter;
@@ -1438,11 +1473,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1438
1473
  case "mysql": {
1439
1474
  const { MysqlAdapter } = await import("./adapters/mysql.js");
1440
1475
  const adapter = new MysqlAdapter({
1441
- host: parsed.host,
1442
- port: parsed.port,
1443
- user: parsed.user,
1444
- password: parsed.password,
1445
- database: parsed.database,
1476
+ host: parsed.host ?? undefined,
1477
+ port: parsed.port ?? undefined,
1478
+ user: parsed.username ?? undefined,
1479
+ password: parsed.password ?? undefined,
1480
+ database: parsed.database || undefined,
1446
1481
  });
1447
1482
  await adapter.connect();
1448
1483
  return adapter;
@@ -1450,11 +1485,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1450
1485
  case "mssql": {
1451
1486
  const { MssqlAdapter } = await import("./adapters/mssql.js");
1452
1487
  const adapter = new MssqlAdapter({
1453
- host: parsed.host,
1454
- port: parsed.port,
1455
- user: parsed.user,
1456
- password: parsed.password,
1457
- database: parsed.database,
1488
+ host: parsed.host ?? undefined,
1489
+ port: parsed.port ?? undefined,
1490
+ user: parsed.username ?? undefined,
1491
+ password: parsed.password ?? undefined,
1492
+ database: parsed.database || undefined,
1458
1493
  });
1459
1494
  await adapter.connect();
1460
1495
  return adapter;
@@ -1462,11 +1497,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1462
1497
  case "firebird": {
1463
1498
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
1464
1499
  const adapter = new FirebirdAdapter({
1465
- host: parsed.host,
1466
- port: parsed.port,
1467
- user: parsed.user,
1468
- password: parsed.password,
1469
- database: parsed.database,
1500
+ host: parsed.host ?? undefined,
1501
+ port: parsed.port ?? undefined,
1502
+ user: parsed.username ?? undefined,
1503
+ password: parsed.password ?? undefined,
1504
+ database: parsed.database || undefined,
1470
1505
  });
1471
1506
  await adapter.connect();
1472
1507
  return adapter;
@@ -1577,7 +1612,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1577
1612
  const parsed = parseDatabaseUrl(url, resolvedUser, resolvedPassword);
1578
1613
  const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
1579
1614
  const db = new Database(setAdapter(adapter));
1580
- db.setDbType(parsed.type);
1615
+ db.setDbType(parsed.engine);
1581
1616
  return exposeDb(db);
1582
1617
  }
1583
1618
 
@@ -1604,6 +1639,11 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1604
1639
  // default and a `{ type: "postgres" }` connection takes the SQLite getNextId
1605
1640
  // branch and crashes on the missing tina4_sequences table (#255).
1606
1641
  const finished = (adapter: DatabaseAdapter): Database => {
1642
+ // Same identity tag as the URL path above - a config-object connection is
1643
+ // just as capable of sharing a cache backend with another database.
1644
+ adapter.cacheIdentity = QueryCache.cacheIdentity(
1645
+ `${type}://${config?.host ?? ""}:${config?.port ?? ""}/${config?.database ?? config?.path ?? ""}`,
1646
+ );
1607
1647
  const db = new Database(setAdapter(adapter));
1608
1648
  db.setDbType(type);
1609
1649
  return exposeDb(db);