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
@@ -4,8 +4,10 @@
4
4
  * Install: npm install node-firebird
5
5
  * URL format: firebird://user:pass@host:port/path/to/database.fdb
6
6
  */
7
+ import { firebirdDialect, buildInsert, buildSetClause, buildWhereClause } from "./sqlDialect.js";
7
8
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
8
9
  import { SQLTranslator } from "../sqlTranslator.js";
10
+ import { connectTimeoutMillis, withConnectTimeout } from "../connectTimeout.js";
9
11
  import { createRequire } from "node:module";
10
12
 
11
13
  let firebird: any = null;
@@ -135,6 +137,66 @@ export interface FirebirdConfig {
135
137
  charset?: string;
136
138
  }
137
139
 
140
+ /**
141
+ * Quote an identifier the way Firebird actually stores it: UPPERCASE.
142
+ *
143
+ * Firebird folds an UNQUOTED identifier to upper case and treats a QUOTED one as
144
+ * case-sensitive. So after the ordinary `CREATE TABLE probe_t (...)` the table is
145
+ * PROBE_T, and `INSERT INTO "probe_t"` matches nothing:
146
+ *
147
+ * Dynamic SQL Error / Table unknown / probe_t
148
+ *
149
+ * That broke the insert path against every conventionally-created table, columns
150
+ * included. A name the caller has ALREADY quoted is passed through untouched,
151
+ * which is the escape hatch for a genuinely case-sensitive `CREATE TABLE "orders"`.
152
+ */
153
+ export function fbQuote(name: string): string {
154
+ if (!name) return name;
155
+ const trimmed = name.trim();
156
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed;
157
+ return `"${trimmed.toUpperCase().replace(/"/g, '""')}"`;
158
+ }
159
+
160
+ /**
161
+ * Firebird's dialect for the shared CRUD builder: its own upper-casing quoter,
162
+ * plain "?" markers. Defined here rather than in sqlDialect.ts because the
163
+ * quoting rule is a Firebird fact, not a generic one.
164
+ */
165
+ const FB_DIALECT = firebirdDialect(fbQuote);
166
+
167
+ /**
168
+ * Firebird's stored column name, folded back only when it was folded.
169
+ *
170
+ * Firebird's identifier folding is ASYMMETRIC. An unquoted `AS x` is stored
171
+ * UPPERCASE, so the driver hands back "X" where every other engine Tina4
172
+ * supports gives "x" — PostgreSQL folds to lower, and MySQL, SQLite and MSSQL
173
+ * preserve what you wrote. Portable code reading row.x broke on Firebird alone.
174
+ *
175
+ * A QUOTED `AS "MyCol"` is stored exactly as written, and that case is
176
+ * deliberate — the caller asked for it — so it is left alone. Folding
177
+ * unconditionally makes a mixed-case key unreachable, the same asymmetric trap
178
+ * that made tableExists miss quoted tables.
179
+ *
180
+ * So: fold back only a name carrying no lowercase letter, the only thing
181
+ * unquoted folding can produce. A quoted ALL-CAPS name is genuinely
182
+ * indistinguishable from a folded one and is lowercased too; that ambiguity is
183
+ * Firebird's, and it is the one spelling this cannot round-trip.
184
+ */
185
+ export function firebirdColumnName(raw: string): string {
186
+ const name = raw.trim();
187
+ return name === name.toUpperCase() ? name.toLowerCase() : name;
188
+ }
189
+
190
+ /** Apply {@link firebirdColumnName} across one row's keys. */
191
+ function foldColumnNames<T>(row: T): T {
192
+ if (row === null || typeof row !== "object" || Array.isArray(row)) return row;
193
+ const out: Record<string, unknown> = {};
194
+ for (const [k, v] of Object.entries(row as Record<string, unknown>)) {
195
+ out[firebirdColumnName(k)] = v;
196
+ }
197
+ return out as T;
198
+ }
199
+
138
200
  export class FirebirdAdapter implements DatabaseAdapter {
139
201
  private db: any = null;
140
202
  private transaction: any = null;
@@ -191,15 +253,20 @@ export class FirebirdAdapter implements DatabaseAdapter {
191
253
  fbConfig.database = normalizeFirebirdDbIdentifier(fbConfig.database);
192
254
  }
193
255
 
194
- await new Promise<void>((resolve, reject) => {
195
- fb.attach(fbConfig, (err: Error | null, db: any) => {
196
- if (err) reject(err);
197
- else {
198
- this.db = db;
199
- resolve();
200
- }
201
- });
202
- });
256
+ // node-firebird has NO connect-timeout option of its own, so there is no
257
+ // driver timer to translate and the outer bound is the ONLY thing standing
258
+ // between a silent driver and a permanently hung boot. This is the adapter
259
+ // the 16-minute probe measured.
260
+ this.db = await withConnectTimeout(
261
+ () => new Promise<any>((resolve, reject) => {
262
+ fb.attach(fbConfig, (err: Error | null, db: any) => (err ? reject(err) : resolve(db)));
263
+ }),
264
+ connectTimeoutMillis(),
265
+ fbConfig.host,
266
+ fbConfig.port,
267
+ // Answered after we gave up: detach so the socket does not outlive the boot.
268
+ (db: any) => { try { db?.detach?.(() => {}); } catch { /* already gone */ } },
269
+ );
203
270
  }
