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.
- package/CLAUDE.md +157 -28
- package/README.md +1 -1
- package/package.json +2 -1
- package/packages/cli/dist/bin.js +32418 -29638
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +32364 -29501
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +5 -4
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +9 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/orm/dist/index.js +22367 -19504
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +60 -24
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +237 -197
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +5 -6
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +2 -2
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -0
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +72 -26
- package/types/orm/src/databaseResult.d.ts +24 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +5 -2
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +14 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- 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
|
-
|
|
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
|
-
|
|
393
|
-
return this.adapter.
|
|
413
|
+
getTables(): string[] {
|
|
414
|
+
return this.adapter.getTables();
|
|
394
415
|
}
|
|
395
416
|
|
|
396
|
-
|
|
397
|
-
return this.adapter.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|