tina4-nodejs 3.13.94 → 3.13.96

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 (123) hide show
  1. package/CLAUDE.md +158 -30
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/packages/cli/dist/bin.js +30911 -28444
  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 +30810 -28261
  8. package/packages/core/public/css/tina4.min.css +1 -1
  9. package/packages/core/src/ai.ts +7 -1
  10. package/packages/core/src/auth.ts +191 -39
  11. package/packages/core/src/background.ts +19 -19
  12. package/packages/core/src/cache.ts +492 -49
  13. package/packages/core/src/devAdmin.ts +79 -32
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +6 -7
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +294 -106
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +34 -16
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +886 -421
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  34. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  35. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  36. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  37. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  38. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  39. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  40. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  41. package/packages/core/src/testClient.ts +18 -5
  42. package/packages/core/src/trustedProxy.ts +249 -0
  43. package/packages/core/src/types.ts +29 -5
  44. package/packages/core/src/websocket.ts +66 -0
  45. package/packages/orm/dist/index.js +22717 -20168
  46. package/packages/orm/src/adapters/firebird.ts +183 -56
  47. package/packages/orm/src/adapters/mongodb.ts +25 -4
  48. package/packages/orm/src/adapters/mssql.ts +114 -29
  49. package/packages/orm/src/adapters/mysql.ts +103 -40
  50. package/packages/orm/src/adapters/odbc.ts +44 -21
  51. package/packages/orm/src/adapters/postgres.ts +118 -26
  52. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  53. package/packages/orm/src/adapters/sqlite.ts +60 -24
  54. package/packages/orm/src/autoCrud.ts +12 -10
  55. package/packages/orm/src/baseModel.ts +135 -40
  56. package/packages/orm/src/cachedDatabase.ts +43 -19
  57. package/packages/orm/src/connectTimeout.ts +265 -0
  58. package/packages/orm/src/database.ts +241 -197
  59. package/packages/orm/src/databaseResult.ts +51 -28
  60. package/packages/orm/src/databaseUrl.ts +484 -0
  61. package/packages/orm/src/docstore.ts +386 -145
  62. package/packages/orm/src/index.ts +13 -6
  63. package/packages/orm/src/migration.ts +44 -11
  64. package/packages/orm/src/model.ts +4 -0
  65. package/packages/orm/src/queryBuilder.ts +47 -6
  66. package/packages/orm/src/sqlTranslator.ts +310 -4
  67. package/packages/orm/src/types.ts +21 -77
  68. package/packages/swagger/dist/index.js +78 -20
  69. package/packages/swagger/src/generator.ts +172 -29
  70. package/types/core/src/ai.d.ts +1 -1
  71. package/types/core/src/auth.d.ts +28 -5
  72. package/types/core/src/background.d.ts +3 -3
  73. package/types/core/src/cache.d.ts +15 -12
  74. package/types/core/src/dispatchPipeline.d.ts +117 -0
  75. package/types/core/src/dotenv.d.ts +38 -16
  76. package/types/core/src/index.d.ts +6 -9
  77. package/types/core/src/logger.d.ts +93 -16
  78. package/types/core/src/messenger.d.ts +47 -6
  79. package/types/core/src/metrics.d.ts +25 -61
  80. package/types/core/src/middleware.d.ts +134 -11
  81. package/types/core/src/queue.d.ts +54 -5
  82. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  83. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  84. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  85. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  86. package/types/core/src/router.d.ts +14 -3
  87. package/types/core/src/server.d.ts +15 -4
  88. package/types/core/src/session.d.ts +87 -2
  89. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  90. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  91. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  92. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  93. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  94. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  95. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  96. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  97. package/types/core/src/trustedProxy.d.ts +44 -0
  98. package/types/core/src/types.d.ts +28 -5
  99. package/types/core/src/websocket.d.ts +26 -0
  100. package/types/orm/src/adapters/firebird.d.ts +55 -10
  101. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  102. package/types/orm/src/adapters/mssql.d.ts +18 -11
  103. package/types/orm/src/adapters/mysql.d.ts +11 -10
  104. package/types/orm/src/adapters/odbc.d.ts +9 -12
  105. package/types/orm/src/adapters/postgres.d.ts +11 -10
  106. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  108. package/types/orm/src/baseModel.d.ts +45 -9
  109. package/types/orm/src/cachedDatabase.d.ts +18 -5
  110. package/types/orm/src/connectTimeout.d.ts +100 -0
  111. package/types/orm/src/database.d.ts +78 -28
  112. package/types/orm/src/databaseResult.d.ts +29 -15
  113. package/types/orm/src/databaseUrl.d.ts +125 -0
  114. package/types/orm/src/docstore.d.ts +102 -43
  115. package/types/orm/src/index.d.ts +6 -4
  116. package/types/orm/src/migration.d.ts +4 -3
  117. package/types/orm/src/queryBuilder.d.ts +23 -3
  118. package/types/orm/src/sqlTranslator.d.ts +126 -2
  119. package/types/orm/src/types.d.ts +21 -38
  120. package/packages/core/src/scss.ts +0 -623
  121. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  122. package/types/core/src/scss.d.ts +0 -19
  123. 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(
@@ -112,6 +113,67 @@ export async function adapterCreateTable(
112
113
  else adapter.createTable(name, columns);
113
114
  }
114
115
 
116
+ /**
117
+ * The true row count for `sql`, ignoring the pagination the caller applied.
118
+ *
119
+ * `count` on a DatabaseResult is the TRUE TOTAL for the filter, not the number
120
+ * of rows the page returned. Node and Ruby used to populate it with
121
+ * `records.length` while Python and PHP populated it from a probe, so
122
+ * `db.fetch(sql).count` answered 20 here and 250 there for one query against one
123
+ * table, and every `toPaginate()` envelope built on it under-reported (ADR-0043).
124
+ * MEASURED 2026-08-05 on a 250-row table read with limit=20: Node reported total
125
+ * 20 over 2 pages against Python's 250 over 13.
126
+ *
127
+ * This is the single source of truth for that probe. Both read paths that build a
128
+ * DatabaseResult — `Database.fetch()` and `QueryBuilder.get()` — call it, so the
129
+ * two can never drift (QueryBuilder.get used to leave `count` at rows-returned,
130
+ * diverging from db.fetch AND from Python/Ruby, whose get() routes through fetch).
131
+ *
132
+ * Only probed when a limit was actually applied. With no limit the rows returned
133
+ * ARE the whole answer for this SQL, so `records.length` is already the true total
134
+ * and a second round-trip would buy nothing — which is also what keeps
135
+ * `fetchAll()` at one query.
136
+ *
137
+ * BEST EFFORT, and it can never mask a real failure: it runs AFTER the main query
138
+ * (which has already thrown on bad SQL) and returns `undefined` on any error.
139
+ * `undefined` — not 0 — is the miss value, so DatabaseResult falls back to
140
+ * records.length, a true lower bound. Reporting 0 next to 100 real records would
141
+ * be the same "states a wrong number authoritatively" defect this exists to remove.
142
+ *
143
+ * The closing paren goes on its OWN LINE: appended inline, a trailing
144
+ * `-- comment` in the caller's SQL comments it out and the probe dies with
145
+ * "incomplete input". Postgres, MySQL and MSSQL additionally require a name for
146
+ * the derived table; SQLite and Firebird do not, and Firebird rejects `AS` there —
147
+ * so the alias comes from the adapter, not an assumption.
148
+ */
149
+ export async function probeTotal(
150
+ adapter: DatabaseAdapter,
151
+ sql: string,
152
+ params: unknown[] | undefined,
153
+ limit: number | undefined,
154
+ ): Promise<number | undefined> {
155
+ if (limit === undefined || limit <= 0) return undefined;
156
+ try {
157
+ const alias = (adapter as any).countSubqueryAlias as string | undefined;
158
+ const suffix = alias ? ` AS ${alias}` : "";
159
+ const rows = await adapterFetch(
160
+ adapter,
161
+ `SELECT COUNT(*) AS tina4_total FROM (${sql}\n)${suffix}`,
162
+ params,
163
+ undefined,
164
+ undefined,
165
+ true,
166
+ );
167
+ const row = Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined;
168
+ if (!row) return undefined;
169
+ const value = row["tina4_total"] ?? row["TINA4_TOTAL"] ?? Object.values(row)[0];
170
+ const n = Number(value);
171
+ return Number.isFinite(n) ? n : undefined;
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ }
176
+
115
177
  /**
116
178
  * Extract the engine-assigned auto-increment id from an `execute()` result.
117
179
  *
@@ -131,6 +193,16 @@ export function extractLastInsertId(result: unknown): number | bigint | null {
131
193
  }
132
194
 
133
195
  let activeAdapter: DatabaseAdapter | null = null;
196
+ /**
197
+ * The default row cap on every read path that advertises a `limit`.
198
+ *
199
+ * One number for the whole family (Python, PHP, Ruby and Node all default to
200
+ * this). Pagination is a default principle: an un-paginated read of a table
201
+ * that grew to a million rows is a production incident waiting to happen. A
202
+ * caller who wants more passes a bigger limit.
203
+ */
204
+ export const DEFAULT_ROW_CAP = 100;
205
+
134
206
  const namedAdapters: Map<string, DatabaseAdapter> = new Map();
135
207
 
136
208
  /**
@@ -289,166 +361,20 @@ export interface DatabaseConfig {
289
361
  /**
290
362
  * Parsed result from a TINA4_DATABASE_URL connection string.
291
363
  */
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
364
  /**
305
- * Parse a TINA4_DATABASE_URL connection string into its components.
365
+ * Parse a connection URL into a `DatabaseUrl` value.
306
366
  *
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
367
+ * Breaking (feature 5): this returned a `ParsedDatabaseUrl` struct whose fields
368
+ * were `type`, `user` and `path`. It now returns a `DatabaseUrl`, whose fields
369
+ * are `engine`, `username` and `database` - the same names PHP, Python and Ruby
370
+ * use, and the same names as the TINA4_DATABASE_USERNAME env var they come from.
371
+ * `ParsedDatabaseUrl` is gone rather than kept as an alias.
313
372
  *
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.
373
+ * The 43-CC body that used to live here - the worst function measured anywhere
374
+ * in the audit - is now one small parser per engine inside the value type.
319
375
  */
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;
376
+ export function parseDatabaseUrl(url: string, username?: string, password?: string): DatabaseUrl {
377
+ return new DatabaseUrl(url, username, password);
452
378
  }
453
379
 
454
380
  /**
@@ -574,7 +500,7 @@ export class Database {
574
500
  db.poolIndex = 0;
575
501
  db.adapter = null; // Don't use single-adapter path
576
502
  db.adapterFactory = async () => wrapWithCache(await createAdapterFromUrl(url, username, password), { sharedCache });
577
- db.dbType = parsed.type;
503
+ db.dbType = parsed.engine;
578
504
  return exposeDb(db);
579
505
  }
580
506
 
@@ -584,7 +510,7 @@ export class Database {
584
510
  const adapter = await createAdapterFromUrl(url, username, password);
585
511
  const wrapped = setAdapter(adapter);
586
512
  const db = new Database(wrapped);
587
- db.dbType = parsed.type;
513
+ db.dbType = parsed.engine;
588
514
  return exposeDb(db);
589
515
  }
590
516
 
@@ -675,7 +601,31 @@ export class Database {
675
601
  * the fallback resolves instantly). This is the breaking change that makes
676
602
  * the wrapper work uniformly across every engine.
677
603
  */
604
+ /**
605
+ * Fetch rows with pagination, capped at DEFAULT_ROW_CAP (100) when the
606
+ * caller does not pass a limit.
607
+ *
608
+ * The cap is the one row-cap number the whole family shares (Python, PHP and
609
+ * Ruby all default `fetch` to 100). Node was the outlier: `limit` was
610
+ * optional with NO default, so a bare `db.fetch("select * from big_table")`
611
+ * returned every row.
612
+ *
613
+ * `fetchAll` deliberately does NOT inherit the cap — see below.
614
+ */
678
615
  async fetch(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
616
+ return this._fetchWithLimit(sql, params, limit ?? DEFAULT_ROW_CAP, offset, opts);
617
+ }
618
+
619
+ /**
620
+ * The shared read body. `limit` is passed through VERBATIM: `undefined`
621
+ * means "no LIMIT clause at all", which is how `fetchAll` stays uncapped.
622
+ *
623
+ * This exists because Node's adapters treat `limit: 0` as `LIMIT 0` (zero
624
+ * rows), not as the "no truncation" sentinel Python and PHP use — so the cap
625
+ * cannot live on the parameter default, or `fetchAll()` would silently
626
+ * inherit it and stop returning every row.
627
+ */
628
+ private async _fetchWithLimit(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
679
629
  // v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
680
630
  // or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
681
631
  // `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
@@ -686,7 +636,8 @@ export class Database {
686
636
  // no store, run directly (mirrors the Python master's `no_cache`).
687
637
  const rows = await adapterFetch(adapter, sql, params, limit, offset, opts?.noCache);
688
638
  this.lastError = null;
689
- return new DatabaseResult(rows, undefined, undefined, limit, offset, adapter, sql);
639
+ const total = await probeTotal(adapter, sql, params, limit);
640
+ return new DatabaseResult(rows, undefined, total, limit, offset, adapter, sql);
690
641
  } catch (e: any) {
691
642
  // v3.13.11 #49.2: fetch() records last_error like execute() does.
692
643
  this.lastError = e?.message ?? String(e);
@@ -741,7 +692,10 @@ export class Database {
741
692
  * SEPARATE trailing argument, never the params array.
742
693
  */
743
694
  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[];
695
+ // Routes through _fetchWithLimit, NOT fetch(), so `limit` stays verbatim.
696
+ // Going through fetch() would apply the 100-row cap and make a method
697
+ // called "fetchAll" quietly stop returning them all.
698
+ return (await this._fetchWithLimit(sql, params, limit, offset, opts)).records as T[];
745
699
  }
746
700
 
747
701
  /**
@@ -835,13 +789,52 @@ export class Database {
835
789
  * the WHERE clause. With neither a filter nor a primary key in `data` this
836
790
  * throws rather than silently changing nothing (audit feature 4, P1).
837
791
  */
838
- async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>, params?: unknown[]): Promise<DatabaseWriteResult> {
839
- let effectiveFilter = filter ?? {};
792
+ async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseWriteResult> {
793
+ let effectiveFilter: Record<string, unknown> | string = filter ?? {};
840
794
  let effectiveData = data;
841
795
 
842
- if (Object.keys(effectiveFilter).length === 0) {
796
+ // A string filter is the OTHER documented form ("id = ?" + params), so it
797
+ // must be tested as a string: Object.keys("id = ?") is ["0",..,"5"], which
798
+ // is non-empty by accident rather than by meaning — and an EMPTY string
799
+ // filter would then be treated as a real filter instead of falling through
800
+ // to the primary key.
801
+ const filterIsEmpty = typeof effectiveFilter === "string"
802
+ ? effectiveFilter.trim() === ""
803
+ : Object.keys(effectiveFilter).length === 0;
804
+
805
+ if (filterIsEmpty) {
843
806
  const pkColumns = await this.primaryKey(table);
844
- const missing = pkColumns.filter((c) => !(c in data));
807
+ // Resolve each key column to the caller's OWN key for it, matched
808
+ // case-insensitively.
809
+ //
810
+ // The engines disagree about identifier case BY DESIGN and always will:
811
+ // Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
812
+ // LOWER, MySQL and SQLite preserve what was typed. Introspection returns
813
+ // the ENGINE's spelling while `data` carries the caller's, so `c in data`
814
+ // failed on whichever engine folds the other way. A case-sensitivity bug,
815
+ // not a Firebird quirk - Firebird just made it visible first.
816
+ //
817
+ // Deliberately does NOT lower-case introspection output: that would
818
+ // special-case one engine and break a genuinely quoted mixed-case table.
819
+ // The WHERE is built from the ENGINE's column name and the CALLER's value.
820
+ const resolved: Record<string, string> = {};
821
+ const missing: string[] = [];
822
+ for (const col of pkColumns) {
823
+ const folded = String(col).toLowerCase();
824
+ const matches = Object.keys(data).filter((k) => k.toLowerCase() === folded);
825
+ if (matches.length > 1) {
826
+ // Ambiguity is refused, never guessed - choosing wrong here writes the
827
+ // WHERE clause of an UPDATE.
828
+ throw new Error(
829
+ `update was given more than one key for the primary-key column ${col}: ` +
830
+ `[${matches.slice().sort().join(", ")}] (table=${table}). These differ ` +
831
+ `only by case, so which one identifies the row is ambiguous - pass ` +
832
+ `exactly one, or pass an explicit filter.`,
833
+ );
834
+ }
835
+ if (matches.length === 1) resolved[col] = matches[0];
836
+ else missing.push(col);
837
+ }
845
838
  if (pkColumns.length === 0 || missing.length > 0) {
846
839
  throw new Error(
847
840
  `update requires a filter or the complete primary key in the data; pass ` +
@@ -856,8 +849,9 @@ export class Database {
856
849
  effectiveData = { ...data };
857
850
  const keyed: Record<string, unknown> = {};
858
851
  for (const col of pkColumns) {
859
- keyed[col] = effectiveData[col];
860
- delete effectiveData[col];
852
+ const callerKey = resolved[col];
853
+ keyed[col] = effectiveData[callerKey];
854
+ delete effectiveData[callerKey];
861
855
  }
862
856
  if (Object.keys(effectiveData).length === 0) {
863
857
  throw new Error(
@@ -879,10 +873,19 @@ export class Database {
879
873
  }
880
874
 
881
875
  /** 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> {
876
+ async delete(table: string, filter?: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseWriteResult> {
883
877
  const effectiveFilter = filter ?? {};
884
- if (!Array.isArray(effectiveFilter) && typeof effectiveFilter !== "string"
885
- && Object.keys(effectiveFilter).length === 0) {
878
+ // A BLANK string counts as no filter. The old guard skipped the emptiness
879
+ // test for anything typed string, so `delete(t, "")` fell through to the
880
+ // adapter, which renders an empty WHERE as `DELETE FROM "t"` — a silent
881
+ // whole-table delete through the very method that exists to make that
882
+ // impossible. truncate() is the explicit spelling.
883
+ const filterIsEmpty = Array.isArray(effectiveFilter)
884
+ ? effectiveFilter.length === 0
885
+ : typeof effectiveFilter === "string"
886
+ ? effectiveFilter.trim() === ""
887
+ : Object.keys(effectiveFilter).length === 0;
888
+ if (filterIsEmpty) {
886
889
  throw new Error(
887
890
  `delete requires a filter (table=${table}). To remove every row use truncate(${table}).`,
888
891
  );
@@ -1075,9 +1078,35 @@ export class Database {
1075
1078
  // (Database.execute_many delegating to adapter.execute_many's owns_txn guard).
1076
1079
  const owns = !this.inExplicitTransaction();
1077
1080
  if (owns) await adapterStartTransaction(adapter);
1081
+
1082
+ // ONE round-trip per CHUNK instead of one per ROW. Looping execute() here
1083
+ // pays a full network round-trip for every row: 500 rows took 9848ms on
1084
+ // PostgreSQL against 15.8ms as a single multi-row VALUES (625x), MySQL 216x,
1085
+ // MSSQL 121x. buildBatchInserts returns an empty array for anything it
1086
+ // cannot collapse safely — RETURNING, upserts, non-INSERT statements, ragged
1087
+ // rows, Firebird — and the row-at-a-time loop then runs unchanged.
1088
+ const batched = SQLTranslator.buildBatchInserts(sql, paramSets, this.dbType ?? "");
1089
+
1078
1090
  try {
1079
- for (const params of paramSets) {
1080
- results.push(await adapterExecute(adapter, sql, params));
1091
+ if (batched.length > 0) {
1092
+ let row = 0;
1093
+ for (const [chunkSql, chunkParams] of batched) {
1094
+ const result = await adapterExecute(adapter, chunkSql, chunkParams);
1095
+ // executeMany's contract is ONE RESULT PER ROW, and callers index into
1096
+ // it. Collapsing rows into chunks must not shorten the array, so each
1097
+ // row reports the result of the statement that actually wrote it.
1098
+ // Node is the only one of the four returning per-row results — Python,
1099
+ // PHP and Ruby return a count or a single DatabaseResult — so this is
1100
+ // the one place the collapse could have been observable.
1101
+ const rowsInChunk = chunkParams.length / (paramSets[0]?.length || 1);
1102
+ for (let i = 0; i < rowsInChunk && row < paramSets.length; i++, row++) {
1103
+ results.push(result);
1104
+ }
1105
+ }
1106
+ } else {
1107
+ for (const params of paramSets) {
1108
+ results.push(await adapterExecute(adapter, sql, params));
1109
+ }
1081
1110
  }
1082
1111
  if (owns) await adapterCommit(adapter);
1083
1112
  } catch (e) {
@@ -1416,21 +1445,31 @@ export class Database {
1416
1445
  * connected; SQLite connects lazily.
1417
1446
  */
1418
1447
  export async function createAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
1448
+ const adapter = await buildAdapterFromUrl(url, username, password);
1449
+ // Tag the adapter with WHICH DATABASE it is connected to. The query cache
1450
+ // folds this into every key, so two databases sharing one cache backend
1451
+ // cannot serve each other's rows. Set here because this is the single funnel
1452
+ // where a URL becomes an adapter.
1453
+ adapter.cacheIdentity = QueryCache.cacheIdentity(url);
1454
+ return adapter;
1455
+ }
1456
+
1457
+ async function buildAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
1419
1458
  const parsed = parseDatabaseUrl(url, username, password);
1420
1459
 
1421
- switch (parsed.type) {
1460
+ switch (parsed.engine) {
1422
1461
  case "sqlite": {
1423
1462
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
1424
- return new SQLiteAdapter(parsed.path ?? "./data/tina4.db");
1463
+ return new SQLiteAdapter(parsed.database || "./data/tina4.db");
1425
1464
  }
1426
1465
  case "postgres": {
1427
1466
  const { PostgresAdapter } = await import("./adapters/postgres.js");
1428
1467
  const adapter = new PostgresAdapter({
1429
- host: parsed.host,
1430
- port: parsed.port,
1431
- user: parsed.user,
1432
- password: parsed.password,
1433
- database: parsed.database,
1468
+ host: parsed.host ?? undefined,
1469
+ port: parsed.port ?? undefined,
1470
+ user: parsed.username ?? undefined,
1471
+ password: parsed.password ?? undefined,
1472
+ database: parsed.database || undefined,
1434
1473
  });
1435
1474
  await adapter.connect();
1436
1475
  return adapter;
@@ -1438,11 +1477,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1438
1477
  case "mysql": {
1439
1478
  const { MysqlAdapter } = await import("./adapters/mysql.js");
1440
1479
  const adapter = new MysqlAdapter({
1441
- host: parsed.host,
1442
- port: parsed.port,
1443
- user: parsed.user,
1444
- password: parsed.password,
1445
- database: parsed.database,
1480
+ host: parsed.host ?? undefined,
1481
+ port: parsed.port ?? undefined,
1482
+ user: parsed.username ?? undefined,
1483
+ password: parsed.password ?? undefined,
1484
+ database: parsed.database || undefined,
1446
1485
  });
1447
1486
  await adapter.connect();
1448
1487
  return adapter;
@@ -1450,11 +1489,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1450
1489
  case "mssql": {
1451
1490
  const { MssqlAdapter } = await import("./adapters/mssql.js");
1452
1491
  const adapter = new MssqlAdapter({
1453
- host: parsed.host,
1454
- port: parsed.port,
1455
- user: parsed.user,
1456
- password: parsed.password,
1457
- database: parsed.database,
1492
+ host: parsed.host ?? undefined,
1493
+ port: parsed.port ?? undefined,
1494
+ user: parsed.username ?? undefined,
1495
+ password: parsed.password ?? undefined,
1496
+ database: parsed.database || undefined,
1458
1497
  });
1459
1498
  await adapter.connect();
1460
1499
  return adapter;
@@ -1462,11 +1501,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1462
1501
  case "firebird": {
1463
1502
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
1464
1503
  const adapter = new FirebirdAdapter({
1465
- host: parsed.host,
1466
- port: parsed.port,
1467
- user: parsed.user,
1468
- password: parsed.password,
1469
- database: parsed.database,
1504
+ host: parsed.host ?? undefined,
1505
+ port: parsed.port ?? undefined,
1506
+ user: parsed.username ?? undefined,
1507
+ password: parsed.password ?? undefined,
1508
+ database: parsed.database || undefined,
1470
1509
  });
1471
1510
  await adapter.connect();
1472
1511
  return adapter;
@@ -1577,7 +1616,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1577
1616
  const parsed = parseDatabaseUrl(url, resolvedUser, resolvedPassword);
1578
1617
  const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
1579
1618
  const db = new Database(setAdapter(adapter));
1580
- db.setDbType(parsed.type);
1619
+ db.setDbType(parsed.engine);
1581
1620
  return exposeDb(db);
1582
1621
  }
1583
1622
 
@@ -1604,6 +1643,11 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1604
1643
  // default and a `{ type: "postgres" }` connection takes the SQLite getNextId
1605
1644
  // branch and crashes on the missing tina4_sequences table (#255).
1606
1645
  const finished = (adapter: DatabaseAdapter): Database => {
1646
+ // Same identity tag as the URL path above - a config-object connection is
1647
+ // just as capable of sharing a cache backend with another database.
1648
+ adapter.cacheIdentity = QueryCache.cacheIdentity(
1649
+ `${type}://${config?.host ?? ""}:${config?.port ?? ""}/${config?.database ?? config?.path ?? ""}`,
1650
+ );
1607
1651
  const db = new Database(setAdapter(adapter));
1608
1652
  db.setDbType(type);
1609
1653
  return exposeDb(db);