204
271
 
205
272
  private parseUrl(url: string): { host?: string; port?: number; user?: string; password?: string; database?: string } {
@@ -312,7 +379,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
312
379
  async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
313
380
  this.ensureConnected();
314
381
  const rows = await this.queryPromise(sql, params);
315
- return (rows as T[]).map(row => this.decodeBlobs(row));
382
+ return (rows as T[]).map(row => this.decodeBlobs(foldColumnNames(row)));
316
383
  }
317
384
 
318
385
  /** Ensure BLOB columns are readable — node-firebird may return callback-based
@@ -361,69 +428,81 @@ export class FirebirdAdapter implements DatabaseAdapter {
361
428
  if (Array.isArray(data)) {
362
429
  if (data.length === 0) return { success: true, affectedRows: 0 };
363
430
  const keys = Object.keys(data[0]);
364
- const placeholders = keys.map(() => "?").join(", ");
365
- const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
431
+ const sql = buildInsert(FB_DIALECT, table, keys);
366
432
  const paramsList = data.map((row) => keys.map((k) => row[k]));
367
- try {
368
- const result = await this.executeManyAsync(sql, paramsList);
369
- return { success: true, affectedRows: result.totalAffected, lastId: result.lastId };
370
- } catch (e) {
371
- return { success: false, affectedRows: 0, error: (e as Error).message };
372
- }
433
+ const result = await this.executeManyAsync(sql, paramsList);
434
+ return { success: true, affectedRows: result.totalAffected, lastId: result.lastId };
373
435
  }
374
436
 
375
437
  const keys = Object.keys(data);
376
- const placeholders = keys.map(() => "?").join(", ");
377
- const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
438
+ const sql = buildInsert(FB_DIALECT, table, keys);
378
439
  const values = Object.values(data);
379
440
 
380
- try {
381
- await this.executePromise(sql, values);
382
- // Firebird doesn't have a generic last_insert_idreturn success without id
383
- return {
384
- success: true,
385
- affectedRows: 1,
386
- };
387
- } catch (e) {
388
- return { success: false, affectedRows: 0, error: (e as Error).message };
389
- }
441
+ // FAIL LOUD, like fetch/execute and the other three frameworks: a bad statement
442
+ // RAISES and never returns a falsy result. Swallowing it into {success:false}
443
+ // is what hid a wholly broken write path the caller awaited a resolved
444
+ // promise, read back zero rows, and no error surfaced anywhere.
445
+ await this.executePromise(sql, values);
446
+ // Firebird doesn't have a generic last_insert_id — return success without id
447
+ return { success: true, affectedRows: 1 };
390
448
  }
391
449
 
392
450
  update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
393
451
  throw new Error("Use updateAsync() for Firebird.");
394
452
  }
395
453
 
396
- async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
454
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
397
455
  this.ensureConnected();
398
- const setClauses = Object.keys(data).map((k) => `"${k}" = ?`).join(", ");
399
- const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
400
- const sql = `UPDATE "${table}" SET ${setClauses} WHERE ${whereClauses}`;
401
- const values = [...Object.values(data), ...Object.values(filter)];
402
-
403
- try {
404
- await this.executePromise(sql, values);
456
+ // Identifiers go through fbQuote, exactly like insertAsync. Firebird folds an
457
+ // UNQUOTED identifier to uppercase, so a conventional `CREATE TABLE probe_t`
458
+ // stores PROBE_T/ID — and a hand-rolled `"${k}"` emits lowercase-quoted "id",
459
+ // which is a DIFFERENT, non-existent column. update and delete were therefore
460
+ // broken on every conventionally-created Firebird table while insert worked.
461
+ const setClauses = buildSetClause(FB_DIALECT, Object.keys(data));
462
+
463
+ // A raw WHERE fragment + params is half the write_path contract's filter
464
+ // form. Without this branch Object.keys("id = ?") yields the STRING INDICES
465
+ // ["0","1",...] and the statement addresses columns that do not exist.
466
+ // Firebird already uses `?`, so the fragment needs no rewriting.
467
+ if (typeof filter === "string") {
468
+ const where = filter ? ` WHERE ${filter}` : "";
469
+ await this.executePromise(
470
+ `UPDATE ${fbQuote(table)} SET ${setClauses}${where}`,
471
+ [...Object.values(data), ...(params ?? [])],
472
+ );
405
473
  return { success: true, affectedRows: 1 };
406
- } catch (e) {
407
- return { success: false, affectedRows: 0, error: (e as Error).message };
408
474
  }
475
+
476
+ const whereClauses = buildWhereClause(FB_DIALECT, Object.keys(filter));
477
+ const sql = `UPDATE ${FB_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
478
+ const values = [...Object.values(data), ...Object.values(filter)];
479
+
480
+ await this.executePromise(sql, values);
481
+ return { success: true, affectedRows: 1 };
409
482
  }
410
483
 
411
484
  delete(table: string, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
412
485
  throw new Error("Use deleteAsync() for Firebird.");
413
486
  }
414
487
 
415
- async deleteAsync(table: string, filter: Record<string, unknown>): Promise<DatabaseResult> {
488
+ async deleteAsync(table: string, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
416
489
  this.ensureConnected();
417
- const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
418
- const sql = `DELETE FROM "${table}" WHERE ${whereClauses}`;
419
- const values = Object.values(filter);
420
490
 
421
- try {
422
- await this.executePromise(sql, values);
491
+ // See updateAsync: truncate() calls this with "1 = 1", which walked the
492
+ // string as an object — db.truncate() was broken outright.
493
+ if (typeof filter === "string") {
494
+ const where = filter ? ` WHERE ${filter}` : "";
495
+ await this.executePromise(`DELETE FROM ${fbQuote(table)}${where}`, params ?? []);
423
496
  return { success: true, affectedRows: 1 };
424
- } catch (e) {
425
- return { success: false, affectedRows: 0, error: (e as Error).message };
426
497
  }
498
+
499
+ // Same fbQuote policy as insert/update — see updateAsync.
500
+ const whereClauses = buildWhereClause(FB_DIALECT, Object.keys(filter));
501
+ const sql = `DELETE FROM ${FB_DIALECT.quote(table)} WHERE ${whereClauses}`;
502
+ const values = Object.values(filter);
503
+
504
+ await this.executePromise(sql, values);
505
+ return { success: true, affectedRows: 1 };
427
506
  }
428
507
 
429
508
  startTransaction(): void {
@@ -477,7 +556,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
477
556
  });
478
557
  }
479
558
 
480
- tables(): string[] {
559
+ getTables(): string[] {
481
560
  throw new Error("Use tablesAsync() for Firebird.");
482
561
  }
483
562
 
@@ -491,7 +570,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
491
570
  });
492
571
  }
493
572
 
494
- columns(table: string): ColumnInfo[] {
573
+ getColumns(table: string): ColumnInfo[] {
495
574
  throw new Error("Use columnsAsync() for Firebird.");
496
575
  }
497
576
 
@@ -503,6 +582,29 @@ export class FirebirdAdapter implements DatabaseAdapter {
503
582
  WHERE RF.RDB$RELATION_NAME = ?`,
504
583
  [table.toUpperCase()],
505
584
  );
585
+
586
+ // The primary key comes from the constraint catalogue. This used to be
587
+ // hardcoded false for every column, so the key was invisible to anything
588
+ // that introspects it -- including the filterless-write guard that lifts the
589
+ // PK out of `data`. Same bug the Python master and the Ruby driver carried.
590
+ const pkNames = new Set<string>();
591
+ try {
592
+ const pkRows = await this.queryAsync<Record<string, unknown>>(
593
+ `SELECT SG.RDB$FIELD_NAME FROM RDB$INDEX_SEGMENTS SG
594
+ JOIN RDB$RELATION_CONSTRAINTS RC ON SG.RDB$INDEX_NAME = RC.RDB$INDEX_NAME
595
+ WHERE RC.RDB$CONSTRAINT_TYPE = 'PRIMARY KEY' AND RC.RDB$RELATION_NAME = ?
596
+ ORDER BY SG.RDB$FIELD_POSITION`,
597
+ [table.toUpperCase()],
598
+ );
599
+ for (const row of pkRows) {
600
+ const raw = (row["RDB$FIELD_NAME"] ?? row["rdb$field_name"] ?? "") as string;
601
+ const trimmed = String(raw).trim().toUpperCase();
602
+ if (trimmed) pkNames.add(trimmed);
603
+ }
604
+ } catch {
605
+ // A table with no primary key is not an error.
606
+ }
607
+
506
608
  return rows.map((r) => {
507
609
  const name = (r["RDB$FIELD_NAME"] ?? r["rdb$field_name"] ?? "") as string;
508
610
  return {
@@ -510,7 +612,9 @@ export class FirebirdAdapter implements DatabaseAdapter {
510
612
  type: firebirdFieldTypeToString(r["RDB$FIELD_TYPE"] ?? r["rdb$field_type"]),
511
613
  nullable: (r["RDB$NULL_FLAG"] ?? r["rdb$null_flag"]) === null,
512
614
  default: r["RDB$DEFAULT_SOURCE"] ?? r["rdb$default_source"],
513
- primaryKey: false,
615
+ primaryKey: pkNames.has(
616
+ (typeof name === "string" ? name.trim() : String(name).trim()).toUpperCase(),
617
+ ),
514
618
  };
515
619
  });
516
620
  }
@@ -531,10 +635,28 @@ export class FirebirdAdapter implements DatabaseAdapter {
531
635
  throw new Error("Use tableExistsAsync() for Firebird.");
532
636
  }
533
637
 
638
+ /**
639
+ * Is this table present, under either spelling Firebird could have stored?
640
+ *
641
+ * Firebird's folding rule is ASYMMETRIC:
642
+ * CREATE TABLE foo -> stored as FOO (unquoted folds to UPPER)
643
+ * CREATE TABLE "Foo" -> stored as Foo (quoted keeps its case)
644
+ *
645
+ * So upper-casing is CORRECT for the unquoted case - the common one - and
646
+ * WRONG for a quoted mixed-case table, which is a real thing on Firebird.
647
+ * Dropping the upper-case would not fix that, it would invert which half is
648
+ * broken.
649
+ *
650
+ * tableExistsAsync("Foo") is genuinely AMBIGUOUS: the caller could mean the
651
+ * quoted `Foo` or the unquoted `FOO`. Match EITHER. Do not "simplify" this
652
+ * back to one comparison - that is the bug it replaces, where a quoted
653
+ * mixed-case table read as absent and createTableAsync's idempotency guard
654
+ * (below) never fired.
655
+ */
534
656
  async tableExistsAsync(name: string): Promise<boolean> {
535
657
  const rows = await this.queryAsync<Record<string, unknown>>(
536
- "SELECT RDB$RELATION_NAME FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ?",
537
- [name.toUpperCase()],
658
+ "SELECT RDB$RELATION_NAME FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = ? OR RDB$RELATION_NAME = ?",
659
+ [name, name.toUpperCase()],
538
660
  );
539
661
  return rows.length > 0;
540
662
  }
