tina4-nodejs 3.13.92 → 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 (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  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 +33062 -29908
  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/devMailbox.ts +20 -44
  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 +7 -6
  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 +81 -13
  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 +109 -13
  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 +6 -9
  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 +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -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
  /**
@@ -484,6 +349,9 @@ export class Database {
484
349
  /** Factory for creating new adapters (used by pool) */
485
350
  private adapterFactory: (() => Promise<DatabaseAdapter>) | null = null;
486
351
 
352
+ /** table -> primary-key column name (or null), introspected once */
353
+ private _pkCache: Map<string, string[]> = new Map();
354
+
487
355
  /**
488
356
  * Whether a standalone write auto-commits. ON by default — a write made
489
357
  * outside an explicit transaction commits on its own connection before
@@ -571,7 +439,7 @@ export class Database {
571
439
  db.poolIndex = 0;
572
440
  db.adapter = null; // Don't use single-adapter path
573
441
  db.adapterFactory = async () => wrapWithCache(await createAdapterFromUrl(url, username, password), { sharedCache });
574
- db.dbType = parsed.type;
442
+ db.dbType = parsed.engine;
575
443
  return exposeDb(db);
576
444
  }
577
445
 
@@ -581,7 +449,7 @@ export class Database {
581
449
  const adapter = await createAdapterFromUrl(url, username, password);
582
450
  const wrapped = setAdapter(adapter);
583
451
  const db = new Database(wrapped);
584
- db.dbType = parsed.type;
452
+ db.dbType = parsed.engine;
585
453
  return exposeDb(db);
586
454
  }
587
455
 
@@ -672,7 +540,31 @@ export class Database {
672
540
  * the fallback resolves instantly). This is the breaking change that makes
673
541
  * the wrapper work uniformly across every engine.
674
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
+ */
675
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> {
676
568
  // v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
677
569
  // or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
678
570
  // `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
@@ -683,7 +575,8 @@ export class Database {
683
575
  // no store, run directly (mirrors the Python master's `no_cache`).
684
576
  const rows = await adapterFetch(adapter, sql, params, limit, offset, opts?.noCache);
685
577
  this.lastError = null;
686
- 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);
687
580
  } catch (e: any) {
688
581
  // v3.13.11 #49.2: fetch() records last_error like execute() does.
689
582
  this.lastError = e?.message ?? String(e);
@@ -691,6 +584,63 @@ export class Database {
691
584
  }
692
585
  }
693
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
+
694
644
  /**
695
645
  * Fetch a single row or null.
696
646
  *
@@ -738,7 +688,10 @@ export class Database {
738
688
  * SEPARATE trailing argument, never the params array.
739
689
  */
740
690
  async fetchAll<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<T[]> {
741
- 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[];
742
695
  }
743
696
 
744
697
  /**
@@ -787,28 +740,174 @@ export class Database {
787
740
  return result;
788
741
  }
789
742
 
790
- /** Update rows in a table matching filter. */
791
- async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>, params?: unknown[]): Promise<DatabaseWriteResult> {
743
+ /**
744
+ * The table's primary-key column, introspected once and cached.
745
+ *
746
+ * Uses the cross-engine getColumns() contract (v3.13.14, #48), which reports
747
+ * primaryKey per column on every adapter. Resolves to null when the table has
748
+ * no primary key or cannot be introspected.
749
+ */
750
+ async primaryKey(table: string): Promise<string[]> {
751
+ if (!this._pkCache.has(table)) {
752
+ let pk: string[] = [];
753
+ try {
754
+ const columns = await this.getColumns(table);
755
+ pk = columns.filter((c) => c.primaryKey).map((c) => c.name);
756
+ } catch {
757
+ pk = [];
758
+ }
759
+ this._pkCache.set(table, pk);
760
+ }
761
+ return this._pkCache.get(table) ?? [];
762
+ }
763
+
764
+ /**
765
+ * A failed write must be loud.
766
+ *
767
+ * The adapters catch a SQL error and return { success: false, affectedRows: 0 },
768
+ * so a filterless update produced invalid SQL ("... WHERE ") and reported
769
+ * nothing rather than raising. A caller who does not inspect the result
770
+ * believes the write landed (audit feature 4, P1).
771
+ */
772
+ private static assertWrote(result: DatabaseWriteResult, verb: string, table: string): DatabaseWriteResult {
773
+ if (result && (result as any).success === false) {
774
+ throw new Error(
775
+ `${verb} failed on ${table}: ${(result as any).error ?? "unknown error"}`,
776
+ );
777
+ }
778
+ return result;
779
+ }
780
+
781
+ /**
782
+ * Update rows. A write with no filter is an error, not a full-table write.
783
+ *
784
+ * With no explicit filter the primary key is taken out of `data` and used as
785
+ * the WHERE clause. With neither a filter nor a primary key in `data` this
786
+ * throws rather than silently changing nothing (audit feature 4, P1).
787
+ */
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 ?? {};
790
+ let effectiveData = data;
791
+
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) {
802
+ const pkColumns = await this.primaryKey(table);
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
+ }
834
+ if (pkColumns.length === 0 || missing.length > 0) {
835
+ throw new Error(
836
+ `update requires a filter or the complete primary key in the data; pass ` +
837
+ `filter explicitly to update multiple rows (table=${table}, ` +
838
+ `primary key=[${pkColumns.join(", ")}], missing from data=[${missing.join(", ")}]). ` +
839
+ `To empty a table use truncate(${table}).`,
840
+ );
841
+ }
842
+ // EVERY key column goes into the WHERE. A composite key built from only its
843
+ // first column would match every row sharing that value - the data-loss bug
844
+ // this method exists to prevent, reintroduced.
845
+ effectiveData = { ...data };
846
+ const keyed: Record<string, unknown> = {};
847
+ for (const col of pkColumns) {
848
+ const callerKey = resolved[col];
849
+ keyed[col] = effectiveData[callerKey];
850
+ delete effectiveData[callerKey];
851
+ }
852
+ if (Object.keys(effectiveData).length === 0) {
853
+ throw new Error(
854
+ `update was given only the primary key [${pkColumns.join(", ")}] and no ` +
855
+ `columns to set (table=${table})`,
856
+ );
857
+ }
858
+ effectiveFilter = keyed;
859
+ }
860
+
792
861
  const adapter = this.getNextAdapter();
