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
@@ -94,8 +94,23 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
94
94
  private backendPromise: Promise<CacheBackend | null> | null = null;
95
95
  private backendName: string;
96
96
 
97
+ /**
98
+ * WHICH DATABASE this wrapper caches for, folded into every cache key.
99
+ * Empty only for an adapter built outside the URL/config funnels, which then
100
+ * behaves exactly as before rather than colliding with a tagged one.
101
+ *
102
+ * Optional-chained on the ADAPTER, not just the property. `setAdapter(null)`
103
+ * is the documented reset idiom (migrateCli.test.ts uses it to clear ORM
104
+ * state between cases) and it reaches here through wrapWithCache. Before the
105
+ * identity field existed the constructor only STORED the adapter, so a null
106
+ * passed through harmlessly; reading `adapter.cacheIdentity` turned that
107
+ * reset into "Cannot read properties of null".
108
+ */
109
+ private readonly identity: string;
110
+
97
111
  constructor(adapter: DatabaseAdapter, options: CachedAdapterOptions = {}) {
98
112
  this.adapter = adapter;
113
+ this.identity = adapter?.cacheIdentity ?? "";
99
114
  this.cachePersistent = options.persistent ?? isTruthy(process.env.TINA4_DB_CACHE);
100
115
  // Request-scoped cache defaults to OFF (opt-in). A request-scoped cache
101
116
  // defaulting ON is a footgun: a `SELECT MAX(id)` (or generator read) right
@@ -312,7 +327,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
312
327
  // where every ORM read flows through the cached db.fetch(). Same store, same
313
328
  // counters, flushed on writes.
314
329
  if (this.enabled) {
315
- const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined);
330
+ const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined, this.identity);
316
331
  const cached = this.cache.get<T[]>(key);
317
332
  if (cached !== undefined) {
318
333
  this.hits++;
@@ -332,7 +347,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
332
347
  // master's `no_cache`). Counters are left untouched so a bypass read isn't
333
348
  // misreported as a hit or a miss.
334
349
  if (this.enabled && !noCache) {
335
- const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined);
350
+ const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined, this.identity);
336
351
  const cached = this.cache.get<T[]>(key);
337
352
  if (cached !== undefined) {
338
353
  this.hits++;
@@ -348,7 +363,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
348
363
 
349
364
  fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[], noCache?: boolean): T | null {
350
365
  if (this.enabled && !noCache) {
351
- const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined);
366
+ const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined, this.identity);
352
367
  const cached = this.cache.get<T | null>(key);
353
368
  if (cached !== undefined) {
354
369
  this.hits++;
@@ -367,14 +382,20 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
367
382
  return this.adapter.insert(table, data);
368
383
  }
369
384
 
370
- update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): DatabaseResult {
385
+ // `params` MUST be forwarded. This wrapper sits in front of EVERY adapter, so
386
+ // dropping it silently unbinds a string filter: `delete(t, "id = ?", [2])`
387
+ // reached the adapter as `delete(t, "id = ?")`, ran `DELETE ... WHERE id = ?`
388
+ // with nothing bound, matched no row, and returned
389
+ // { success: true, affectedRows: 0 } — a silent no-op on a documented calling
390
+ // form, caught by the shared write-path contract.
391
+ update(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): DatabaseResult {
371
392
  if (this.enabled) this.invalidate();
372
- return this.adapter.update(table, data, filter);
393
+ return this.adapter.update(table, data, filter, params);
373
394
  }
374
395
 
375
- delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
396
+ delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): DatabaseResult {
376
397
  if (this.enabled) this.invalidate();
377
- return this.adapter.delete(table, filter);
398
+ return this.adapter.delete(table, filter, params);
378
399
  }
379
400
 
380
401
  startTransaction(): void {
@@ -389,12 +410,12 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
389
410
  this.adapter.rollback();
390
411
  }
391
412
 