@@ -552,7 +674,12 @@ export class FirebirdAdapter implements DatabaseAdapter {
552
674
 
553
675
  for (const [colName, def] of Object.entries(columns)) {
554
676
  const sqlType = fieldTypeToFirebird(def);
555
- const parts = [`"${colName}" ${sqlType}`];
677
+ // fbQuote, not a hand-rolled `"${colName}"` — DDL must agree with the write
678
+ // path or the ORM creates a table it cannot write to. Lowercase-quoted "id"
679
+ // is a case-sensitive column; every insert/update/delete addresses ID.
680
+ // tableExistsAsync looks up name.toUpperCase() and would not have found it
681
+ // either, so createTableAsync would re-run and fail already-exists.
682
+ const parts = [`${fbQuote(colName)} ${sqlType}`];
556
683
 
557
684
  if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
558
685
  if (def.required && !def.primaryKey) parts.push("NOT NULL");
@@ -568,7 +695,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
568
695
  colDefs.push(parts.join(" "));
569
696
  }
570
697
 
571
- const sql = `CREATE TABLE "${name}" (${colDefs.join(", ")})`;
698
+ const sql = `CREATE TABLE ${fbQuote(name)} (${colDefs.join(", ")})`;
572
699
  await this.executeAsync(sql);
573
700
 
574
701
  // Create sequences and triggers for auto-increment columns
@@ -579,7 +706,7 @@ export class FirebirdAdapter implements DatabaseAdapter {
579
706
 
580
707
  await this.executeAsync(`CREATE SEQUENCE "${seqName}"`);
581
708
  await this.executeAsync(
582
- `CREATE TRIGGER "${trigName}" FOR "${name}" ACTIVE BEFORE INSERT POSITION 0 AS BEGIN IF (NEW."${colName}" IS NULL) THEN NEW."${colName}" = NEXT VALUE FOR "${seqName}"; END`,
709
+ `CREATE TRIGGER "${trigName}" FOR ${fbQuote(name)} ACTIVE BEFORE INSERT POSITION 0 AS BEGIN IF (NEW.${fbQuote(colName)} IS NULL) THEN NEW.${fbQuote(colName)} = NEXT VALUE FOR "${seqName}"; END`,
583
710
  );
584
711
  }
585
712
  }