793
862
  const result = (adapter as any).updateAsync
794
- ? await (adapter as any).updateAsync(table, data, filter ?? {}, params)
795
- : adapter.update(table, data, filter ?? {}, params);
863
+ ? await (adapter as any).updateAsync(table, effectiveData, effectiveFilter, params)
864
+ : adapter.update(table, effectiveData, effectiveFilter, params);
796
865
  if (this.autoCommit && !this.inExplicitTransaction()) {
797
866
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
798
867
  }
799
- return result;
868
+ return Database.assertWrote(result, "update", table);
800
869
  }
801
870
 
802
- /** Delete rows from a table matching filter. */
803
- async delete(table: string, filter?: Record<string, unknown>, params?: unknown[]): Promise<DatabaseWriteResult> {
871
+ /** Delete rows. A filterless delete throws; use truncate() to empty a table. */
872
+ async delete(table: string, filter?: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseWriteResult> {
873
+ const effectiveFilter = filter ?? {};
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) {
885
+ throw new Error(
886
+ `delete requires a filter (table=${table}). To remove every row use truncate(${table}).`,
887
+ );
888
+ }
889
+
804
890
  const adapter = this.getNextAdapter();
805
891
  const result = (adapter as any).deleteAsync
806
- ? await (adapter as any).deleteAsync(table, filter ?? {}, params)
807
- : adapter.delete(table, filter ?? {}, params);
892
+ ? await (adapter as any).deleteAsync(table, effectiveFilter, params)
893
+ : adapter.delete(table, effectiveFilter, params);
808
894
  if (this.autoCommit && !this.inExplicitTransaction()) {
809
895
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
810
896
  }
811
- return result;
897
+ return Database.assertWrote(result, "delete", table);
898
+ }
899
+
900
+ /** Remove every row. The explicit spelling of a whole-table delete. */
901
+ async truncate(table: string): Promise<DatabaseWriteResult> {
902
+ const adapter = this.getNextAdapter();
903
+ // The adapters' delete() already accepts a raw string WHERE clause.
904
+ const result = (adapter as any).deleteAsync
905
+ ? await (adapter as any).deleteAsync(table, "1 = 1", [])
906
+ : adapter.delete(table, "1 = 1" as any, []);
907
+ if (this.autoCommit && !this.inExplicitTransaction()) {
908
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
909
+ }
910
+ return Database.assertWrote(result, "truncate", table);
812
911
  }
813
912
 
814
913
  /** Close all database connections (pool or single). */
@@ -975,9 +1074,35 @@ export class Database {
975
1074
  // (Database.execute_many delegating to adapter.execute_many's owns_txn guard).
976
1075
  const owns = !this.inExplicitTransaction();
977
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
+
978
1086
  try {
979
- for (const params of paramSets) {
980
- 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
+ }
981
1106
  }
982
1107
  if (owns) await adapterCommit(adapter);
983
1108
  } catch (e) {
@@ -1316,21 +1441,31 @@ export class Database {
1316
1441
  * connected; SQLite connects lazily.
1317
1442
  */
1318
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> {
1319
1454
  const parsed = parseDatabaseUrl(url, username, password);
1320
1455
 
1321
- switch (parsed.type) {
1456
+ switch (parsed.engine) {
1322
1457
  case "sqlite": {
1323
1458
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
1324
- return new SQLiteAdapter(parsed.path ?? "./data/tina4.db");
1459
+ return new SQLiteAdapter(parsed.database || "./data/tina4.db");
1325
1460
  }
1326
1461
  case "postgres": {
1327
1462
  const { PostgresAdapter } = await import("./adapters/postgres.js");
1328
1463
  const adapter = new PostgresAdapter({
1329
- host: parsed.host,
1330
- port: parsed.port,
1331
- user: parsed.user,
1332
- password: parsed.password,
1333
- 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,
1334
1469
  });
1335
1470
  await adapter.connect();
1336
1471
  return adapter;
@@ -1338,11 +1473,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1338
1473
  case "mysql": {
1339
1474
  const { MysqlAdapter } = await import("./adapters/mysql.js");
1340
1475
  const adapter = new MysqlAdapter({
1341
- host: parsed.host,
1342
- port: parsed.port,
1343
- user: parsed.user,
1344
- password: parsed.password,
1345
- 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,
1346
1481
  });
1347
1482
  await adapter.connect();
1348
1483
  return adapter;
@@ -1350,11 +1485,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1350
1485
  case "mssql": {
1351
1486
  const { MssqlAdapter } = await import("./adapters/mssql.js");
1352
1487
  const adapter = new MssqlAdapter({
1353
- host: parsed.host,
1354
- port: parsed.port,
1355
- user: parsed.user,
1356
- password: parsed.password,
1357
- 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,
1358
1493
  });
1359
1494
  await adapter.connect();
1360
1495
  return adapter;
@@ -1362,11 +1497,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1362
1497
  case "firebird": {
1363
1498
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
1364
1499
  const adapter = new FirebirdAdapter({
1365
- host: parsed.host,
1366
- port: parsed.port,
1367
- user: parsed.user,
1368
- password: parsed.password,
1369
- 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,
1370
1505
  });
1371
1506
  await adapter.connect();
1372
1507
  return adapter;
@@ -1477,7 +1612,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1477
1612
  const parsed = parseDatabaseUrl(url, resolvedUser, resolvedPassword);
1478
1613
  const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
1479
1614
  const db = new Database(setAdapter(adapter));
1480
- db.setDbType(parsed.type);
1615
+ db.setDbType(parsed.engine);
1481
1616
  return exposeDb(db);
1482
1617
  }
1483
1618
 
@@ -1504,6 +1639,11 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1504
1639
  // default and a `{ type: "postgres" }` connection takes the SQLite getNextId
1505
1640
  // branch and crashes on the missing tina4_sequences table (#255).
1506
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
+ );
1507
1647
  const db = new Database(setAdapter(adapter));
1508
1648
  db.setDbType(type);
1509
1649
  return exposeDb(db);