392
- tables(): string[] {
393
- return this.adapter.tables();
413
+ getTables(): string[] {
414
+ return this.adapter.getTables();
394
415
  }
395
416
 
396
- columns(table: string): ColumnInfo[] {
397
- return this.adapter.columns(table);
417
+ getColumns(table: string): ColumnInfo[] {
418
+ return this.adapter.getColumns(table);
398
419
  }
399
420
 
400
421
  lastInsertId(): number | bigint | string | null {
@@ -437,7 +458,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
437
458
  // `noCache` bypasses both cache layers for this one call — no lookup, no
438
459
  // store, run directly (mirrors the Python master's `no_cache`).
439
460
  if (this.enabled && !noCache) {
440
- const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined);
461
+ const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined, this.identity);
441
462
  // Persistent distributed backend is AUTHORITATIVE (mirrors Python, where a
442
463
  // configured _cache_backend bypasses the in-process dict). This keeps
443
464
  // cross-instance write-invalidation deterministic: a write clears the
@@ -469,7 +490,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
469
490
  : this.adapter.fetchOne<T>(sql, params);
470
491
  // `noCache` bypasses both cache layers for this one call (see fetchAsync).
471
492
  if (this.enabled && !noCache) {
472
- const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined);
493
+ const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined, this.identity);
473
494
  if (this.usesPersistentBackend()) {
474
495
  const shared = await this.backendGetOne<T>(key);
475
496
  if (shared !== undefined) { this.hits++; return shared.row; }
@@ -496,7 +517,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
496
517
  ? await (this.adapter as any).queryAsync(sql, params)
497
518
  : this.adapter.query<T>(sql, params);
498
519
  if (this.enabled) {
499
- const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined);
520
+ const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined, this.identity);
500
521
  if (this.usesPersistentBackend()) {
501
522
  const shared = await this.backendGetRows<T>(key);
502
523
  if (shared !== undefined) { this.hits++; return shared; }
@@ -532,18 +553,21 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
532
553
  : this.adapter.insert(table, data);
533
554
  }
534
555
 
535
- async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): Promise<DatabaseResult> {
556
+ // The SYNC fallback must forward `params` too. An adapter without an async
557
+ // variant (sqlite) took this branch, so a string filter arrived unbound — the
558
+ // same silent no-op as above, on the default engine.
559
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
536
560
  if (this.enabled) await this.invalidateAsync();
537
561
  return (this.adapter as any).updateAsync
538
562
  ? await (this.adapter as any).updateAsync(table, data, filter, params)
539
- : this.adapter.update(table, data, filter);
563
+ : this.adapter.update(table, data, filter, params);
540
564
  }
541
565
 