@@ -5,6 +5,7 @@
5
5
  * URL format: mongodb://host:port/dbname or mongodb+srv://user:pass@host/dbname
6
6
  */
7
7
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
8
+ import { connectTarget, connectTimeoutMillis, driverConnectTimeoutMillis, withConnectTimeout } from "../connectTimeout.js";
8
9
 
9
10
  export interface MongoConfig {
10
11
  host?: string;
@@ -309,8 +310,28 @@ export class MongodbAdapter implements DatabaseAdapter {
309
310
  );
310
311
  }
311
312
 
312
- this.client = new MongoClient(this._connectionString);
313
- await this.client.connect();
313
+ // The driver's own budget is 30s (serverSelectionTimeoutMS and
314
+ // connectTimeoutMS both). It is set from the Tina4 budget so ONE variable
315
+ // governs, and omitted when the bound is disabled so the driver keeps its
316
+ // own 30s exactly as before.
317
+ const budgetMs = connectTimeoutMillis();
318
+ const driverMs = driverConnectTimeoutMillis(budgetMs);
319
+ const timeoutOptions = driverMs === null
320
+ ? {}
321
+ : { serverSelectionTimeoutMS: driverMs, connectTimeoutMS: driverMs };
322
+
323
+ const { host, port } = connectTarget(this._connectionString, 27017);
324
+ await withConnectTimeout(
325
+ () => {
326
+ this.client = new MongoClient(this._connectionString, timeoutOptions);
327
+ return this.client.connect();
328
+ },
329
+ budgetMs,
330
+ host,
331
+ port,
332
+ // Answered after we gave up: close it so the pool does not outlive the boot.
333
+ () => { void Promise.resolve(this.client?.close()).catch(() => { /* already gone */ }); },
334
+ );
314
335
  this.db = this.client.db(this._dbName);
