tina4-nodejs 3.13.92 → 3.13.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -4,8 +4,10 @@
4
4
  * Install: npm install mysql2
5
5
  * URL format: mysql://user:pass@host:port/database
6
6
  */
7
+ import { MYSQL_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 { connectTarget, connectTimeoutMillis, driverConnectTimeoutMillis, withConnectTimeout } from "../connectTimeout.js";
9
11
  import { createRequire } from "node:module";
10
12
 
11
13
  let mysql2: any = null;
@@ -37,6 +39,14 @@ export interface MysqlConfig {
37
39
  }
38
40
 
39
41
  export class MysqlAdapter implements DatabaseAdapter {
42
+ /**
43
+ * Postgres, MySQL and MSSQL all REQUIRE a name for a derived table, so
44
+ * the COUNT probe in Database.countProbe wraps as
45
+ * `FROM (sql) AS _count_query`. SQLite and Firebird leave this unset and
46
+ * get no alias - Firebird rejects `AS` in that position.
47
+ */
48
+ readonly countSubqueryAlias = "_count_query";
49
+
40
50
  private connection: any = null;
41
51
  private _lastInsertId: number | bigint | null = null;
42
52
  // True between startTransactionAsync() and commit/rollback. executeManyAsync
@@ -50,33 +60,55 @@ export class MysqlAdapter implements DatabaseAdapter {
50
60
  async connect(): Promise<void> {
51
61
  const mod = requireMysql2();
52
62
 
53
- if (typeof this.config === "string") {
54
- // Parse URL: mysql://user:pass@host:port/database
55
- const url = new URL(this.config);
56
- this.connection = mod.createConnection({
57
- host: url.hostname || "localhost",
58
- port: url.port ? parseInt(url.port, 10) : 3306,
59
- user: decodeURIComponent(url.username),
60
- password: decodeURIComponent(url.password),
61
- database: url.pathname.replace(/^\//, ""),
62
- });
63
- } else {
64
- this.connection = mod.createConnection({
65
- host: this.config.host ?? "localhost",
66
- port: this.config.port ?? 3306,
67
- user: this.config.user,
68
- password: this.config.password,
69
- database: this.config.database,
70
- });
71
- }
72
-
73
- // Promisify the connection
74
- await new Promise<void>((resolve, reject) => {
75
- this.connection.connect((err: Error | null) => {
76
- if (err) reject(err);
77
- else resolve();
78
- });
79
- });
63
+ // mysql2 has its own connectTimeout (10s by default). It is set from the
64
+ // Tina4 budget so ONE variable governs, and omitted when the bound is
65
+ // disabled so the driver keeps exactly the behaviour it had before.
66
+ const budgetMs = connectTimeoutMillis();
67
+ const driverMs = driverConnectTimeoutMillis(budgetMs);
68
+ const timeoutOption = driverMs === null ? {} : { connectTimeout: driverMs };
69
+
70
+ const { host, port } = connectTarget(this.config, 3306);
71
+ // createConnection() IS the arming point - mysql2 starts its connectTimeout
72
+ // at the END OF ITS CONSTRUCTOR (base/connection.js), not inside connect() -
73
+ // so it has to sit inside the thunk for the Tina4 clock to start first.
74
+ await withConnectTimeout(
75
+ () => {
76
+ if (typeof this.config === "string") {
77
+ // Parse URL: mysql://user:pass@host:port/database
78
+ const url = new URL(this.config);
79
+ this.connection = mod.createConnection({
80
+ host: url.hostname || "localhost",
81
+ port: url.port ? parseInt(url.port, 10) : 3306,
82
+ user: decodeURIComponent(url.username),
83
+ password: decodeURIComponent(url.password),
84
+ database: url.pathname.replace(/^\//, ""),
85
+ ...timeoutOption,
86
+ });
87
+ } else {
88
+ this.connection = mod.createConnection({
89
+ host: this.config.host ?? "localhost",
90
+ port: this.config.port ?? 3306,
91
+ user: this.config.user,
92
+ password: this.config.password,
93
+ database: this.config.database,
94
+ ...timeoutOption,
95
+ });
96
+ }
97
+
98
+ // Promisify the connection
99
+ return new Promise<void>((resolve, reject) => {
100
+ this.connection.connect((err: Error | null) => {
101
+ if (err) reject(err);
102
+ else resolve();
103
+ });
104
+ });
105
+ },
106
+ budgetMs,
107
+ host,
108
+ port,
109
+ // Answered after we gave up: destroy it so the socket does not outlive the boot.
110
+ () => { try { this.connection?.destroy?.(); } catch { /* already gone */ } },
111
+ );
80
112
  }
81
113
 
82
114
  private ensureConnected(): void {
@@ -195,8 +227,7 @@ export class MysqlAdapter implements DatabaseAdapter {
195
227
  if (Array.isArray(data)) {
196
228
  if (data.length === 0) return { success: true, affectedRows: 0 };
197
229
  const keys = Object.keys(data[0]);
198
- const placeholders = keys.map(() => "?").join(", ");
199
- const sql = `INSERT INTO \`${table}\` (\`${keys.join("`, `")}\`) VALUES (${placeholders})`;
230
+ const sql = buildInsert(MYSQL_DIALECT, table, keys);
200
231
  const paramsList = data.map((row) => keys.map((k) => row[k]));
201
232
  try {
202
233
  const result = await this.executeManyAsync(sql, paramsList);
@@ -208,8 +239,7 @@ export class MysqlAdapter implements DatabaseAdapter {
208
239
  }
209
240
 
210
241
  const keys = Object.keys(data);
211
- const placeholders = keys.map(() => "?").join(", ");
212
- const sql = `INSERT INTO \`${table}\` (\`${keys.join("`, `")}\`) VALUES (${placeholders})`;
242
+ const sql = buildInsert(MYSQL_DIALECT, table, keys);
213
243
  const values = Object.values(data);
214
244
 
215
245
  try {
@@ -229,11 +259,29 @@ export class MysqlAdapter implements DatabaseAdapter {
229
259
  throw new Error("Use updateAsync() for MySQL.");
230
260
  }
231
261
 
232
- async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
262
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
233
263
  this.ensureConnected();
234
- const setClauses = Object.keys(data).map((k) => `\`${k}\` = ?`).join(", ");
235
- const whereClauses = Object.keys(filter).map((k) => `\`${k}\` = ?`).join(" AND ");
236
- const sql = `UPDATE \`${table}\` SET ${setClauses} WHERE ${whereClauses}`;
264
+ const setClauses = buildSetClause(MYSQL_DIALECT, Object.keys(data));
265
+
266
+ // A raw WHERE fragment + params is half the write_path contract's filter
267
+ // form. Without this branch Object.keys("id = ?") yields the STRING INDICES
268
+ // ["0","1",...], producing `WHERE \`0\` = ? AND \`1\` = ?` — MySQL then
269
+ // reports an unknown column '0'. MySQL already uses `?`, so the fragment
270
+ // needs no placeholder rewriting.
271
+ if (typeof filter === "string") {
272
+ const where = filter ? ` WHERE ${filter}` : "";
273
+ const sql = `UPDATE ${MYSQL_DIALECT.quote(table)} SET ${setClauses}${where}`;
274
+ const values = [...Object.values(data), ...(params ?? [])];
275
+ try {
276
+ const result = await this.queryPromise(sql, values);
277
+ return { success: true, affectedRows: result.affectedRows ?? 0 };
278
+ } catch (e) {
279
+ return { success: false, affectedRows: 0, error: (e as Error).message };
280
+ }
281
+ }
282
+
283
+ const whereClauses = buildWhereClause(MYSQL_DIALECT, Object.keys(filter));
284
+ const sql = `UPDATE ${MYSQL_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
237
285
  const values = [...Object.values(data), ...Object.values(filter)];
238
286
 
239
287
  try {
@@ -248,10 +296,25 @@ export class MysqlAdapter implements DatabaseAdapter {
248
296
  throw new Error("Use deleteAsync() for MySQL.");
249
297
  }
250
298
 
251
- async deleteAsync(table: string, filter: Record<string, unknown>): Promise<DatabaseResult> {
299
+ async deleteAsync(table: string, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
252
300
  this.ensureConnected();
253
- const whereClauses = Object.keys(filter).map((k) => `\`${k}\` = ?`).join(" AND ");
254
- const sql = `DELETE FROM \`${table}\` WHERE ${whereClauses}`;
301
+
302
+ // See updateAsync: truncate() calls this with "1 = 1", which became
303
+ // `WHERE \`0\` = ? AND \`1\` = ? ...` — db.truncate() was broken outright.
304
+ if (typeof filter === "string") {
305
+ const sql = filter
306
+ ? `DELETE FROM \`${table}\` WHERE ${filter}`
307
+ : `DELETE FROM \`${table}\``;
308
+ try {
309
+ const result = await this.queryPromise(sql, params ?? []);
310
+ return { success: true, affectedRows: result.affectedRows ?? 0 };
311
+ } catch (e) {
312
+ return { success: false, affectedRows: 0, error: (e as Error).message };
313
+ }
314
+ }
315
+
316
+ const whereClauses = buildWhereClause(MYSQL_DIALECT, Object.keys(filter));
317
+ const sql = `DELETE FROM ${MYSQL_DIALECT.quote(table)} WHERE ${whereClauses}`;
255
318
  const values = Object.values(filter);
256
319
 
257
320
  try {
@@ -289,7 +352,7 @@ export class MysqlAdapter implements DatabaseAdapter {
289
352
  this._inTransaction = false;
290
353
  }
291
354
 
292
- tables(): string[] {
355
+ getTables(): string[] {
293
356
  throw new Error("Use tablesAsync() for MySQL.");
294
357
  }
295
358
 
@@ -298,7 +361,7 @@ export class MysqlAdapter implements DatabaseAdapter {
298
361
  return rows.map((r) => Object.values(r)[0]);
299
362
  }
300
363
 
301
- columns(table: string): ColumnInfo[] {
364
+ getColumns(table: string): ColumnInfo[] {
302
365
  throw new Error("Use columnsAsync() for MySQL.");
303
366
  }
304
367
 
@@ -8,8 +8,11 @@
8
8
  * The connection string after stripping the "odbc:///" prefix is passed
9
9
  * directly to odbc.connect(), so any valid ODBC connection string works.
10
10
  */
11
+ import { ANSI_DIALECT, buildInsert, buildSetClause, buildWhereClause } from "./sqlDialect.js";
11
12
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
12
13
  import { createRequire } from "node:module";
14
+ import { SQLTranslator } from "../sqlTranslator.js";
15
+ import { connectTimeoutMillis, withConnectTimeout } from "../connectTimeout.js";
13
16
 
14
17
  let odbcModule: any = null;
15
18
 
@@ -53,6 +56,21 @@ export class OdbcAdapter implements DatabaseAdapter {
53
56
  return this.config.connectionString;
54
57
  }
55
58
 
59
+ /**
60
+ * The address for a diagnostic message. ODBC hides it inside an opaque
61
+ * driver keyword string, so this reads the standard keywords and falls back to
62
+ * the data-source name - it is never used to connect, only to say which target
63
+ * hung.
64
+ */
65
+ private describeTarget(): { host: string; port: number | string } {
66
+ const connectionString = this.getConnectionString();
67
+ const host = /(?:^|;)\s*(?:SERVER|SERVERNAME|HOST)\s*=\s*([^;]+)/i.exec(connectionString)?.[1]?.trim()
68
+ ?? /(?:^|;)\s*DSN\s*=\s*([^;]+)/i.exec(connectionString)?.[1]?.trim()
69
+ ?? "the ODBC data source";
70
+ const port = /(?:^|;)\s*PORT\s*=\s*(\d+)/i.exec(connectionString)?.[1] ?? "unspecified";
71
+ return { host, port };
72
+ }
73
+
56
74
  /** Connect to the ODBC data source. Must be called before using the adapter. */
57
75
  async connect(): Promise<void> {
58
76
  const odbc = requireOdbc();
@@ -62,7 +80,19 @@ export class OdbcAdapter implements DatabaseAdapter {
62
80
  if (!connectFn) {
63
81
  throw new Error("odbc module does not export a connect() function. Check your odbc package version.");
64
82
  }
65
- this.connection = await connectFn(connStr);
83
+ // Outer bound only: the connection string is the driver's, and injecting a
84
+ // CONNECTIONTIMEOUT keyword into a string the operator wrote would be this
85
+ // adapter editing configuration it does not own. The bound frees the CALLER;
86
+ // the native driver thread it is waiting on cannot be cancelled from JS.
87
+ const { host, port } = this.describeTarget();
88
+ this.connection = await withConnectTimeout(
89
+ () => connectFn(connStr),
90
+ connectTimeoutMillis(),
91
+ host,
92
+ port,
93
+ // Answered after we gave up: close it so the handle does not outlive the boot.
94
+ (arrived: any) => { try { void arrived?.close?.(); } catch { /* already gone */ } },
95
+ );
66
96
  }
67
97
 
68
98
  private ensureConnected(): void {
@@ -119,11 +149,11 @@ export class OdbcAdapter implements DatabaseAdapter {
119
149
  throw new Error("Use rollbackAsync() for ODBC — async adapter requires async methods.");
120
150
  }
121
151
 
122
- tables(): string[] {
152
+ getTables(): string[] {
123
153
  throw new Error("Use tablesAsync() for ODBC — async adapter requires async methods.");
124
154
  }
125
155
 
126
- columns(table: string): ColumnInfo[] {
156
+ getColumns(table: string): ColumnInfo[] {
127
157
  throw new Error("Use columnsAsync() for ODBC — async adapter requires async methods.");
128
158
  }
129
159
 
@@ -195,16 +225,10 @@ export class OdbcAdapter implements DatabaseAdapter {
195
225
  limit?: number,
196
226
  skip?: number,
197
227
  ): Promise<T[]> {
198
- let effectiveSql = sql;
199
- if (limit !== undefined) {
200
- const sqlUpper = sql.toUpperCase().split("--")[0];
201
- if (!sqlUpper.includes("LIMIT")) {
202
- effectiveSql += ` LIMIT ${limit}`;
203
- if (skip !== undefined && skip > 0) {
204
- effectiveSql += ` OFFSET ${skip}`;
205
- }
206
- }
207
- }
228
+ // See SQLTranslator.appendLimit: the old inline check was a substring search,
229
+ // so a LIMIT in a string literal or a trailing comment silently dropped the
230
+ // row cap and returned every row.
231
+ const effectiveSql = SQLTranslator.appendLimit(sql, limit, skip);
208
232
  return this.queryAsync<T>(effectiveSql, params);
209
233
  }
210
234
 
@@ -218,8 +242,7 @@ export class OdbcAdapter implements DatabaseAdapter {
218
242
  async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
219
243
  this.ensureConnected();
220
244
  const keys = Object.keys(data);
221
- const placeholders = keys.map(() => "?").join(", ");
222
- const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
245
+ const sql = buildInsert(ANSI_DIALECT, table, keys);
223
246
  const values = Object.values(data);
224
247
 
225
248
  try {
@@ -233,9 +256,9 @@ export class OdbcAdapter implements DatabaseAdapter {
233
256
  /** Update rows in a table matching filter. */
234
257
  async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
235
258
  this.ensureConnected();
236
- const setClauses = Object.keys(data).map((k) => `"${k}" = ?`).join(", ");
237
- const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
238
- const sql = `UPDATE "${table}" SET ${setClauses} WHERE ${whereClauses}`;
259
+ const setClauses = buildSetClause(ANSI_DIALECT, Object.keys(data));
260
+ const whereClauses = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
261
+ const sql = `UPDATE ${ANSI_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
239
262
  const values = [...Object.values(data), ...Object.values(filter)];
240
263
 
241
264
  try {
@@ -274,8 +297,8 @@ export class OdbcAdapter implements DatabaseAdapter {
274
297
  }
275
298
  }
276
299
 
277
- const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
278
- const sql = `DELETE FROM "${table}" WHERE ${whereClauses}`;
300
+ const whereClauses = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
301
+ const sql = `DELETE FROM ${ANSI_DIALECT.quote(table)} WHERE ${whereClauses}`;
279
302
  const values = Object.values(filter);
280
303
 
281
304
  try {
@@ -326,7 +349,7 @@ export class OdbcAdapter implements DatabaseAdapter {
326
349
  /** Get column metadata for a table using ODBC catalog functions. */
327
350
  async columnsAsync(table: string): Promise<ColumnInfo[]> {
328
351
  this.ensureConnected();
329
- // odbc.Connection.columns(catalog, schema, table, column)
352
+ // odbc.Connection.getColumns(catalog, schema, table, column)
330
353
  const rows: any[] = await this.connection.columns(null, null, table, null);
331
354
  return rows.map((r: any) => ({
332
355
  name: r.COLUMN_NAME ?? r.column_name,
@@ -4,8 +4,10 @@
4
4
  * Install: npm install pg @types/pg
5
5
  * URL format: postgresql://user:pass@host:port/database
6
6
  */
7
+ import { ANSI_DIALECT, POSTGRES_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 { connectTarget, connectTimeoutMillis, driverConnectTimeoutMillis, withConnectTimeout } from "../connectTimeout.js";
9
11
 
10
12
  import { createRequire } from "node:module";
11
13
 
@@ -67,6 +69,14 @@ export interface PostgresConfig {
67
69
  }
68
70
 
69
71
  export class PostgresAdapter implements DatabaseAdapter {
72
+ /**
73
+ * Postgres, MySQL and MSSQL all REQUIRE a name for a derived table, so
74
+ * the COUNT probe in Database.countProbe wraps as
75
+ * `FROM (sql) AS _count_query`. SQLite and Firebird leave this unset and
76
+ * get no alias - Firebird rejects `AS` in that position.
77
+ */
78
+ readonly countSubqueryAlias = "_count_query";
79
+
70
80
  private client: InstanceType<typeof import("pg").Client> | null = null;
71
81
  // string is included for non-integer primary keys: a UUID PK (the common
72
82
  // `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` shape) returns its id as a
@@ -84,13 +94,31 @@ export class PostgresAdapter implements DatabaseAdapter {
84
94
  const pgModule = requirePg();
85
95
  const Client = pgModule.Client ?? (pgModule as any).default?.Client;
86
96
 
87
- if (typeof this.config === "string") {
88
- this.client = new Client({ connectionString: this.config });
89
- } else {
90
- this.client = new Client(this.config);
91
- }
92
-
93
- await this.client!.connect();
97
+ // pg's own connectionTimeoutMillis defaults to 0 - no timeout at all - so
98
+ // without this a server that accepts and never completes the startup
99
+ // handshake hangs the boot forever. Omitted entirely when the bound is
100
+ // disabled, which restores exactly the old (unbounded) behaviour.
101
+ const budgetMs = connectTimeoutMillis();
102
+ const driverMs = driverConnectTimeoutMillis(budgetMs);
103
+ const timeoutOption = driverMs === null ? {} : { connectionTimeoutMillis: driverMs };
104
+
105
+ const { host, port } = connectTarget(this.config, 5432);
106
+ // Everything that touches pg lives inside the thunk so the Tina4 clock
107
+ // starts before pg arms its own timer (client.js `_connect`, cleared only at
108
+ // ReadyForQuery - so the knob covers the whole handshake, not just the TCP).
109
+ await withConnectTimeout(
110
+ () => {
111
+ this.client = typeof this.config === "string"
112
+ ? new Client({ connectionString: this.config, ...timeoutOption })
113
+ : new Client({ ...this.config, ...timeoutOption });
114
+ return this.client!.connect();
115
+ },
116
+ budgetMs,
117
+ host,
118
+ port,
119
+ // Answered after we gave up: end it so the socket does not outlive the boot.
120
+ () => { void Promise.resolve(this.client?.end()).catch(() => { /* already gone */ }); },
121
+ );
94
122
  }
95
123
 
96
124
  // NOTE: a plain runtime guard, not an `asserts this is ...` predicate. A
@@ -112,8 +140,11 @@ export class PostgresAdapter implements DatabaseAdapter {
112
140
  return row;
113
141
  }
114
142
 
115
- private convertPlaceholders(sql: string): string {
116
- let count = 0;
143
+ // `startAt` lets a caller that has already consumed N placeholders (an UPDATE
144
+ // whose SET values are $1..$N) continue the numbering into a raw WHERE
145
+ // fragment instead of restarting at $1.
146
+ private convertPlaceholders(sql: string, startAt = 1): string {
147
+ let count = startAt - 1;
117
148
  return sql.replace(/\?/g, () => {
118
149
  count++;
119
150
  return `$${count}`;
@@ -244,8 +275,8 @@ export class PostgresAdapter implements DatabaseAdapter {
244
275
  if (Array.isArray(data)) {
245
276
  if (data.length === 0) return { success: true, affectedRows: 0 };
246
277
  const keys = Object.keys(data[0]);
247
- const placeholders = keys.map(() => "?").join(", ");
248
- const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
278
+ // The batch path binds through executeManyAsync, which converts "?" itself.
279
+ const sql = buildInsert(ANSI_DIALECT, table, keys);
249
280
  const paramsList = data.map((row) => keys.map((k) => row[k]));
250
281
  try {
251
282
  const result = await this.executeManyAsync(sql, paramsList);
@@ -257,8 +288,7 @@ export class PostgresAdapter implements DatabaseAdapter {
257
288
  }
258
289
 
259
290
  const keys = Object.keys(data);
260
- const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
261
- const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders}) RETURNING *`;
291
+ const sql = buildInsert(POSTGRES_DIALECT, table, keys, " RETURNING *");
262
292
  const values = Object.values(data);
263
293
 
264
294
  try {
@@ -280,15 +310,35 @@ export class PostgresAdapter implements DatabaseAdapter {
280
310
  throw new Error("Use updateAsync() for PostgreSQL.");
281
311
  }
282
312
 
283
- async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
313
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
284
314
  this.ensureConnected();
285
315
  const dataKeys = Object.keys(data);
286
- const filterKeys = Object.keys(filter);
287
316
  let paramIndex = 1;
317
+ const setClauses = buildSetClause(POSTGRES_DIALECT, dataKeys, paramIndex);
318
+ paramIndex += dataKeys.length;
319
+
320
+ // A raw WHERE fragment + params is half the write_path contract's filter
321
+ // form ("a string filter with params works the same as a hash filter").
322
+ // Without this branch Object.keys("id = ?") yields the STRING INDICES
323
+ // ["0","1",...], producing `WHERE "0" = $2 AND "1" = $3` — the engine then
324
+ // reports `column "0" does not exist`. sqlite/mongodb/odbc already carried
325
+ // this branch; postgres/mysql/mssql/firebird did not.
326
+ if (typeof filter === "string") {
327
+ const where = filter ? ` WHERE ${this.convertPlaceholders(filter, paramIndex)}` : "";
328
+ const sql = `UPDATE ${POSTGRES_DIALECT.quote(table)} SET ${setClauses}${where}`;
329
+ const values = [...Object.values(data), ...(params ?? [])];
330
+ try {
331
+ const result = await this.client!.query(sql, values);
332
+ return { success: true, affectedRows: result.rowCount ?? 0 };
333
+ } catch (e) {
334
+ return { success: false, affectedRows: 0, error: (e as Error).message };
335
+ }
336
+ }
288
337
 
289
- const setClauses = dataKeys.map((k) => `"${k}" = $${paramIndex++}`).join(", ");
290
- const whereClauses = filterKeys.map((k) => `"${k}" = $${paramIndex++}`).join(" AND ");
291
- const sql = `UPDATE "${table}" SET ${setClauses} WHERE ${whereClauses}`;
338
+ const filterKeys = Object.keys(filter);
339
+ const whereClauses = buildWhereClause(POSTGRES_DIALECT, filterKeys, paramIndex);
340
+ paramIndex += filterKeys.length;
341
+ const sql = `UPDATE ${POSTGRES_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
292
342
  const values = [...Object.values(data), ...Object.values(filter)];
293
343
 
294
344
  try {
@@ -303,12 +353,30 @@ export class PostgresAdapter implements DatabaseAdapter {
303
353
  throw new Error("Use deleteAsync() for PostgreSQL.");
304
354
  }
305
355
 
306
- async deleteAsync(table: string, filter: Record<string, unknown>): Promise<DatabaseResult> {
356
+ async deleteAsync(table: string, filter: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseResult> {
307
357
  this.ensureConnected();
358
+
359
+ // See updateAsync: a raw WHERE fragment must not be walked as an object.
360
+ // truncate() calls this with "1 = 1", which became `WHERE "0" = $1 AND
361
+ // "1" = $2 ...` and failed with `column "0" does not exist` — db.truncate()
362
+ // was broken outright on PostgreSQL.
363
+ if (typeof filter === "string") {
364
+ const sql = filter
365
+ ? `DELETE FROM "${table}" WHERE ${this.convertPlaceholders(filter)}`
366
+ : `DELETE FROM "${table}"`;
367
+ try {
368
+ const result = await this.client!.query(sql, params ?? []);
369
+ return { success: true, affectedRows: result.rowCount ?? 0 };
370
+ } catch (e) {
371
+ return { success: false, affectedRows: 0, error: (e as Error).message };
372
+ }
373
+ }
374
+
308
375
  const filterKeys = Object.keys(filter);
309
376
  let paramIndex = 1;
310
- const whereClauses = filterKeys.map((k) => `"${k}" = $${paramIndex++}`).join(" AND ");
311
- const sql = `DELETE FROM "${table}" WHERE ${whereClauses}`;
377
+ const whereClauses = buildWhereClause(POSTGRES_DIALECT, filterKeys, paramIndex);
378
+ paramIndex += filterKeys.length;
379
+ const sql = `DELETE FROM ${POSTGRES_DIALECT.quote(table)} WHERE ${whereClauses}`;
312
380
  const values = Object.values(filter);
313
381
 
314
382
  try {
@@ -354,7 +422,7 @@ export class PostgresAdapter implements DatabaseAdapter {
354
422
  this._inTransaction = false;
355
423
  }
356
424
 
357
- tables(): string[] {
425
+ getTables(): string[] {
358
426
  throw new Error("Use tablesAsync() for PostgreSQL.");
359
427
  }
360
428
 
@@ -371,28 +439,52 @@ export class PostgresAdapter implements DatabaseAdapter {
371
439
  );
372
440
  }
373
441
 
374
- columns(table: string): ColumnInfo[] {
442
+ getColumns(table: string): ColumnInfo[] {
375
443
  throw new Error("Use columnsAsync() for PostgreSQL.");
376
444
  }
377
445
 
378
446
  async columnsAsync(table: string): Promise<ColumnInfo[]> {
379
447
  // v3.13.14 (#48): honour a schema-qualified name; default to public.
380
448
  const [schema, tbl] = SQLTranslator.splitSchema(table);
449
+ // primaryKey was hardcoded false, so primaryKey(table) introspected NOTHING
450
+ // on PostgreSQL. The filterless-write guard (feature 4) reads it, so
451
+ // update(table, data) keyed on the primary key in `data` threw "update
452
+ // requires a filter or the complete primary key in the data" against every
453
+ // PostgreSQL table. Port the Python master's LEFT JOIN so the cross-engine
454
+ // columns() contract (#48) actually holds here — the subquery yields every
455
+ // column of the PK, so a COMPOSITE key reports true on each of its columns,
456
+ // not just the first.
381
457
  const rows = await this.queryAsync<{
382
458
  column_name: string;
383
459
  data_type: string;
384
460
  is_nullable: string;
385
461
  column_default: string | null;
462
+ is_primary: boolean | string;
386
463
  }>(
387
- "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND table_schema = $2",
388
- [tbl, schema ?? "public"],
464
+ `SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
465
+ CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_primary
466
+ FROM information_schema.columns c
467
+ LEFT JOIN (
468
+ SELECT ku.column_name
469
+ FROM information_schema.table_constraints tc
470
+ JOIN information_schema.key_column_usage ku
471
+ ON tc.constraint_name = ku.constraint_name
472
+ AND tc.table_schema = ku.table_schema
473
+ WHERE tc.table_name = $1 AND tc.table_schema = $2
474
+ AND tc.constraint_type = 'PRIMARY KEY'
475
+ ) pk ON c.column_name = pk.column_name
476
+ WHERE c.table_name = $3 AND c.table_schema = $4
477
+ ORDER BY c.ordinal_position`,
478
+ [tbl, schema ?? "public", tbl, schema ?? "public"],
389
479
  );
390
480
  return rows.map((r) => ({
391
481
  name: r.column_name,
392
482
  type: r.data_type,
393
483
  nullable: r.is_nullable === "YES",
394
484
  default: r.column_default,
395
- primaryKey: false,
485
+ // node-postgres decodes bool to true/false; stay tolerant of the raw
486
+ // "t" text form in case the type parser is bypassed.
487
+ primaryKey: r.is_primary === true || r.is_primary === "t",
396
488
  }));
397
489
  }
398
490