542
566
  async deleteAsync(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseResult> {
543
567
  if (this.enabled) await this.invalidateAsync();
544
568
  return (this.adapter as any).deleteAsync
545
569
  ? await (this.adapter as any).deleteAsync(table, filter, params)
546
- : this.adapter.delete(table, filter as Record<string, unknown>);
570
+ : this.adapter.delete(table, filter, params);
547
571
  }
548
572
 
549
573
  async startTransactionAsync(): Promise<void> {
@@ -570,13 +594,13 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
570
594
  async tablesAsync(): Promise<string[]> {
571
595
  return (this.adapter as any).tablesAsync
572
596
  ? await (this.adapter as any).tablesAsync()
573
- : this.adapter.tables();
597
+ : this.adapter.getTables();
574
598
  }
575
599
 
576
600
  async columnsAsync(table: string): Promise<ColumnInfo[]> {
577
601
  return (this.adapter as any).columnsAsync
578
602
  ? await (this.adapter as any).columnsAsync(table)
579
- : this.adapter.columns(table);
603
+ : this.adapter.getColumns(table);
580
604
  }
581
605
 
582
606
  async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
@@ -0,0 +1,265 @@
1
+ /**
2
+ * TINA4_DATABASE_CONNECT_TIMEOUT - bound a database connect attempt.
3
+ *
4
+ * THE DEFECT, measured 2026-08-06. The Firebird adapter awaited `fb.attach()` in
5
+ * a bare promise with no timeout, so a driver that never calls back hung the app
6
+ * on connect with no log, no error and no signal. A probe reproduced exactly
7
+ * that against a socket that accepts and never replies: 16 minutes at 0.0% CPU,
8
+ * a process that looked alive to every health check and served nothing.
9
+ *
10
+ * THE SHARED CONTRACT, identical in all four frameworks:
11
+ *
12
+ * name: TINA4_DATABASE_CONNECT_TIMEOUT
13
+ * unit: SECONDS
14
+ * default: 10
15
+ * <= 0: disables the bound (unbounded, the old behaviour)
16
+ * garbage: warn and use 10
17
+ * on expiry: throw an error naming the host, the port, the elapsed seconds,
18
+ * and the variable that tunes it
19
+ *
20
+ * TWO LAYERS, because one is not enough:
21
+ *
22
+ * 1. The driver's own knob where it has one (`connectionTimeoutMillis` on pg,
23
+ * `connectTimeout` on mysql2 and tedious, `serverSelectionTimeoutMS` on the
24
+ * Mongo driver). Without this the variable would be a lie on those
25
+ * adapters: tedious defaults to 15s and Mongo to 30s, so a configured 60s
26
+ * would still fail early with the driver's message rather than ours.
27
+ * 2. `withConnectTimeout()` around the whole attempt. Without this the bound
28
+ * would not exist at all where the driver has no knob - node-firebird, the
29
+ * measured case - and a driver knob covers only the phase the driver
30
+ * thinks it covers, never the whole handshake.
31
+ *
32
+ * THE DRIVER'S TIMER IS MEANT TO WIN, and `withConnectTimeout` TRANSLATES what
33
+ * it raises rather than racing it. Node shipped the other shape first: the knob
34
+ * was set to the bound PLUS a 1000ms grace so an outer timer expired first and
35
+ * Node's own message surfaced. Python, PHP and Ruby all landed on the translator
36
+ * instead, leaving Node the lone outlier of four, for three reasons this file
37
+ * now accepts:
38
+ *
39
+ * - it inflates the operator's configured N into N + grace, so
40
+ * TINA4_DATABASE_CONNECT_TIMEOUT=10 silently means 11 to the driver;
41
+ * - it throws away the driver's own diagnosis, which is frequently the more
42
+ * specific one ("connection to server at 127.0.0.1, port 5432 failed:
43
+ * timeout expired" says more than a bare bound expiring);
44
+ * - where a watchdog is involved it abandons work the driver could have
45
+ * unwound itself.
46
+ *
47
+ * So the knob is set to the bound EXACTLY (rounded up, never to 0), our clock
48
+ * starts BEFORE the driver arms its own, and a driver failure that took at least
49
+ * that long is re-thrown in the framework's words with the driver's error kept
50
+ * as `cause`. N means N. The outer timer stays as the BACKSTOP for the phases a
51
+ * knob does not cover and the adapters that have no knob at all; because it is
52
+ * armed after the driver's, it only ever fires when the driver's did not.
53
+ *
54
+ * DISABLED (<= 0) MEANS THE OLD BEHAVIOUR, exactly. Neither layer is applied,
55
+ * so each driver keeps whatever it did before this existed.
56
+ */
57
+ import { Log } from "../../core/src/index.js";
58
+
59
+ /** Seconds. Long enough for a cold cross-region connect, short enough to page. */
60
+ export const DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS = 10;
61
+
62
+ /**
63
+ * Resolve the connect budget in MILLISECONDS, or `null` when the bound is
64
+ * disabled.
65
+ *
66
+ * Milliseconds because every consumer needs them: `setTimeout` and all four
67
+ * driver knobs are in ms. Seconds are the operator-facing unit, so the variable
68
+ * is read as seconds and converted once, here.
69
+ *
70
+ * Call this ONCE per connect and pass the result down - it is the only resolver,
71
+ * so calling it twice would warn twice about one typo.
72
+ */
73
+ export function connectTimeoutMillis(): number | null {
74
+ const raw = process.env.TINA4_DATABASE_CONNECT_TIMEOUT;
75
+ if (raw === undefined || raw.trim() === "") {
76
+ return DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS * 1000;
77
+ }
78
+ const seconds = Number(raw);
79
+ if (!Number.isFinite(seconds)) {
80
+ Log.warning(
81
+ `TINA4_DATABASE_CONNECT_TIMEOUT="${raw}" is not a valid number of seconds - `
82
+ + `using ${DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS}`,
83
+ );
84
+ return DEFAULT_DATABASE_CONNECT_TIMEOUT_SECONDS * 1000;
85
+ }
86
+ // <= 0 is a deliberate opt-out, not a typo: unbounded, the old behaviour.
87
+ return seconds <= 0 ? null : seconds * 1000;
88
+ }
89
+
90
+ /**
91
+ * Clock slack when deciding whether a failed connect was OUR bound expiring.
92
+ *
93
+ * THE DECISION IS MADE BY ELAPSED TIME, NEVER BY MATCHING THE DRIVER'S TEXT.
94
+ * The four clients word an expiry four different ways - pg `timeout expired`,
95
+ * mysql2 `connect ETIMEDOUT`, tedious `Failed to connect to ... in 2000ms`,
96
+ * Mongo `Server selection timed out after 2000 ms` - and a marker table would
97
+ * drift the moment any of them reworded, then MISS. A missed timeout is the
98
+ * whole defect this file exists to prevent, so nothing here reads the message.
99
+ *
100
+ * WHY 50ms, AND WHY NOT ZERO. Python and PHP use no tolerance at all, PHP having
101
+ * measured its four C clients OVERSHOOTING their deadline by ~3ms at a 3s bound -
102
+ * a client that measures its own elapsed time can never report early. Node's
103
+ * knobs are not those: all four are plain JS `setTimeout` calls (pg `client.js`,
104
+ * mysql2 `base/connection.js`, tedious `connection.js`, and the Mongo driver's
105
+ * selection loop), and libuv's loop time is coarse, so a Node timer CAN fire
106
+ * before `performance.now()` agrees the budget has passed. MEASURED, 60 rounds
107
+ * at a 200ms budget:
108
+ *
109
+ * Linux x64, Node v24.18.0 earliest -0.7125ms (fires EARLY)
110
+ * darwin arm64, Node v24.9.0 earliest +0.0872ms (never early)
111
+ *
112
+ * Zero would therefore be a real miss on Linux. 50ms is a ~70x margin on the
113
+ * measured worst case, and still 0.5% of the 10s default. Ruby's 250ms is for a
114
+ * different problem - libpq's `connect_timeout` is INTEGER SECONDS and reads a
115
+ * 10s bound back as 9.998s - which no Node client has.
116
+ *
117
+ * It does NOT inflate the operator's N: it only widens what COUNTS as the bound
118
+ * expiring, never how long anything waits. It errs deliberately: over-translating
119
+ * a genuine fast failure that lands within 50ms of the bound still shows the
120
+ * operator the driver's real error (`Driver reported:`, plus `cause`), whereas
121
+ * under-translating hands them a bare driver message naming no variable.
122
+ */
123
+ export const CONNECT_TIMEOUT_TOLERANCE_MS = 50;
124
+
125
+ /**
126
+ * The value for a driver's own connect-timeout option, from the Tina4 budget.
127
+ * `null` in, `null` out - a disabled bound sets no driver option at all.
128
+ *
129
+ * ROUNDED UP, AND NEVER TO 0. Up, so the driver's timer can never expire before
130
+ * our clock has reached the bound - that ordering is the whole basis for the
131
+ * elapsed-time test in `withConnectTimeout`, and rounding down would break it.
132
+ * Never 0, because three of the four knobs read 0 as WAIT FOREVER: pg does
133
+ * `connectionTimeoutMillis || 0` then `if (> 0)`, mysql2 does
134
+ * `if (this.config.connectTimeout)`, and libpq (the same trap Python and Ruby
135
+ * name) treats `connect_timeout=0` as no limit. A sub-millisecond bound must
136
+ * therefore floor at 1ms rather than silently disabling the bound being set.
137
+ */
138
+ export function driverConnectTimeoutMillis(budgetMs: number | null): number | null {
139
+ return budgetMs === null ? null : Math.max(1, Math.ceil(budgetMs));
140
+ }
141
+
142
+ /**
143
+ * Best-effort host/port for the DIAGNOSTIC, from either a config object or a
144
+ * connection URL. Never used to connect - the adapter has already done that with
145
+ * its own parsing, and this must not become a second, divergent parser that
146
+ * decides where anything dials.
147
+ */
148
+ export function connectTarget(
149
+ config: { host?: string; port?: number } | string,
150
+ defaultPort: number,
151
+ ): { host: string; port: number | string } {
152
+ if (typeof config === "string") {
153
+ try {
154
+ const url = new URL(config);
155
+ return { host: url.hostname || "localhost", port: url.port ? Number(url.port) : defaultPort };
156
+ } catch {
157
+ return { host: "localhost", port: defaultPort };
158
+ }
159
+ }
160
+ return { host: config.host ?? "localhost", port: config.port ?? defaultPort };
161
+ }
162
+
163
+ /**
164
+ * The ONE error a timed-out connect carries: it names the host, the port, the
165
+ * seconds actually spent, and the variable that tunes it - the four things an
166
+ * operator needs to tell "my bound fired" apart from "the database rejected me",
167
+ * which no driver's own timeout message provides.
168
+ *
169
+ * The driver's diagnosis is kept BOTH ways: appended to the text, because that
170
+ * is the line that reaches a log, and as `cause`, matching Python's `__cause__`
171
+ * and Ruby's cause chain. Its whitespace is flattened first - pg's is
172
+ * multi-line, and a log line that wraps is a log line that gets grepped wrong.
173
+ */
174
+ function connectTimedOutError(
175
+ host: string,
176
+ port: number | string,
177
+ elapsedMs: number,
178
+ budgetMs: number,
179
+ cause?: unknown,
180
+ ): Error {
181
+ const reported = cause instanceof Error ? cause.message : cause === undefined ? "" : String(cause);
182
+ const detail = reported ? ` Driver reported: ${reported.replace(/\s+/g, " ").trim()}` : "";
183
+ return new Error(
184
+ `Database connect to ${host}:${port} timed out after ${(elapsedMs / 1000).toFixed(1)}s `
185
+ + `(TINA4_DATABASE_CONNECT_TIMEOUT=${budgetMs / 1000} seconds; set it to 0 to wait `
186
+ + `indefinitely).${detail}`,
187
+ cause === undefined ? undefined : { cause },
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Bound a driver connect, and name the bound when it expires.
193
+ *
194
+ * @param attempt a THUNK that starts the driver's connect. It is a thunk, not a
195
+ * promise, so OUR CLOCK STARTS FIRST - everything that touches
196
+ * the driver must run inside it. That ordering is load-bearing:
197
+ * the driver arms its own timer somewhere in here (mysql2 arms
198
+ * its at the END OF ITS CONSTRUCTOR, not in `connect()`), and
199
+ * only by starting first can we know that the driver's timer
200
+ * cannot expire before `elapsed` has reached the bound.
201
+ * @param budgetMs from `connectTimeoutMillis()`; `null` runs `attempt` untouched
202
+ * @param host named in the error - an operator needs to know WHICH server hung
203
+ * @param port named in the error alongside the host
204
+ * @param abandon called if the driver answers AFTER we gave up, with whatever it
205
+ * produced. Nobody will ever use that connection, so the adapter
206
+ * closes it here rather than leaking a socket for the life of the
207
+ * process - a connect that is retried every 10s would otherwise
208
+ * accumulate one abandoned connection per attempt, forever.
209
+ *
210
+ * TWO WAYS OUT, both wearing the same message. Normally the DRIVER's timer fires
211
+ * first (it was armed first) and we TRANSLATE its failure; where the driver has
212
+ * no knob, or its knob did not cover the phase that hung, our own timer fires as
213
+ * the backstop and there is no driver diagnosis to report.
214
+ */
215
+ export function withConnectTimeout<T>(
216
+ attempt: () => Promise<T>,
217
+ budgetMs: number | null,
218
+ host: string,
219
+ port: number | string,
220
+ abandon?: (arrived: T) => void,
221
+ ): Promise<T> {
222
+ // performance.now() is MONOTONIC. Date.now() is not: an NTP step backwards
223
+ // mid-connect would shrink `elapsed`, the test below would read a real timeout
224
+ // as an ordinary failure, and the bare driver message would surface - the exact
225
+ // defect. Python uses time.monotonic() and Ruby CLOCK_MONOTONIC for this.
226
+ const startedAt = performance.now();
227
+ const elapsedMs = (): number => performance.now() - startedAt;
228
+
229
+ // Unbounded: no clock, no knob, no wrapper - exactly the old behaviour.
230
+ if (budgetMs === null) return attempt();
231
+
232
+ const started = attempt();
233
+
234
+ return new Promise<T>((resolve, reject) => {
235
+ let expired = false;
236
+
237
+ const timer = setTimeout(() => {
238
+ expired = true;
239
+ reject(connectTimedOutError(host, port, elapsedMs(), budgetMs));
240
+ }, budgetMs);
241
+
242
+ started.then(
243
+ (arrived) => {
244
+ clearTimeout(timer);
245
+ if (expired) abandon?.(arrived);
246
+ else resolve(arrived);
247
+ },
248
+ (failure: unknown) => {
249
+ clearTimeout(timer);
250
+ // Already rejected with the timeout; the driver's late error has no
251
+ // caller left to reach. Attaching this handler is what keeps it from
252
+ // surfacing as an unhandled rejection.
253
+ if (expired) return;
254
+ const elapsed = elapsedMs();
255
+ // Faster than the bound is a REAL error - a refused connection, bad
256
+ // credentials, an unknown database - and is re-thrown untouched.
257
+ reject(
258
+ elapsed < budgetMs - CONNECT_TIMEOUT_TOLERANCE_MS
259
+ ? failure
260
+ : connectTimedOutError(host, port, elapsed, budgetMs, failure),
261
+ );
262
+ },
263
+ );
264
+ });
265
+ }