315
336
  }
316
337
 
@@ -556,7 +577,7 @@ export class MongodbAdapter implements DatabaseAdapter {
556
577
  this._inTransaction = false;
557
578
  }
558
579
 
559
- tables(): string[] {
580
+ getTables(): string[] {
560
581
  throw new Error("Use tablesAsync() for MongoDB — async adapter requires async methods.");
561
582
  }
562
583
 
@@ -566,7 +587,7 @@ export class MongodbAdapter implements DatabaseAdapter {
566
587
  return collections.map((c: any) => c.name as string);
567
588
  }
568
589
 
569
- columns(table: string): ColumnInfo[] {
590
+ getColumns(table: string): ColumnInfo[] {
570
591
  throw new Error("Use columnsAsync() for MongoDB — async adapter requires async methods.");
571
592
  }
572
593
 
@@ -4,8 +4,10 @@
4
4
  * Install: npm install tedious
5
5
  * URL format: mssql://user:pass@host:port/database
6
6
  */
7
+ import { ANSI_DIALECT, MSSQL_DIALECT, buildInsert, buildSetClause, buildWhereClause } from "./sqlDialect.js";
7
8
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
8
9
  import { SQLTranslator } from "../sqlTranslator.js";
10
+ import { connectTimeoutMillis, driverConnectTimeoutMillis, withConnectTimeout } from "../connectTimeout.js";
9
11
  import { createRequire } from "node:module";
10
12
 
11
13
  let tedious: any = null;
@@ -38,6 +40,14 @@ export interface MssqlConfig {
38
40
  }
39
41
 
40
42
  export class MssqlAdapter implements DatabaseAdapter {
43
+ /**
44
+ * Postgres, MySQL and MSSQL all REQUIRE a name for a derived table, so
45
+ * the COUNT probe in Database.countProbe wraps as
46
+ * `FROM (sql) AS _count_query`. SQLite and Firebird leave this unset and
47
+ * get no alias - Firebird rejects `AS` in that position.
48
+ */
49
+ readonly countSubqueryAlias = "_count_query";
50
+
41
51
  private connection: any = null;
42
52
  private _lastInsertId: number | bigint | null = null;
43
53
  // True between startTransactionAsync() and commit/rollback. executeManyAsync
@@ -52,6 +62,14 @@ export class MssqlAdapter implements DatabaseAdapter {
52
62
  const tediousModule = requireTedious();
53
63
  const Connection = tediousModule.Connection;
54
64
 
65
+ // tedious has its own connectTimeout (15s by default). It is set from the
66
+ // Tina4 budget so ONE variable governs - otherwise a configured 60s would
67
+ // still fail at tedious's 15s, with tedious's message, and the variable
68
+ // would be a lie. Omitted when the bound is disabled, restoring the old 15s.
69
+ const budgetMs = connectTimeoutMillis();
70
+ const driverMs = driverConnectTimeoutMillis(budgetMs);
71
+ const timeoutOption = driverMs === null ? {} : { connectTimeout: driverMs };
72
+
55
73
  let tediousConfig: any;
56
74
 
57
75
  if (typeof this.config === "string") {
@@ -70,6 +88,7 @@ export class MssqlAdapter implements DatabaseAdapter {
70
88
  port: parsed.port ?? 1433,
71
89
  trustServerCertificate: true,
72
90
  encrypt: false,
91
+ ...timeoutOption,
73
92
  },
74
93
  };
75
94
  } else {
@@ -87,19 +106,29 @@ export class MssqlAdapter implements DatabaseAdapter {
87
106
  port: this.config.port ?? 1433,
88
107
  trustServerCertificate: true,
89
108
  encrypt: false,
109
+ ...timeoutOption,
90
110
  ...this.config.options,
91
111
  },
92
112
  };
93
113
  }
94
114
 
95
- await new Promise<void>((resolve, reject) => {
96
- this.connection = new Connection(tediousConfig);
97
- this.connection.on("connect", (err: Error | null) => {
98
- if (err) reject(err);
99
- else resolve();
100
- });
101
- this.connection.connect();
102
- });
115
+ // Inside the thunk so the Tina4 clock starts before tedious arms its own
116
+ // connectTimer (connection.js, at the top of its connect flow).
117
+ await withConnectTimeout(
118
+ () => new Promise<void>((resolve, reject) => {
119
+ this.connection = new Connection(tediousConfig);
120
+ this.connection.on("connect", (err: Error | null) => {
121
+ if (err) reject(err);
122
+ else resolve();
123
+ });
124
+ this.connection.connect();
125
+ }),
126
+ budgetMs,
127
+ tediousConfig.server ?? "localhost",
128
+ tediousConfig.options?.port ?? 1433,
129
+ // Answered after we gave up: close it so the socket does not outlive the boot.
130
+ () => { try { this.connection?.close?.(); } catch { /* already gone */ } },
131
+ );
103
132
  }
104
133
 
105
134
  private parseUrl(url: string): { host: string; port?: number; user?: string; password?: string; database?: string } {
@@ -166,9 +195,15 @@ export class MssqlAdapter implements DatabaseAdapter {
166
195
  });
167
196
  }
168
197
 
169
- /** Convert ? placeholders to @p0, @p1, ... for tedious. */
170
- private convertPlaceholders(sql: string): string {
171
- let count = 0;
198
+ /**
199
+ * Convert ? placeholders to @p0, @p1, ... for tedious.
200
+ *
201
+ * `startAt` lets a caller that has already consumed N placeholders (an UPDATE
202
+ * whose SET values are @p0..@p{N-1}) continue the numbering into a raw WHERE
203
+ * fragment instead of restarting at @p0.
204
+ */
205
+ private convertPlaceholders(sql: string, startAt = 0): string {
206
+ let count = startAt;
172
207
  return sql.replace(/\?/g, () => {
173
208
  return `@p${count++}`;
174
209
  });
@@ -272,8 +307,8 @@ export class MssqlAdapter implements DatabaseAdapter {
272
307
  const keys = Object.keys(data[0]);
273
308
  // `?` placeholders — executeManyAsync -> executeAsync runs convertPlaceholders,
274
309
  // which rewrites them to @p0, @p1, ... for tedious.
275
- const placeholders = keys.map(() => "?").join(", ");
276
- const sql = `INSERT INTO [${table}] ([${keys.join("], [")}]) VALUES (${placeholders})`;
310
+ // The batch path binds through executeMany, which converts "?" itself.
311
+ const sql = buildInsert({ quote: MSSQL_DIALECT.quote, marker: ANSI_DIALECT.marker }, table, keys);
277
312
  const paramsList = data.map((row) => keys.map((k) => row[k]));
278
313
  try {
279
314
  const result = await this.executeManyAsync(sql, paramsList);
@@ -285,8 +320,9 @@ export class MssqlAdapter implements DatabaseAdapter {
285
320
  }
286
321
 
287
322
  const keys = Object.keys(data);
288
- const placeholders = keys.map((_, i) => `@p${i}`).join(", ");
289
- const sql = `INSERT INTO [${table}] ([${keys.join("], [")}]) VALUES (${placeholders}); SELECT SCOPE_IDENTITY() AS id`;
323
+ // startAt 0: MSSQL BINDS by the marker name, so @p must start where the
324
+ // binding loop starts. Shifting to 1 would name parameters that do not exist.
325
+ const sql = buildInsert(MSSQL_DIALECT, table, keys, "; SELECT SCOPE_IDENTITY() AS id", 0);
290
326
  const values = Object.values(data);
291
327
 
292
328
  try {
@@ -311,15 +347,33 @@ export class MssqlAdapter implements DatabaseAdapter {
311
347
  throw new Error("Use updateAsync() for MSSQL.");
312
348
  }
313
349
 
314
- async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
350
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
315
351
  this.ensureConnected();
316
352
  const dataKeys = Object.keys(data);
317
- const filterKeys = Object.keys(filter);
318
353
  let paramIndex = 0;
354
+ const setClauses = buildSetClause(MSSQL_DIALECT, dataKeys, paramIndex);
355
+ paramIndex += dataKeys.length;
356
+
357
+ // A raw WHERE fragment + params is half the write_path contract's filter
358
+ // form. Without this branch Object.keys("id = ?") yields the STRING INDICES
359
+ // ["0","1",...], producing `WHERE [0] = @p1 AND [1] = @p2` — SQL Server then
360
+ // reports an invalid column name '0'.
361
+ if (typeof filter === "string") {
362
+ const where = filter ? ` WHERE ${this.convertPlaceholders(filter, paramIndex)}` : "";
363
+ const sql = `UPDATE ${MSSQL_DIALECT.quote(table)} SET ${setClauses}${where}`;
364
+ const values = [...Object.values(data), ...(params ?? [])];
365
+ try {
366
+ const result = await this.execSqlPromise(sql, values);
367
+ return { success: true, affectedRows: result.rowCount };
368
+ } catch (e) {
369
+ return { success: false, affectedRows: 0, error: (e as Error).message };
370
+ }
371
+ }
319
372
 
320
- const setClauses = dataKeys.map((k) => `[${k}] = @p${paramIndex++}`).join(", ");
321
- const whereClauses = filterKeys.map((k) => `[${k}] = @p${paramIndex++}`).join(" AND ");
322
- const sql = `UPDATE [${table}] SET ${setClauses} WHERE ${whereClauses}`;
373
+ const filterKeys = Object.keys(filter);
374
+ const whereClauses = buildWhereClause(MSSQL_DIALECT, filterKeys, paramIndex);
375
+ paramIndex += filterKeys.length;
376
+ const sql = `UPDATE ${MSSQL_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
323
377
  const values = [...Object.values(data), ...Object.values(filter)];
324
378
 
325
379
  try {
@@ -334,12 +388,28 @@ export class MssqlAdapter implements DatabaseAdapter {
334
388
  throw new Error("Use deleteAsync() for MSSQL.");
335
389
  }
336
390
 
337
- async deleteAsync(table: string, filter: Record<string, unknown>): Promise<DatabaseResult> {
391
+ async deleteAsync(table: string, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
338
392
  this.ensureConnected();
393
+
394
+ // See updateAsync: truncate() calls this with "1 = 1", which became
395
+ // `WHERE [0] = @p0 AND [1] = @p1 ...` — db.truncate() was broken outright.
396
+ if (typeof filter === "string") {
397
+ const sql = filter
398
+ ? `DELETE FROM [${table}] WHERE ${this.convertPlaceholders(filter)}`
399
+ : `DELETE FROM [${table}]`;
400
+ try {
401
+ const result = await this.execSqlPromise(sql, params ?? []);
402
+ return { success: true, affectedRows: result.rowCount };
403
+ } catch (e) {
404
+ return { success: false, affectedRows: 0, error: (e as Error).message };
405
+ }
406
+ }
407
+
339
408
  const filterKeys = Object.keys(filter);
340
409
  let paramIndex = 0;
341
- const whereClauses = filterKeys.map((k) => `[${k}] = @p${paramIndex++}`).join(" AND ");
342
- const sql = `DELETE FROM [${table}] WHERE ${whereClauses}`;
410
+ const whereClauses = buildWhereClause(MSSQL_DIALECT, filterKeys, paramIndex);
411
+ paramIndex += filterKeys.length;
412
+ const sql = `DELETE FROM ${MSSQL_DIALECT.quote(table)} WHERE ${whereClauses}`;
343
413
  const values = Object.values(filter);
344
414
 
345
415
  try {
@@ -389,7 +459,7 @@ export class MssqlAdapter implements DatabaseAdapter {
389
459
  this._inTransaction = false;
390
460
  }
391
461
 
392
- tables(): string[] {
462
+ getTables(): string[] {
393
463
  throw new Error("Use tablesAsync() for MSSQL.");
394
464
  }
395
465
 
@@ -400,7 +470,7 @@ export class MssqlAdapter implements DatabaseAdapter {
400
470
  return rows.map((r) => r.TABLE_NAME);
401
471
  }
402
472
 
403
- columns(table: string): ColumnInfo[] {
473
+ getColumns(table: string): ColumnInfo[] {
404
474
  throw new Error("Use columnsAsync() for MSSQL.");
405
475
  }
406
476
 
@@ -413,17 +483,32 @@ export class MssqlAdapter implements DatabaseAdapter {
413
483
  DATA_TYPE: string;
414
484
  IS_NULLABLE: string;
415
485
  COLUMN_DEFAULT: string | null;
486
+ is_primary: number;
416
487
  }>(
417
- "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS " +
418
- "WHERE TABLE_NAME = ? AND (? IS NULL OR TABLE_SCHEMA = ?)",
419
- [tbl, schema, schema],
488
+ `SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT,
489
+ CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS is_primary
490
+ FROM INFORMATION_SCHEMA.COLUMNS c
491
+ LEFT JOIN (
492
+ SELECT ku.COLUMN_NAME
493
+ FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
494
+ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
495
+ ON tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
496
+ WHERE tc.TABLE_NAME = ? AND (? IS NULL OR tc.TABLE_SCHEMA = ?)
497
+ AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
498
+ ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME
499
+ WHERE c.TABLE_NAME = ? AND (? IS NULL OR c.TABLE_SCHEMA = ?)
500
+ ORDER BY c.ORDINAL_POSITION`,
501
+ [tbl, schema, schema, tbl, schema, schema],
420
502
  );
421
503
  return rows.map((r) => ({
422
504
  name: r.COLUMN_NAME,
423
505
  type: r.DATA_TYPE,
424
506
  nullable: r.IS_NULLABLE === "YES",
425
507
  default: r.COLUMN_DEFAULT,
426
- primaryKey: false,
508
+ // Same hole PostgreSQL had: hardcoded false meant primaryKey(table)
509
+ // introspected NOTHING on SQL Server, so the feature-4 filterless-write
510
+ // guard rejected every PK-keyed update. Ported from the Python master.
511
+ primaryKey: Number(r.is_primary) === 1,
427
512
  }));
428
513
  }
429
514