turbine-orm 0.81.0 → 0.90.0

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 (295) hide show
  1. package/README.md +26 -25
  2. package/dist/checkout.d.ts +27 -2
  3. package/dist/checkout.js +75 -4
  4. package/dist/cjs/checkout.d.ts +27 -2
  5. package/dist/cjs/checkout.js +76 -3
  6. package/dist/cjs/cli/code-first-tags.d.ts +62 -0
  7. package/dist/cjs/cli/code-first-tags.js +124 -0
  8. package/dist/cjs/cli/compile-query.js +9 -0
  9. package/dist/cjs/cli/config.d.ts +35 -6
  10. package/dist/cjs/cli/config.js +17 -1
  11. package/dist/cjs/cli/destructive.d.ts +7 -4
  12. package/dist/cjs/cli/destructive.js +224 -20
  13. package/dist/cjs/cli/error-catalog.js +36 -0
  14. package/dist/cjs/cli/error-text.d.ts +15 -0
  15. package/dist/cjs/cli/error-text.js +40 -0
  16. package/dist/cjs/cli/index.d.ts +62 -4
  17. package/dist/cjs/cli/index.js +568 -108
  18. package/dist/cjs/cli/local-server-guard.d.ts +53 -0
  19. package/dist/cjs/cli/local-server-guard.js +119 -0
  20. package/dist/cjs/cli/local-server-output.d.ts +41 -0
  21. package/dist/cjs/cli/local-server-output.js +103 -0
  22. package/dist/cjs/cli/mcp.d.ts +8 -0
  23. package/dist/cjs/cli/mcp.js +121 -32
  24. package/dist/cjs/cli/migrate.d.ts +113 -5
  25. package/dist/cjs/cli/migrate.js +270 -21
  26. package/dist/cjs/cli/observe.d.ts +6 -1
  27. package/dist/cjs/cli/observe.js +85 -55
  28. package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
  29. package/dist/cjs/cli/pii-predicate-guard.js +98 -37
  30. package/dist/cjs/cli/prisma-resolve.js +17 -13
  31. package/dist/cjs/cli/prisma-schema.js +12 -4
  32. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  33. package/dist/cjs/cli/studio.d.ts +14 -2
  34. package/dist/cjs/cli/studio.js +244 -69
  35. package/dist/cjs/cli/ui.d.ts +1 -1
  36. package/dist/cjs/client.d.ts +558 -85
  37. package/dist/cjs/client.js +1270 -395
  38. package/dist/cjs/connection-guard.d.ts +13 -0
  39. package/dist/cjs/connection-guard.js +28 -0
  40. package/dist/cjs/connection-url.d.ts +51 -16
  41. package/dist/cjs/connection-url.js +67 -34
  42. package/dist/cjs/diagnostics.d.ts +81 -0
  43. package/dist/cjs/diagnostics.js +147 -0
  44. package/dist/cjs/dialect.d.ts +449 -20
  45. package/dist/cjs/dialect.js +117 -76
  46. package/dist/cjs/engine-config.d.ts +7 -6
  47. package/dist/cjs/errors.d.ts +126 -6
  48. package/dist/cjs/errors.js +256 -13
  49. package/dist/cjs/generate.d.ts +57 -7
  50. package/dist/cjs/generate.js +635 -279
  51. package/dist/cjs/global-filters-config.d.ts +59 -0
  52. package/dist/cjs/global-filters-config.js +367 -0
  53. package/dist/cjs/index.d.ts +9 -4
  54. package/dist/cjs/index.js +10 -2
  55. package/dist/cjs/introspect.d.ts +31 -6
  56. package/dist/cjs/introspect.js +108 -50
  57. package/dist/cjs/log-codes.d.ts +12 -0
  58. package/dist/cjs/log-codes.js +77 -0
  59. package/dist/cjs/logger.d.ts +87 -0
  60. package/dist/cjs/logger.js +98 -0
  61. package/dist/cjs/mssql.d.ts +8 -5
  62. package/dist/cjs/mssql.js +352 -65
  63. package/dist/cjs/mysql.d.ts +3 -5
  64. package/dist/cjs/mysql.js +420 -70
  65. package/dist/cjs/nested-write.d.ts +3 -0
  66. package/dist/cjs/nested-write.js +179 -114
  67. package/dist/cjs/pipeline-submittable.d.ts +12 -0
  68. package/dist/cjs/pipeline-submittable.js +118 -44
  69. package/dist/cjs/pipeline.js +21 -26
  70. package/dist/cjs/powdb-introspect.d.ts +5 -2
  71. package/dist/cjs/powdb-introspect.js +20 -15
  72. package/dist/cjs/powdb-shared.d.ts +36 -5
  73. package/dist/cjs/powdb-shared.js +67 -8
  74. package/dist/cjs/powdb.d.ts +4 -0
  75. package/dist/cjs/powdb.js +183 -51
  76. package/dist/cjs/powql.d.ts +394 -51
  77. package/dist/cjs/powql.js +1829 -284
  78. package/dist/cjs/prisma-compat.d.ts +23 -15
  79. package/dist/cjs/prisma-compat.js +507 -372
  80. package/dist/cjs/query/aggregates.d.ts +17 -1
  81. package/dist/cjs/query/aggregates.js +261 -29
  82. package/dist/cjs/query/batched-loader.d.ts +25 -6
  83. package/dist/cjs/query/batched-loader.js +103 -20
  84. package/dist/cjs/query/builder.d.ts +325 -184
  85. package/dist/cjs/query/builder.js +1452 -490
  86. package/dist/cjs/query/column-ref.d.ts +61 -0
  87. package/dist/cjs/query/column-ref.js +73 -0
  88. package/dist/cjs/query/compound-unique.d.ts +21 -0
  89. package/dist/cjs/query/compound-unique.js +98 -18
  90. package/dist/cjs/query/deferred.d.ts +53 -16
  91. package/dist/cjs/query/expr.d.ts +217 -0
  92. package/dist/cjs/query/expr.js +859 -0
  93. package/dist/cjs/query/filters.d.ts +155 -53
  94. package/dist/cjs/query/filters.js +613 -76
  95. package/dist/cjs/query/index.d.ts +2 -0
  96. package/dist/cjs/query/index.js +7 -1
  97. package/dist/cjs/query/json-value.d.ts +43 -0
  98. package/dist/cjs/query/json-value.js +59 -0
  99. package/dist/cjs/query/normalize-args.d.ts +87 -0
  100. package/dist/cjs/query/normalize-args.js +527 -0
  101. package/dist/cjs/query/option-surface.d.ts +16 -4
  102. package/dist/cjs/query/option-surface.js +57 -14
  103. package/dist/cjs/query/relation-names.d.ts +14 -0
  104. package/dist/cjs/query/relation-names.js +77 -18
  105. package/dist/cjs/query/relations.d.ts +30 -3
  106. package/dist/cjs/query/relations.js +219 -63
  107. package/dist/cjs/query/row-lock.d.ts +49 -0
  108. package/dist/cjs/query/row-lock.js +138 -0
  109. package/dist/cjs/query/sql-comment.d.ts +161 -0
  110. package/dist/cjs/query/sql-comment.js +381 -0
  111. package/dist/cjs/query/stream-idle.d.ts +132 -0
  112. package/dist/cjs/query/stream-idle.js +212 -0
  113. package/dist/cjs/query/table-client.d.ts +101 -0
  114. package/dist/cjs/query/table-client.js +74 -0
  115. package/dist/cjs/query/types.d.ts +677 -298
  116. package/dist/cjs/query/utils.d.ts +105 -6
  117. package/dist/cjs/query/utils.js +426 -19
  118. package/dist/cjs/query/warn-registry.d.ts +18 -0
  119. package/dist/cjs/query/warn-registry.js +18 -0
  120. package/dist/cjs/query/where-compile.d.ts +138 -14
  121. package/dist/cjs/query/where-compile.js +513 -29
  122. package/dist/cjs/query/where.d.ts +159 -59
  123. package/dist/cjs/query/where.js +716 -246
  124. package/dist/cjs/query/writes.d.ts +112 -50
  125. package/dist/cjs/query/writes.js +1498 -200
  126. package/dist/cjs/query-events.d.ts +4 -2
  127. package/dist/cjs/query-events.js +3 -1
  128. package/dist/cjs/realtime.d.ts +7 -3
  129. package/dist/cjs/realtime.js +13 -2
  130. package/dist/cjs/schema-builder.d.ts +55 -4
  131. package/dist/cjs/schema-builder.js +382 -18
  132. package/dist/cjs/schema-metadata.d.ts +21 -1
  133. package/dist/cjs/schema-metadata.js +29 -7
  134. package/dist/cjs/schema-sql.d.ts +75 -5
  135. package/dist/cjs/schema-sql.js +497 -45
  136. package/dist/cjs/schema.d.ts +26 -5
  137. package/dist/cjs/schema.js +110 -13
  138. package/dist/cjs/sql-placeholders.d.ts +56 -0
  139. package/dist/cjs/sql-placeholders.js +204 -0
  140. package/dist/cjs/sqlite.d.ts +75 -20
  141. package/dist/cjs/sqlite.js +593 -95
  142. package/dist/cjs/supplied-pool-types.d.ts +48 -0
  143. package/dist/cjs/supplied-pool-types.js +256 -0
  144. package/dist/cjs/transaction-control.d.ts +51 -0
  145. package/dist/cjs/transaction-control.js +316 -0
  146. package/dist/cjs/transaction-state.d.ts +342 -0
  147. package/dist/cjs/transaction-state.js +707 -0
  148. package/dist/cjs/typed-sql.d.ts +37 -3
  149. package/dist/cjs/typed-sql.js +102 -8
  150. package/dist/cli/code-first-tags.d.ts +62 -0
  151. package/dist/cli/code-first-tags.js +120 -0
  152. package/dist/cli/compile-query.js +9 -0
  153. package/dist/cli/config.d.ts +35 -6
  154. package/dist/cli/config.js +16 -1
  155. package/dist/cli/destructive.d.ts +7 -4
  156. package/dist/cli/destructive.js +224 -20
  157. package/dist/cli/error-catalog.js +36 -0
  158. package/dist/cli/error-text.d.ts +15 -0
  159. package/dist/cli/error-text.js +37 -0
  160. package/dist/cli/index.d.ts +62 -4
  161. package/dist/cli/index.js +569 -112
  162. package/dist/cli/local-server-guard.d.ts +53 -0
  163. package/dist/cli/local-server-guard.js +113 -0
  164. package/dist/cli/local-server-output.d.ts +41 -0
  165. package/dist/cli/local-server-output.js +98 -0
  166. package/dist/cli/mcp.d.ts +8 -0
  167. package/dist/cli/mcp.js +121 -34
  168. package/dist/cli/migrate.d.ts +113 -5
  169. package/dist/cli/migrate.js +261 -21
  170. package/dist/cli/observe.d.ts +6 -1
  171. package/dist/cli/observe.js +84 -54
  172. package/dist/cli/pii-predicate-guard.d.ts +43 -23
  173. package/dist/cli/pii-predicate-guard.js +100 -35
  174. package/dist/cli/prisma-resolve.js +15 -11
  175. package/dist/cli/prisma-schema.js +12 -4
  176. package/dist/cli/studio-ui.generated.js +1 -1
  177. package/dist/cli/studio.d.ts +14 -2
  178. package/dist/cli/studio.js +246 -71
  179. package/dist/cli/ui.d.ts +1 -1
  180. package/dist/client.d.ts +558 -85
  181. package/dist/client.js +1275 -400
  182. package/dist/connection-guard.d.ts +13 -0
  183. package/dist/connection-guard.js +27 -0
  184. package/dist/connection-url.d.ts +51 -16
  185. package/dist/connection-url.js +63 -34
  186. package/dist/diagnostics.d.ts +81 -0
  187. package/dist/diagnostics.js +144 -0
  188. package/dist/dialect.d.ts +449 -20
  189. package/dist/dialect.js +115 -43
  190. package/dist/engine-config.d.ts +7 -6
  191. package/dist/errors.d.ts +126 -6
  192. package/dist/errors.js +252 -12
  193. package/dist/generate.d.ts +57 -7
  194. package/dist/generate.js +635 -281
  195. package/dist/global-filters-config.d.ts +59 -0
  196. package/dist/global-filters-config.js +361 -0
  197. package/dist/index.d.ts +9 -4
  198. package/dist/index.js +4 -2
  199. package/dist/introspect.d.ts +31 -6
  200. package/dist/introspect.js +106 -50
  201. package/dist/log-codes.d.ts +12 -0
  202. package/dist/log-codes.js +74 -0
  203. package/dist/logger.d.ts +87 -0
  204. package/dist/logger.js +93 -0
  205. package/dist/mssql.d.ts +8 -5
  206. package/dist/mssql.js +354 -67
  207. package/dist/mysql.d.ts +3 -5
  208. package/dist/mysql.js +423 -73
  209. package/dist/nested-write.d.ts +3 -0
  210. package/dist/nested-write.js +177 -112
  211. package/dist/pipeline-submittable.d.ts +12 -0
  212. package/dist/pipeline-submittable.js +117 -44
  213. package/dist/pipeline.js +21 -26
  214. package/dist/powdb-introspect.d.ts +5 -2
  215. package/dist/powdb-introspect.js +21 -16
  216. package/dist/powdb-shared.d.ts +36 -5
  217. package/dist/powdb-shared.js +65 -8
  218. package/dist/powdb.d.ts +4 -0
  219. package/dist/powdb.js +183 -51
  220. package/dist/powql.d.ts +394 -51
  221. package/dist/powql.js +1836 -291
  222. package/dist/prisma-compat.d.ts +23 -15
  223. package/dist/prisma-compat.js +508 -373
  224. package/dist/query/aggregates.d.ts +17 -1
  225. package/dist/query/aggregates.js +263 -32
  226. package/dist/query/batched-loader.d.ts +25 -6
  227. package/dist/query/batched-loader.js +101 -21
  228. package/dist/query/builder.d.ts +325 -184
  229. package/dist/query/builder.js +1458 -495
  230. package/dist/query/column-ref.d.ts +61 -0
  231. package/dist/query/column-ref.js +66 -0
  232. package/dist/query/compound-unique.d.ts +21 -0
  233. package/dist/query/compound-unique.js +97 -19
  234. package/dist/query/deferred.d.ts +53 -16
  235. package/dist/query/expr.d.ts +217 -0
  236. package/dist/query/expr.js +848 -0
  237. package/dist/query/filters.d.ts +155 -53
  238. package/dist/query/filters.js +597 -72
  239. package/dist/query/index.d.ts +2 -0
  240. package/dist/query/index.js +4 -0
  241. package/dist/query/json-value.d.ts +43 -0
  242. package/dist/query/json-value.js +54 -0
  243. package/dist/query/normalize-args.d.ts +87 -0
  244. package/dist/query/normalize-args.js +517 -0
  245. package/dist/query/option-surface.d.ts +16 -4
  246. package/dist/query/option-surface.js +56 -13
  247. package/dist/query/relation-names.d.ts +14 -0
  248. package/dist/query/relation-names.js +77 -19
  249. package/dist/query/relations.d.ts +30 -3
  250. package/dist/query/relations.js +220 -67
  251. package/dist/query/row-lock.d.ts +49 -0
  252. package/dist/query/row-lock.js +131 -0
  253. package/dist/query/sql-comment.d.ts +161 -0
  254. package/dist/query/sql-comment.js +374 -0
  255. package/dist/query/stream-idle.d.ts +132 -0
  256. package/dist/query/stream-idle.js +203 -0
  257. package/dist/query/table-client.d.ts +101 -0
  258. package/dist/query/table-client.js +70 -0
  259. package/dist/query/types.d.ts +677 -298
  260. package/dist/query/utils.d.ts +105 -6
  261. package/dist/query/utils.js +414 -18
  262. package/dist/query/warn-registry.d.ts +18 -0
  263. package/dist/query/warn-registry.js +18 -0
  264. package/dist/query/where-compile.d.ts +138 -14
  265. package/dist/query/where-compile.js +508 -31
  266. package/dist/query/where.d.ts +159 -59
  267. package/dist/query/where.js +711 -248
  268. package/dist/query/writes.d.ts +112 -50
  269. package/dist/query/writes.js +1496 -202
  270. package/dist/query-events.d.ts +4 -2
  271. package/dist/query-events.js +3 -1
  272. package/dist/realtime.d.ts +7 -3
  273. package/dist/realtime.js +13 -2
  274. package/dist/schema-builder.d.ts +55 -4
  275. package/dist/schema-builder.js +381 -18
  276. package/dist/schema-metadata.d.ts +21 -1
  277. package/dist/schema-metadata.js +28 -7
  278. package/dist/schema-sql.d.ts +75 -5
  279. package/dist/schema-sql.js +494 -44
  280. package/dist/schema.d.ts +26 -5
  281. package/dist/schema.js +109 -13
  282. package/dist/sql-placeholders.d.ts +56 -0
  283. package/dist/sql-placeholders.js +200 -0
  284. package/dist/sqlite.d.ts +75 -20
  285. package/dist/sqlite.js +597 -99
  286. package/dist/supplied-pool-types.d.ts +48 -0
  287. package/dist/supplied-pool-types.js +247 -0
  288. package/dist/transaction-control.d.ts +51 -0
  289. package/dist/transaction-control.js +311 -0
  290. package/dist/transaction-state.d.ts +342 -0
  291. package/dist/transaction-state.js +696 -0
  292. package/dist/typed-sql.d.ts +37 -3
  293. package/dist/typed-sql.js +100 -8
  294. package/package.json +7 -4
  295. package/skills/turbine-orm/SKILL.md +56 -6
@@ -32,15 +32,25 @@ const _pg_1 = __importDefault(require("pg"));
32
32
  const checkout_js_1 = require("./checkout.js");
33
33
  const connection_guard_js_1 = require("./connection-guard.js");
34
34
  const connection_url_js_1 = require("./connection-url.js");
35
+ const diagnostics_js_1 = require("./diagnostics.js");
35
36
  const dialect_js_1 = require("./dialect.js");
36
37
  const errors_js_1 = require("./errors.js");
38
+ const global_filters_config_js_1 = require("./global-filters-config.js");
39
+ const logger_js_1 = require("./logger.js");
40
+ const nested_write_js_1 = require("./nested-write.js");
37
41
  const observe_js_1 = require("./observe.js");
38
42
  const pipeline_js_1 = require("./pipeline.js");
39
43
  const index_js_1 = require("./query/index.js");
44
+ const row_lock_js_1 = require("./query/row-lock.js");
45
+ const sql_comment_js_1 = require("./query/sql-comment.js");
46
+ const stream_idle_js_1 = require("./query/stream-idle.js");
47
+ const table_client_js_1 = require("./query/table-client.js");
40
48
  const utils_js_1 = require("./query/utils.js");
41
49
  const warn_registry_js_1 = require("./query/warn-registry.js");
42
50
  const query_events_js_1 = require("./query-events.js");
43
51
  const realtime_js_1 = require("./realtime.js");
52
+ const supplied_pool_types_js_1 = require("./supplied-pool-types.js");
53
+ const transaction_state_js_1 = require("./transaction-state.js");
44
54
  const typed_sql_js_1 = require("./typed-sql.js");
45
55
  async function withRetry(fn, options) {
46
56
  const maxAttempts = options?.maxAttempts ?? 3;
@@ -110,11 +120,17 @@ const TURBINE_CONFIG_KEYS = {
110
120
  idleTimeoutMillis: true,
111
121
  connectionTimeoutMillis: true,
112
122
  logging: true,
123
+ logger: true,
124
+ diagnostics: true,
113
125
  defaultLimit: true,
114
126
  warnOnUnlimited: true,
115
127
  utcTimestamps: true,
116
128
  temporalInfinity: true,
117
129
  planCacheMode: true,
130
+ statementTimeoutMs: true,
131
+ maxLifetimeSeconds: true,
132
+ streamIdleTimeoutMs: true,
133
+ sqlComment: true,
118
134
  scopedConnect: true,
119
135
  relationLoadStrategy: true,
120
136
  stableRelationOrder: true,
@@ -162,8 +178,7 @@ function suggestConfigKey(key) {
162
178
  return (0, utils_js_1.suggestKey)(key, CONFIG_KEY_SET);
163
179
  }
164
180
  /**
165
- * Dev-mode notice for a key on the config object that is not part of the config
166
- * surface.
181
+ * Notice for a key on the config object that is not part of the config surface.
167
182
  *
168
183
  * An unknown key is silently ignored (JavaScript objects have no schema), which
169
184
  * makes a typo or a wrong guess indistinguishable from a broken feature: a
@@ -175,12 +190,10 @@ function suggestConfigKey(key) {
175
190
  * turbine that passes a key this version has not heard of must keep running,
176
191
  * and the whole check is wrapped so that a hostile / exotic config object
177
192
  * (a Proxy whose `ownKeys` throws) cannot take down the constructor either.
178
- * Dev-only (`NODE_ENV !== 'production'`) and once per key per process, like the
179
- * other advisory diagnostics.
193
+ * Always on, whatever `diagnostics` says: an ignored option is ignored in
194
+ * production too, and the line fires once per key per process.
180
195
  */
181
- function warnUnknownConfigKeys(config) {
182
- if (process.env.NODE_ENV === 'production')
183
- return;
196
+ function warnUnknownConfigKeys(config, logger) {
184
197
  try {
185
198
  for (const key of Object.keys(config)) {
186
199
  if (CONFIG_KEY_SET.has(key) || NON_CONFIG_KEYS.has(key))
@@ -188,8 +201,8 @@ function warnUnknownConfigKeys(config) {
188
201
  if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.unknownConfigKey, key))
189
202
  continue;
190
203
  const suggestion = suggestConfigKey(key);
191
- console.warn(`[turbine] Unknown option "${key}" in the config passed to TurbineClient, it is ignored.` +
192
- (suggestion ? ` Did you mean "${suggestion}"?` : ''));
204
+ (0, logger_js_1.logTo)(logger, 'warn', `Unknown option "${key}" in the config passed to TurbineClient, it is ignored.` +
205
+ (suggestion ? ` Did you mean "${suggestion}"?` : ''), { code: 'unknown-config-key', key, ...(suggestion ? { suggestion } : {}) });
193
206
  }
194
207
  }
195
208
  catch {
@@ -336,20 +349,6 @@ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
336
349
  RepeatableRead: 'REPEATABLE READ',
337
350
  Serializable: 'SERIALIZABLE',
338
351
  });
339
- /**
340
- * Resolve a requested isolation level, REFUSING anything not in the map.
341
- *
342
- * A miss used to yield `undefined`, which `beginStatement` renders as a plain
343
- * `BEGIN`. So `isolationLevel: 'serializable'` (wrong case) asked for
344
- * SERIALIZABLE and silently got READ COMMITTED, which is the worst available
345
- * outcome for this option: the caller believes it has a guarantee it does not
346
- * have, and the workload that depended on it produces wrong data with no error.
347
- *
348
- * The TypeScript union on `TransactionOptions.isolationLevel` does not prevent
349
- * this. It is not enforced for a JavaScript consumer of a published package, for
350
- * a value read from config or an environment variable, or across any `as`.
351
- * Throwing costs a caller who was already broken one clear error.
352
- */
353
352
  /**
354
353
  * Run a transaction-control statement (BEGIN / COMMIT / ROLLBACK) with the same
355
354
  * error translation every other query boundary gets.
@@ -365,20 +364,101 @@ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
365
364
  * loops, and a loop keyed on it would never fire for the commit-time conflicts
366
365
  * that are the main reason to run SERIALIZABLE at all.
367
366
  */
368
- async function runTxControl(client, sql) {
367
+ async function runTxControl(client, sql, params) {
369
368
  try {
370
- await client.query(sql);
369
+ await client.query(sql, params);
371
370
  }
372
371
  catch (err) {
373
372
  throw (0, errors_js_1.wrapPgError)(err);
374
373
  }
375
374
  }
375
+ /**
376
+ * Refuse anything in a batch that is not a deferred query, before any of it
377
+ * runs.
378
+ *
379
+ * The array forms take the descriptors `build*()` returns. Prisma's array form
380
+ * takes promises instead, so code ported from it passes `db.users.create(...)`,
381
+ * whose query has ALREADY started on its own connection, outside any
382
+ * transaction, by the time the array exists. That cannot be undone here, so
383
+ * the error says so and names the builder to use instead. The promise's own
384
+ * rejection is observed, so this refusal is the error the caller sees rather
385
+ * than an unhandled rejection from a query nobody awaits.
386
+ */
387
+ function assertDeferredQueries(queries, api, unit) {
388
+ queries.forEach((q, i) => {
389
+ if (q !== null &&
390
+ (typeof q === 'object' || typeof q === 'function') &&
391
+ typeof q.then === 'function') {
392
+ Promise.resolve(q).catch(() => { });
393
+ throw new errors_js_1.ValidationError(`${api}: item ${i} is a Promise. Its query started running on its own the moment it was created, ` +
394
+ `outside this ${unit}, and may already have committed. Pass the deferred form instead, built with ` +
395
+ '`build*()`: `db.users.buildCreate(...)` rather than `db.users.create(...)` (likewise `buildUpdate`, ' +
396
+ '`buildDelete`, `buildUpsert`, `buildFindMany`).' +
397
+ (unit === 'transaction' ? ' Or use the callback form, `$transaction(async (tx) => { ... })`.' : ''));
398
+ }
399
+ const dq = q;
400
+ if (!dq || typeof dq !== 'object' || typeof dq.sql !== 'string' || typeof dq.transform !== 'function') {
401
+ throw new errors_js_1.ValidationError(`${api}: item ${i} is not a deferred query. Build each item with a \`build*()\` method, ` +
402
+ 'for example `db.users.buildCreate({ data })` or `db.posts.buildCount({ where })`.');
403
+ }
404
+ });
405
+ }
406
+ /** The PG* variables pg reads for a connection target when its config leaves the field unset. */
407
+ const PG_ENV_TARGETS = ['PGHOST', 'PGPORT', 'PGDATABASE', 'PGUSER'];
408
+ /** A non-empty environment variable, or undefined (also where `process` does not exist). */
409
+ function pgEnv(name) {
410
+ return (typeof process !== 'undefined' ? process.env?.[name] : undefined) || undefined;
411
+ }
412
+ /**
413
+ * Whether an owned pool's config names a database at all: a connection
414
+ * string, any host setting, DATABASE_URL (unless an explicit, empty
415
+ * `connectionString` suppresses it, as the pool branch does), or a PG*
416
+ * variable pg will read.
417
+ */
418
+ function hasDatabaseTarget(config) {
419
+ if (config.connectionString)
420
+ return true;
421
+ if (config.host != null ||
422
+ config.port != null ||
423
+ config.database != null ||
424
+ config.user != null ||
425
+ config.password != null) {
426
+ return true;
427
+ }
428
+ if (config.connectionString == null && pgEnv('DATABASE_URL'))
429
+ return true;
430
+ return PG_ENV_TARGETS.some((name) => pgEnv(name) !== undefined);
431
+ }
432
+ /**
433
+ * The pool of a client that was given no database: every query and checkout
434
+ * rejects with a {@link ConnectionError} naming the fix, and `disconnect()`
435
+ * succeeds. Building SQL never touches the pool, so it still works.
436
+ */
437
+ function unconfiguredPool(emptyConnectionString) {
438
+ const fail = () => Promise.reject(new errors_js_1.ConnectionError('No database configured: ' +
439
+ (emptyConnectionString
440
+ ? "`connectionString` is an empty string, which names no database (often `process.env.DATABASE_URL ?? ''` " +
441
+ 'evaluated before DATABASE_URL was loaded). '
442
+ : 'this client was given no `connectionString`, `pool` or host settings, and the DATABASE_URL environment ' +
443
+ 'variable is not set. ') +
444
+ 'turbine-orm does not read `.env` files: load yours before the app starts (`node --env-file=.env app.js`, ' +
445
+ 'or `process.loadEnvFile()` on Node 20.12 and later), or pass `connectionString` explicitly.'));
446
+ return { query: fail, connect: fail, end: async () => { }, on: () => { } };
447
+ }
448
+ /**
449
+ * Resolve a requested isolation level, REFUSING anything not in the map.
450
+ *
451
+ * A miss rendered as a plain `BEGIN` would be the worst outcome for this
452
+ * option: `isolationLevel: 'serializable'` (wrong case) would ask for
453
+ * SERIALIZABLE and silently get READ COMMITTED. The TypeScript union does not
454
+ * prevent it for a JavaScript caller, a value read from config, or any `as`.
455
+ */
376
456
  function resolveIsolationLevel(level) {
377
457
  if (level === undefined)
378
458
  return undefined;
379
459
  const sql = (0, utils_js_1.ownLookup)(ISOLATION_LEVELS, level);
380
460
  if (sql === undefined) {
381
- throw new errors_js_1.ValidationError(`$transaction: unknown isolationLevel ${JSON.stringify(level)}. ` +
461
+ throw new errors_js_1.ValidationError(`$transaction: unknown isolationLevel ${describeValue(level)}. ` +
382
462
  `Expected one of: ${Object.keys(ISOLATION_LEVELS).join(', ')} (case-sensitive).`);
383
463
  }
384
464
  return sql;
@@ -391,34 +471,274 @@ function resolveIsolationLevel(level) {
391
471
  */
392
472
  const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
393
473
  /**
394
- * The read-only `QueryInterface` operations that a read-replica setup may route
395
- * to a replica pool. Every other method (all writes, plus internals) stays on
396
- * the primary. Kept as a Set so the routing proxy's `get` trap is O(1).
474
+ * How long a transaction being abandoned (timed out, failed, or returned with
475
+ * work still running) gives its in-flight statement to answer a cancel
476
+ * request, and its ROLLBACK to answer, before the connection is destroyed
477
+ * instead of reused.
478
+ */
479
+ const TX_ABORT_GRACE_MS = 2000;
480
+ /** Default {@link TurbineConfig.maxLifetimeSeconds} on pools Turbine opens. */
481
+ const DEFAULT_MAX_LIFETIME_SECONDS = 1800;
482
+ /**
483
+ * A value as an error message should quote it. `JSON.stringify` writes `NaN` and
484
+ * `Infinity` as `null`, throws on a bigint, and returns `undefined` for a symbol
485
+ * or a function, so a message built from it can name a value the caller never
486
+ * passed.
487
+ */
488
+ function describeValue(value) {
489
+ switch (typeof value) {
490
+ case 'string':
491
+ return JSON.stringify(value);
492
+ case 'bigint':
493
+ return `${value}n`;
494
+ case 'symbol':
495
+ return value.toString();
496
+ case 'function':
497
+ return 'a function';
498
+ case 'object':
499
+ return value === null ? 'null' : Array.isArray(value) ? 'an array' : 'an object';
500
+ default:
501
+ return String(value);
502
+ }
503
+ }
504
+ /**
505
+ * `allowBuiltinSettings` as a lower-cased set (PostgreSQL folds setting names).
506
+ * Each entry must be a plain un-dotted setting name; anything else is E003.
397
507
  */
508
+ function resolveBuiltinSettingsAllowlist(value) {
509
+ if (value === undefined)
510
+ return new Set();
511
+ if (!Array.isArray(value)) {
512
+ throw new errors_js_1.ValidationError(`$transaction: allowBuiltinSettings must be an array of setting names, received ${typeof value}.`);
513
+ }
514
+ const names = new Set();
515
+ for (const name of value) {
516
+ if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
517
+ throw new errors_js_1.ValidationError(`$transaction: allowBuiltinSettings entry ${describeValue(name)} is not a PostgreSQL setting name ` +
518
+ '(a plain identifier such as "statement_timeout"). Namespaced custom settings need no entry.');
519
+ }
520
+ names.add(name.toLowerCase());
521
+ }
522
+ return names;
523
+ }
524
+ /**
525
+ * SQLSTATEs with which the server refuses a `set_config`, and what each means
526
+ * for the caller. A function of the setting where the cause depends on it: the
527
+ * same SQLSTATE says "no such role" for `role` and "unreadable duration" for
528
+ * `statement_timeout`.
529
+ */
530
+ const SESSION_SETTING_REFUSALS = Object.assign(Object.create(null), {
531
+ '22023': (setting) => setting === 'role' || setting === 'session_authorization'
532
+ ? 'the value is not valid for this setting (the role must exist)'
533
+ : 'the value is not valid for this setting',
534
+ '42704': () => 'the server has no setting by that name',
535
+ '42501': () => 'the database role is not allowed to change it',
536
+ '55P02': () => 'it cannot be changed after the connection has started',
537
+ });
398
538
  /**
399
- * Operations that may be served by a read replica.
539
+ * Apply one `sessionContext` entry. A refusal by the server names the setting
540
+ * that caused it rather than reading as a column or type error; the server's
541
+ * own message (which may contain the value) stays on `cause`.
542
+ */
543
+ async function applySessionSetting(client, setting) {
544
+ try {
545
+ await client.query(setting.sql, setting.params);
546
+ }
547
+ catch (err) {
548
+ const code = err?.code;
549
+ const describe = typeof code === 'string' ? SESSION_SETTING_REFUSALS[code] : undefined;
550
+ if (describe === undefined)
551
+ throw (0, errors_js_1.wrapPgError)(err);
552
+ throw new errors_js_1.ValidationError(`sessionContext["${setting.name}"] was refused by the server: ${describe(setting.name.toLowerCase())} (SQLSTATE ${code}). ` +
553
+ 'The transaction was rolled back before your callback ran.', { cause: err });
554
+ }
555
+ }
556
+ /** A non-negative safe integer config value, or `undefined` when unset. Anything else is E003. */
557
+ function resolveNonNegativeInteger(value, name, unit) {
558
+ if (value === undefined || value === null)
559
+ return undefined;
560
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
561
+ throw new errors_js_1.ValidationError(`Invalid ${name}: ${typeof value === 'string' ? JSON.stringify(value) : String(value)}. Expected a ` +
562
+ `non-negative whole number of ${unit}.`);
563
+ }
564
+ return value;
565
+ }
566
+ /**
567
+ * Refuse a dialect with no `capabilities` object (E003): every code path asks
568
+ * it what the engine can do, so a hand-built dialect without one would fail on
569
+ * its first query with a TypeError instead of here.
570
+ */
571
+ function assertDialectCapabilities(dialect) {
572
+ const capabilities = dialect.capabilities;
573
+ if (typeof capabilities === 'object' && capabilities !== null)
574
+ return;
575
+ throw new errors_js_1.ValidationError(`The dialect "${String(dialect.name)}" has no \`capabilities\` object, which says what the engine can do. ` +
576
+ 'Build it from an engine dialect (`{ ...postgresDialect, name, capabilities: { ... } }`) and state every ' +
577
+ 'EngineCapabilities key.');
578
+ }
579
+ /**
580
+ * The statement that opens a transaction, read-only when asked. PostgreSQL and
581
+ * MySQL both accept `READ ONLY` after their own BEGIN form (`BEGIN [ISOLATION
582
+ * LEVEL x] READ ONLY`, `[SET TRANSACTION ISOLATION LEVEL x; ]START TRANSACTION
583
+ * READ ONLY`); the other engines have no read-only transaction.
584
+ */
585
+ function beginStatementFor(dialect, isolationSql, readOnly) {
586
+ const begin = dialect.beginStatement(isolationSql);
587
+ if (!readOnly)
588
+ return begin;
589
+ if (dialect.capabilities.readOnlyTransactions)
590
+ return `${begin} READ ONLY`;
591
+ throw new errors_js_1.UnsupportedFeatureError('$transaction({ readOnly: true })', dialect.name, 'This engine has no read-only transaction mode. Drop the option, or enforce read-only access with a ' +
592
+ 'database role that has no write privileges.');
593
+ }
594
+ /** `readOnly` as a boolean; anything but a boolean or `undefined` is E003. */
595
+ function resolveReadOnly(value) {
596
+ if (value === undefined || value === false)
597
+ return false;
598
+ if (value === true)
599
+ return true;
600
+ throw new errors_js_1.ValidationError(`$transaction: readOnly must be true or false, received ${describeValue(value)}.`);
601
+ }
602
+ /** Whether `pending` settles (either way) within `ms`. */
603
+ async function settlesWithin(pending, ms) {
604
+ let timer;
605
+ const late = new Promise((resolve) => {
606
+ timer = setTimeout(() => resolve(false), ms);
607
+ });
608
+ try {
609
+ return await Promise.race([
610
+ pending.then(() => true, () => true),
611
+ late,
612
+ ]);
613
+ }
614
+ finally {
615
+ clearTimeout(timer);
616
+ }
617
+ }
618
+ /**
619
+ * Stop a transaction that will not commit, on the server and now: cancel the
620
+ * statement it is running (the protocol's CancelRequest, which also reaches the
621
+ * right backend through a pooler), wait for it to answer, then ROLLBACK, which
622
+ * releases every lock the transaction holds.
400
623
  *
401
- * This is a hand-maintained list of names, so it drifts the moment a read
402
- * method is added to `QueryInterface` and not added here. The failure is
403
- * SILENT and directional: the missing operation keeps working, it just runs on
404
- * the PRIMARY, so no test fails and no user sees an error, only a busier
405
- * primary. `findManyStreamBatches` was added and missed exactly this way.
406
- * `src/test/read-operations-drift.test.ts` closes the loop mechanically.
624
+ * Closing the socket alone does not do that. PostgreSQL notices a closed
625
+ * client only when it next writes to it, so a backend waiting on a lock keeps
626
+ * the transaction, and every lock it already took, until that lock clears.
627
+ *
628
+ * Resolves `true` when the connection is rolled back and reusable, `false` when
629
+ * it is not (the cancel could not be sent, the statement or the ROLLBACK did not
630
+ * answer within {@link TX_ABORT_GRACE_MS}, or the ROLLBACK failed); the caller
631
+ * then destroys it. Call after `state.end()`, so nothing new is sent meanwhile.
632
+ */
633
+ async function rollbackOnServer(client, state, dialect) {
634
+ if (state.running) {
635
+ if (!(await (0, utils_js_1.sendCancelRequest)(client, TX_ABORT_GRACE_MS)))
636
+ return false;
637
+ if (!(await state.whenIdle(TX_ABORT_GRACE_MS)))
638
+ return false;
639
+ }
640
+ let failed = false;
641
+ const rollback = client.query(dialect.rollbackStatement()).catch(() => {
642
+ failed = true;
643
+ });
644
+ return (await settlesWithin(rollback, TX_ABORT_GRACE_MS)) && !failed;
645
+ }
646
+ /** `rawUnsafe` takes statement text; a tagged-template call or a non-string is E003. */
647
+ function assertSqlText(text, api) {
648
+ if (typeof text === 'string' && text.trim() !== '')
649
+ return;
650
+ throw new errors_js_1.ValidationError(Array.isArray(text)
651
+ ? `${api} takes the statement as a string, not as a tagged template. Use raw\`...\` or sql\`...\` for a ` +
652
+ 'template, whose values are bound for you.'
653
+ : `${api} takes the statement text as a non-empty string, received ${text === null ? 'null' : typeof text}.`);
654
+ }
655
+ /**
656
+ * The connection `transaction(fn)` and `tx.$connection()` hand out: the
657
+ * checked-out client, with every statement routed through the transaction's
658
+ * state, so one issued after the transaction settled is refused rather than
659
+ * run on a connection that is back in the pool, and transaction-control SQL
660
+ * (`COMMIT`, `BEGIN`, ...) is refused rather than ending the transaction behind
661
+ * the state that tracks it.
662
+ *
663
+ * What would take the connection away is refused too: `release` (the
664
+ * transaction returns it itself), `end` and `connect`, and the driver's
665
+ * `connection` (the wire-level object, whose writes bypass every check here) is
666
+ * hidden. Everything else is the driver client's own.
667
+ */
668
+ function closableRawClient(client, state, scope = state.rootScope, api = 'transaction()') {
669
+ // The refusals name the API the caller used: a `tx.$connection()` caller
670
+ // never called `transaction()`.
671
+ const who = api === 'transaction()' ? 'transaction(): the callback' : '$connection(): the $transaction callback';
672
+ const target = client;
673
+ const send = target.query;
674
+ const query = (...args) => {
675
+ const first = args[0];
676
+ const text = typeof first === 'string' ? first : first?.text;
677
+ // Callback form and submittables (cursors, COPY streams) answer through
678
+ // their own channel, not a promise: checked, then passed straight through.
679
+ if (typeof args[args.length - 1] === 'function' ||
680
+ typeof first?.submit === 'function') {
681
+ state.assertOpen();
682
+ state.screenRaw(text);
683
+ return send.apply(client, args);
684
+ }
685
+ return state.runRaw(text, () => send.apply(client, args), scope);
686
+ };
687
+ const release = () => {
688
+ throw new errors_js_1.ValidationError(`${who} must not release its client; the transaction releases it after COMMIT or ROLLBACK.`);
689
+ };
690
+ const refuse = (verb) => {
691
+ return () => {
692
+ throw new errors_js_1.ValidationError(`${who} must not ${verb} its client; the transaction owns the connection and ` +
693
+ 'returns it to the pool after COMMIT or ROLLBACK.');
694
+ };
695
+ };
696
+ const refused = new Map([
697
+ ['end', refuse('end')],
698
+ ['connect', refuse('connect')],
699
+ ]);
700
+ const bound = new Map();
701
+ return new Proxy(target, {
702
+ has(t, prop) {
703
+ return prop === 'connection' ? false : Reflect.has(t, prop);
704
+ },
705
+ get(t, prop) {
706
+ if (prop === 'query')
707
+ return query;
708
+ if (prop === 'release')
709
+ return release;
710
+ if (prop === 'connection')
711
+ return undefined;
712
+ const refusal = refused.get(prop);
713
+ if (refusal !== undefined)
714
+ return refusal;
715
+ const value = Reflect.get(t, prop, t);
716
+ if (typeof value !== 'function')
717
+ return value;
718
+ let fn = bound.get(prop);
719
+ if (fn === undefined) {
720
+ fn = value.bind(t);
721
+ bound.set(prop, fn);
722
+ }
723
+ return fn;
724
+ },
725
+ });
726
+ }
727
+ /**
728
+ * Operations that may be served by a read replica: the `read` entries of
729
+ * {@link TABLE_METHODS}. Every other method (writes, `build*`, internals) stays
730
+ * on the primary. A Set so the routing proxy's `get` trap is O(1).
731
+ *
732
+ * Derived, not listed, because a hand-kept list drifts SILENTLY: a read method
733
+ * missing here keeps working on the primary, so no test fails. A new public
734
+ * QueryInterface method cannot compile until it is classified in TABLE_METHODS.
735
+ * `src/test/read-operations-drift.test.ts` is the run-time check.
407
736
  *
408
737
  * Exported for that test only.
409
738
  */
410
- exports.READ_OPERATIONS = new Set([
411
- 'findMany',
412
- 'findFirst',
413
- 'findUnique',
414
- 'findFirstOrThrow',
415
- 'findUniqueOrThrow',
416
- 'count',
417
- 'aggregate',
418
- 'groupBy',
419
- 'findManyStream',
420
- 'findManyStreamBatches',
421
- ]);
739
+ exports.READ_OPERATIONS = new Set(Object.entries(table_client_js_1.TABLE_METHODS)
740
+ .filter(([, kind]) => kind === 'read')
741
+ .map(([name]) => name));
422
742
  /**
423
743
  * Internal marker on the config object that tells the `TurbineClient`
424
744
  * constructor to build a lightweight "primary-only" view sharing an existing
@@ -433,6 +753,10 @@ const PRIMARY_VIEW = Symbol('turbine.primaryView');
433
753
  * A transaction-scoped client that provides the same table accessor API as TurbineClient.
434
754
  * All queries run on a dedicated connection within a BEGIN/COMMIT block.
435
755
  * Supports nested transactions via SAVEPOINTs.
756
+ *
757
+ * Closed when its transaction settles (commit, rollback, timeout, error): every
758
+ * later call through it throws `ValidationError` (E003) instead of running on a
759
+ * connection that is back in the pool and may belong to another request.
436
760
  */
437
761
  class TransactionClient {
438
762
  client;
@@ -441,9 +765,19 @@ class TransactionClient {
441
765
  queryOptions;
442
766
  sourcePool;
443
767
  tableCache = new Map();
444
- savepointCounter = 0;
445
768
  /** Active SQL dialect, owns savepoint keywords and raw-SQL placeholders. */
446
769
  dialect;
770
+ /** Open/closed state, first failure and savepoint blocks, shared by everything this client hands out. */
771
+ state;
772
+ /**
773
+ * Where in the transaction this handle sits: the transaction itself, or the
774
+ * nested block whose callback received it. Statements carry it, so one issued
775
+ * through an outer handle from inside a block can be told from one issued
776
+ * through the block's own.
777
+ */
778
+ scope;
779
+ /** What {@link $connection} hands out, built on first use. */
780
+ connectionHandle;
447
781
  constructor(client, schema, middlewares, queryOptions,
448
782
  /**
449
783
  * The parent pool this transaction runs on. Only its `readonly` and
@@ -454,16 +788,26 @@ class TransactionClient {
454
788
  * (emitting join PowQL a pre-0.13 engine rejects). Undefined / absent flags
455
789
  * for a plain pg pool leave the proxy unchanged.
456
790
  */
457
- sourcePool) {
791
+ sourcePool,
792
+ /**
793
+ * The state of the transaction this client belongs to. `$transaction`
794
+ * passes the one it opened, which also turns on per-write savepoints for
795
+ * multi-statement writes. Omitted for Turbine's own implicit transactions.
796
+ */
797
+ state,
798
+ /** The nested block this handle belongs to; omitted for the transaction's own handle. */
799
+ scope) {
458
800
  this.client = client;
459
801
  this.schema = schema;
460
802
  this.middlewares = middlewares;
461
803
  this.queryOptions = queryOptions;
462
804
  this.sourcePool = sourcePool;
463
805
  this.dialect = queryOptions?.dialect ?? dialect_js_1.postgresDialect;
806
+ this.state = state ?? new transaction_state_js_1.TransactionState(client, this.dialect);
807
+ this.scope = scope ?? this.state.rootScope;
464
808
  // Auto-create typed table accessors for all tables in the schema
465
809
  for (const tableName of Object.keys(schema.tables)) {
466
- const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
810
+ const camelName = (0, global_filters_config_js_1.tableAccessorName)(tableName);
467
811
  if (!(camelName in this)) {
468
812
  Object.defineProperty(this, camelName, {
469
813
  get: () => this.table(tableName),
@@ -484,43 +828,121 @@ class TransactionClient {
484
828
  const txPool = this.createTxPool();
485
829
  const txOpts = { ...this.queryOptions, _txScoped: true };
486
830
  qi = txOpts.queryInterfaceFactory
487
- ? txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts)
831
+ ? (0, table_client_js_1.asQueryInterface)(txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts))
488
832
  : new index_js_1.QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
833
+ if (this.state.savepointWrites)
834
+ savepointMultiStatementWrites(qi, this.schema.tables[name], this.state);
489
835
  this.tableCache.set(name, qi);
490
836
  }
491
837
  return qi;
492
838
  }
493
839
  /**
494
- * Execute a nested transaction via SAVEPOINT.
495
- * If the inner function throws, only the savepoint is rolled back.
840
+ * The driver connection this transaction runs on, for code that needs one:
841
+ * another library's query function, a COPY stream, a driver feature Turbine
842
+ * does not wrap. Statements sent on it run inside this transaction.
843
+ *
844
+ * - It closes with the transaction: a statement sent after COMMIT or
845
+ * ROLLBACK is refused with `ValidationError` (E003) instead of running on a
846
+ * connection that is back in the pool.
847
+ * - `release()` is refused. The transaction returns the connection.
848
+ * - COMMIT, ROLLBACK, BEGIN, SAVEPOINT, RELEASE and SET TRANSACTION are
849
+ * refused with `ValidationError` (E003): the transaction ends itself, and
850
+ * Turbine would then commit or roll back a transaction that is no longer
851
+ * open. Use `tx.$transaction` for a savepoint.
852
+ * - `end()` and `connect()` are refused like `release()`, and the driver's
853
+ * `connection` object is not reachable through it.
854
+ * - Its queries use the driver's own parsers. On a pool you supplied, that is
855
+ * your parser configuration, not Turbine's int8 and UTC readings.
856
+ *
857
+ * Typed as the driver-neutral client. For the pg-only surface, cast:
858
+ * `tx.$connection() as unknown as PoolClient`.
859
+ *
860
+ * Throws `UnsupportedFeatureError` (E017) where no driver connection backs
861
+ * the transaction: PowDB, and a stateless HTTP driver.
496
862
  */
497
- async $transaction(fn) {
498
- const savepointName = `sp_${++this.savepointCounter}`;
499
- await this.client.query(this.dialect.savepointStatement(savepointName));
500
- try {
501
- const result = await fn(this);
502
- await this.client.query(this.dialect.releaseSavepointStatement(savepointName));
503
- return result;
863
+ $connection() {
864
+ this.state.assertOpen();
865
+ if (this.connectionHandle)
866
+ return this.connectionHandle;
867
+ const raw = (0, supplied_pool_types_js_1.rawClientOf)(this.client);
868
+ const driver = this.dialect.capabilities.connectionDriver;
869
+ if (driver === 'none') {
870
+ throw new errors_js_1.UnsupportedFeatureError('tx.$connection()', this.dialect.name, 'A PowDB transaction has no driver connection to hand out. Run PowQL inside it with `tx.rawUnsafe(text, ...values)`.');
504
871
  }
505
- catch (err) {
506
- await this.client.query(this.dialect.rollbackToSavepointStatement(savepointName));
507
- throw err;
872
+ if (driver === 'pg-compatible' && typeof raw.on !== 'function') {
873
+ throw new errors_js_1.UnsupportedFeatureError('tx.$connection()', this.dialect.name, 'The pool returned a client with no event support, as a stateless HTTP driver does, so there is no ' +
874
+ 'connection to hand out. Use `tx.raw` / `tx.rawUnsafe`, or a TCP pool.');
508
875
  }
876
+ this.connectionHandle = closableRawClient(raw, this.state, this.scope, '$connection()');
877
+ return this.connectionHandle;
509
878
  }
510
879
  /**
511
- * Execute a raw SQL query within this transaction.
880
+ * Execute a nested transaction via SAVEPOINT.
881
+ *
882
+ * If the inner function throws, only the savepoint is rolled back. Blocks
883
+ * started side by side (`Promise.all`) run one after another, because
884
+ * savepoints are a stack on the connection and a rollback in one would
885
+ * otherwise undo a sibling that already reported success.
886
+ *
887
+ * The callback receives a `tx` of its own, bound to the block. Use it for the
888
+ * work inside the block: a statement through the OUTER `tx` from inside the
889
+ * block would run in the block's savepoint and be undone with it, so it is
890
+ * refused with `ValidationError` (E003).
891
+ */
892
+ async $transaction(fn) {
893
+ return this.state.withBlock((scope) => fn(this.inScope(scope)), this.scope);
894
+ }
895
+ /** A handle on the same transaction bound to nested block `scope`. */
896
+ inScope(scope) {
897
+ return new TransactionClient(this.client, this.schema, this.middlewares, this.queryOptions, this.sourcePool, this.state, scope);
898
+ }
899
+ /**
900
+ * Execute a raw SQL query within this transaction, as a tagged template:
901
+ * every `${value}` is sent as a bound parameter.
512
902
  */
513
903
  async raw(strings, ...values) {
514
- let sql = '';
515
- strings.forEach((str, i) => {
516
- sql += str;
517
- if (i < values.length) {
518
- sql += this.dialect.paramPlaceholder(i + 1);
519
- }
520
- });
521
- const result = await (0, query_events_js_1.runReportedStatement)(this.queryOptions?._onQuery, (0, query_events_js_1.rawIdentity)('raw'), sql, values, () => this.client.query(sql, values), errors_js_1.wrapPgError);
904
+ const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect, 'raw');
905
+ return this.runRaw(sql, params, 'raw');
906
+ }
907
+ /**
908
+ * Execute SQL text built at runtime within this transaction. See
909
+ * {@link TurbineClient.rawUnsafe}: nothing in `text` is escaped, so it must
910
+ * never contain untrusted input. Values go in `values`, referenced as `$1`,
911
+ * `$2`, ... (the engine's own placeholders on other engines).
912
+ */
913
+ async rawUnsafe(text, ...values) {
914
+ assertSqlText(text, 'rawUnsafe');
915
+ return this.runRaw(text, values, 'rawUnsafe');
916
+ }
917
+ /**
918
+ * Typed raw SQL on this transaction's connection, like
919
+ * {@link TurbineClient.sql}: await it for `T[]`, or call `.one()` /
920
+ * `.scalar()`. Refused, like every other `tx` call, once the transaction has
921
+ * settled.
922
+ */
923
+ sql(strings, ...values) {
924
+ this.state.assertOpen();
925
+ const { sql, params: bound } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect);
926
+ const params = this.rawParams(bound, 'sql');
927
+ const state = this.state;
928
+ const client = this.client;
929
+ const scope = this.scope;
930
+ const pool = {
931
+ query: (text, vals) => state.runRaw(text, () => client.query(text, vals), scope),
932
+ };
933
+ return new typed_sql_js_1.TypedSqlQuery(pool, sql, params, false, undefined, this.queryOptions?._onQuery);
934
+ }
935
+ async runRaw(sql, values, action) {
936
+ const params = this.rawParams(values, action);
937
+ const result = await this.state.runRaw(sql, () => (0, query_events_js_1.runReportedStatement)(this.queryOptions?._onQuery, (0, query_events_js_1.rawIdentity)(action), sql, params, () => this.client.query(sql, params), errors_js_1.wrapPgError), this.scope);
522
938
  return result.rows;
523
939
  }
940
+ /** Bound Dates as UTC text on a PostgreSQL client reading temporal types as UTC. */
941
+ rawParams(params, api) {
942
+ return this.dialect.capabilities.utcTimestampRewrite && this.queryOptions?.utcTimestamps !== false
943
+ ? (0, typed_sql_js_1.utcDateParams)(params, api)
944
+ : params;
945
+ }
524
946
  /**
525
947
  * @internal The `turbine-orm/prisma-compat` adapter's transaction seam. NOT
526
948
  * application API: use {@link raw}, whose tagged template makes concatenating
@@ -548,7 +970,8 @@ class TransactionClient {
548
970
  * passes `'$queryRaw'` / `'$executeRaw'` ...; default `'rawQuery'`).
549
971
  */
550
972
  async rawQuery(text, params = [], action = 'rawQuery') {
551
- const result = await (0, query_events_js_1.runReportedStatement)(this.queryOptions?._onQuery, (0, query_events_js_1.rawIdentity)(action), text, params, () => this.client.query(text, params), errors_js_1.wrapPgError);
973
+ const bound = this.rawParams(params, action);
974
+ const result = await this.state.runRaw(text, () => (0, query_events_js_1.runReportedStatement)(this.queryOptions?._onQuery, (0, query_events_js_1.rawIdentity)(action), text, bound, () => this.client.query(text, bound), errors_js_1.wrapPgError), this.scope);
552
975
  return { rows: result.rows, rowCount: result.rowCount };
553
976
  }
554
977
  /**
@@ -558,28 +981,40 @@ class TransactionClient {
558
981
  *
559
982
  * pg driver errors thrown by queries are translated into typed Turbine
560
983
  * errors via wrapPgError so transaction-scoped queries surface the same
561
- * typed errors as pool-scoped queries.
984
+ * typed errors as pool-scoped queries. Every statement passes through the
985
+ * transaction state, which refuses it once the transaction has settled.
562
986
  */
563
987
  createTxPool() {
564
988
  const client = this.client;
989
+ const state = this.state;
990
+ const scope = this.scope;
991
+ const query = (textOrConfig, values) => state.run(async () => {
992
+ try {
993
+ if (typeof textOrConfig === 'string') {
994
+ return await client.query(textOrConfig, values);
995
+ }
996
+ // Object form for prepared statements: { name, text, values }
997
+ // A driver client accepts it, but the two-argument `query` in the
998
+ // PgCompatPoolClient contract does not describe it, so we cast.
999
+ return await client.query(textOrConfig);
1000
+ }
1001
+ catch (err) {
1002
+ throw (0, errors_js_1.wrapPgError)(err);
1003
+ }
1004
+ }, scope);
565
1005
  // Return a minimal pool-compatible object that routes queries
566
1006
  // through the transaction client
567
1007
  const txPool = {
568
- query: async (textOrConfig, values) => {
569
- try {
570
- if (typeof textOrConfig === 'string') {
571
- return await client.query(textOrConfig, values);
572
- }
573
- // Object form for prepared statements: { name, text, values }
574
- // A driver client accepts it, but the two-argument `query` in the
575
- // PgCompatPoolClient contract does not describe it, so we cast.
576
- return await client.query(textOrConfig);
577
- }
578
- catch (err) {
579
- throw (0, errors_js_1.wrapPgError)(err);
580
- }
1008
+ query,
1009
+ // The transaction's connection is not this pool's to lend: a "checkout"
1010
+ // runs on it through the same guarded `query`, and releasing it is a
1011
+ // no-op, so nothing reached through the pool can hand the connection back
1012
+ // to the real pool while the transaction still holds it.
1013
+ connect: async () => {
1014
+ state.assertOpen();
1015
+ return { query, release: () => { } };
581
1016
  },
582
- connect: () => Promise.resolve(client),
1017
+ [transaction_state_js_1.TRANSACTION_STATE]: state,
583
1018
  };
584
1019
  // Carry the parent pool's PowDB-only flags through so a transaction-scoped
585
1020
  // PowqlInterface reads the same read-only guard and capabilities it would
@@ -592,6 +1027,47 @@ class TransactionClient {
592
1027
  }
593
1028
  }
594
1029
  exports.TransactionClient = TransactionClient;
1030
+ /**
1031
+ * Give each multi-statement write on a transaction-scoped accessor its own
1032
+ * savepoint: `create` / `update` carrying nested relation writes, and an
1033
+ * `upsert` that looks the row up first.
1034
+ *
1035
+ * Joining the caller's transaction statement by statement, a failure raised
1036
+ * part way through (a nested `connect` that finds nothing, a validation error
1037
+ * found mid-walk) would leave the statements before it in place, and a caller
1038
+ * that caught the error and went on would commit half of the write. In its own
1039
+ * savepoint the write is all or nothing, and the caller's transaction
1040
+ * continues either way.
1041
+ *
1042
+ * Installed as own methods on the accessor this transaction created and owns,
1043
+ * so a single-statement write pays one property lookup and a shape check.
1044
+ */
1045
+ function savepointMultiStatementWrites(qi, meta, state) {
1046
+ if (!meta)
1047
+ return;
1048
+ const target = qi;
1049
+ const nestedData = (args) => {
1050
+ const data = args?.data;
1051
+ return data !== null && typeof data === 'object' && (0, nested_write_js_1.hasRelationFields)(data, meta);
1052
+ };
1053
+ const lookupUpsert = (args) => {
1054
+ const needsLookup = qi.upsertNeedsLookup;
1055
+ return typeof needsLookup === 'function' && args !== undefined && needsLookup.call(qi, args);
1056
+ };
1057
+ const wrap = (method, multiStatement) => {
1058
+ const original = target[method];
1059
+ if (typeof original !== 'function')
1060
+ return;
1061
+ Object.defineProperty(qi, method, {
1062
+ value: (args) => multiStatement(args) ? state.withSavepoint(() => original.call(qi, args)) : original.call(qi, args),
1063
+ configurable: true,
1064
+ writable: true,
1065
+ });
1066
+ };
1067
+ wrap('create', nestedData);
1068
+ wrap('update', nestedData);
1069
+ wrap('upsert', lookupUpsert);
1070
+ }
595
1071
  // ---------------------------------------------------------------------------
596
1072
  // TurbineClient
597
1073
  // ---------------------------------------------------------------------------
@@ -609,6 +1085,13 @@ class TurbineClient {
609
1085
  * `db.pool as unknown as Pool`.
610
1086
  */
611
1087
  pool;
1088
+ /**
1089
+ * The pool Turbine's own statements go through: {@link pool}, or for a
1090
+ * caller-supplied PostgreSQL pool a view of it whose queries carry Turbine's
1091
+ * int8 and temporal parsers (see supplied-pool-types.ts). `pool` stays the
1092
+ * caller's object, and queries the caller runs on it keep their parsers.
1093
+ */
1094
+ queryPool;
612
1095
  /** The schema metadata this client was built from */
613
1096
  schema;
614
1097
  static int8ParserRegistered = false;
@@ -636,6 +1119,10 @@ class TurbineClient {
636
1119
  */
637
1120
  static utcTimestampParsersRegistered = false;
638
1121
  logging;
1122
+ /** Where this client's log lines go (the `logger` option, else console). */
1123
+ logger;
1124
+ /** The `diagnostics` option, resolved once at construction. */
1125
+ diagnostics;
639
1126
  /** Active SQL dialect, owns transaction keywords, set_config, raw-SQL placeholders, capability flags. */
640
1127
  dialect;
641
1128
  /** Validated `plan_cache_mode` to pin on every owned connection, or undefined to issue nothing. */
@@ -644,6 +1131,10 @@ class TurbineClient {
644
1131
  middlewares = [];
645
1132
  queryListeners = new Set();
646
1133
  queryOptions;
1134
+ /** The resolved `sqlComment` every accessor shares, for the batch paths that bypass them. */
1135
+ get sqlCommenter() {
1136
+ return this.queryOptions._sqlCommenter;
1137
+ }
647
1138
  errorMessagesSafe;
648
1139
  /**
649
1140
  * THIS client's error-message mode. Not the process default: see
@@ -660,6 +1151,10 @@ class TurbineClient {
660
1151
  ownsPool = true;
661
1152
  /** Active LISTEN subscriptions, torn down on disconnect() so it never hangs */
662
1153
  activeSubscriptions = new Set();
1154
+ /** `$on('poolError')` / `$on('connectionLost')` listeners. */
1155
+ poolListeners = { poolError: new Set(), connectionLost: new Set() };
1156
+ /** What each pool Turbine opened has checked out, for `disconnect({ timeoutMs })`. */
1157
+ checkoutWatches = [];
663
1158
  /**
664
1159
  * Read-replica pools in round-robin order. Empty when no replicas are
665
1160
  * configured, in which case `table()` takes the original single-pool path.
@@ -689,19 +1184,23 @@ class TurbineClient {
689
1184
  const parent = seed.parent;
690
1185
  this.schema = schema;
691
1186
  this.logging = parent.logging;
1187
+ this.logger = parent.logger;
1188
+ this.diagnostics = parent.diagnostics;
692
1189
  this.dialect = parent.dialect;
693
1190
  this.errorMessagesSafe = parent.errorMessagesSafe;
694
1191
  this.errorMessageMode = parent.errorMessageMode;
695
1192
  this.queryParamsVisible = parent.queryParamsVisible;
696
1193
  this.queryOptions = parent.queryOptions;
697
1194
  this.middlewares = parent.middlewares; // shared reference: $use on parent flows through
1195
+ this.poolListeners = parent.poolListeners;
698
1196
  this.pool = parent.pool;
1197
+ this.queryPool = parent.queryPool;
699
1198
  this.ownsPool = false;
700
1199
  this.replicaPools = [];
701
1200
  this.ownedReplicaPools = [];
702
1201
  this.replicaTableCaches = [];
703
1202
  for (const tableName of Object.keys(schema.tables)) {
704
- const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1203
+ const camelName = (0, global_filters_config_js_1.tableAccessorName)(tableName);
705
1204
  if (!(camelName in this)) {
706
1205
  Object.defineProperty(this, camelName, {
707
1206
  get: () => this.table(tableName),
@@ -739,15 +1238,38 @@ class TurbineClient {
739
1238
  }
740
1239
  break;
741
1240
  }
1241
+ // The logger and the diagnostics mode are validated first, so every line
1242
+ // below, the construction warnings included, reaches the caller's logger.
1243
+ // NODE_ENV is read here and nowhere on a query path.
1244
+ const logger = (0, logger_js_1.resolveLogger)(config.logger);
1245
+ const diagnostics = (0, diagnostics_js_1.resolveDiagnostics)(config.diagnostics);
742
1246
  // Name any key on the config object that is not part of the config surface
743
- // (dev only, once per key, never throws). See warnUnknownConfigKeys.
744
- warnUnknownConfigKeys(config);
1247
+ // (once per key, never throws). See warnUnknownConfigKeys.
1248
+ warnUnknownConfigKeys(config, logger);
745
1249
  // ALL config validation runs before ANY process-global side effect below.
746
1250
  // A constructor that throws must leave the process exactly as it found it:
747
1251
  // settling the process-global parser mode and then rejecting the config
748
1252
  // would poison the next, valid, TurbineClient with a phantom conflict.
749
1253
  const dialect = config.dialect ?? dialect_js_1.postgresDialect;
1254
+ assertDialectCapabilities(dialect);
750
1255
  const planCacheMode = TurbineClient.resolvePlanCacheMode(config.planCacheMode, dialect);
1256
+ const statementTimeoutMs = TurbineClient.resolveStatementTimeout(config.statementTimeoutMs, dialect);
1257
+ const maxLifetimeSeconds = resolveNonNegativeInteger(config.maxLifetimeSeconds, 'maxLifetimeSeconds', 'seconds');
1258
+ const streamIdleTimeoutMs = (0, stream_idle_js_1.resolveStreamIdleTimeout)(config.streamIdleTimeoutMs, 'streamIdleTimeoutMs');
1259
+ // A backend that injects its own query interface (PowDB) builds no SQL,
1260
+ // so there is nothing for a comment to ride on: refused, not ignored.
1261
+ const sqlComment = (0, sql_comment_js_1.resolveSqlComment)(config.sqlComment, logger, {
1262
+ dialect: dialect.name,
1263
+ preparesEveryStatement: dialect.capabilities.preparesEveryStatement,
1264
+ buildsSql: config.queryInterfaceFactory === undefined,
1265
+ });
1266
+ // Keyed by table name, with every key checked against the schema: a
1267
+ // filter under a name that matches no table would never be applied.
1268
+ // A non-SQL backend (PowDB) injects its own query interface and checks
1269
+ // its own filters; every SQL engine compiles each static filter here once,
1270
+ // with this client's dialect, so a value the compiler refuses fails now.
1271
+ const factory = config.queryInterfaceFactory;
1272
+ const globalFilters = (0, global_filters_config_js_1.normalizeGlobalFilters)(config.globalFilters, schema, factory === undefined ? { QueryInterface: index_js_1.QueryInterface, dialect, utcTimestamps: config.utcTimestamps } : undefined);
751
1273
  // Refuse a connection string that cannot address a database, before it
752
1274
  // reaches pg's permissive parser and comes back one query later as a DNS
753
1275
  // error about a fragment of itself. Only the strings this client will
@@ -801,16 +1323,14 @@ class TurbineClient {
801
1323
  // fork and rely on their own parser configuration, registration is
802
1324
  // process-global, so flipping it because a string replica exists alongside
803
1325
  // an external primary would silently change the external primary's parsing
804
- // too. String replicas configured next to an external primary therefore
805
- // inherit the caller's parser configuration (documented). Registration is
806
- // constructor-gated by the static flags, so it happens at most once.
1326
+ // too. With an external primary, Turbine's own queries carry the parsers
1327
+ // per query instead, on the primary and on every replica (see typedPool).
1328
+ // Registration is constructor-gated by the static flags, so it happens at
1329
+ // most once.
807
1330
  const ownsAnyPool = !config.pool;
808
1331
  if (ownsAnyPool && !TurbineClient.int8ParserRegistered) {
809
- (0, utils_js_1.warnParserOverwrite)(20, 'int8');
810
- _pg_1.default.types.setTypeParser(20, (0, utils_js_1.markTurbineParser)((val) => {
811
- const n = Number(val);
812
- return Number.isSafeInteger(n) ? n : val;
813
- }));
1332
+ (0, utils_js_1.warnParserOverwrite)(20, 'int8', logger);
1333
+ _pg_1.default.types.setTypeParser(20, supplied_pool_types_js_1.parseInt8);
814
1334
  TurbineClient.int8ParserRegistered = true;
815
1335
  }
816
1336
  // Parse the zone-less temporal types (`timestamp` OID 1114, `date` OID
@@ -835,16 +1355,22 @@ class TurbineClient {
835
1355
  const wantUtcTimestamps = config.utcTimestamps !== false;
836
1356
  TurbineClient.assertUtcTimestampsAgree(wantUtcTimestamps);
837
1357
  if (ownsAnyPool && wantUtcTimestamps && !TurbineClient.utcTimestampParsersRegistered) {
838
- (0, utils_js_1.registerUtcTemporalParsers)();
1358
+ (0, utils_js_1.registerUtcTemporalParsers)(logger);
839
1359
  TurbineClient.utcTimestampParsersRegistered = true;
840
1360
  }
841
1361
  TurbineClient.utcTimestampParserMode = wantUtcTimestamps;
842
1362
  this.logging = config.logging ?? false;
1363
+ this.logger = logger;
1364
+ this.diagnostics = diagnostics;
843
1365
  this.dialect = dialect;
844
1366
  this.planCacheMode = planCacheMode;
845
1367
  this.schema = schema;
846
1368
  // Respect env var kill switch
847
1369
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
1370
+ const pooler = config.pool ? undefined : TurbineClient.poolerEndpoint(config);
1371
+ if (config.preparedStatements === true && pooler !== undefined) {
1372
+ TurbineClient.warnPreparedOnPooler(pooler, logger);
1373
+ }
848
1374
  this.errorMessageMode = config.errorMessages ?? 'safe';
849
1375
  this.errorMessagesSafe = this.errorMessageMode === 'safe';
850
1376
  // Query-event param visibility. One derived boolean, so the two config
@@ -863,15 +1389,19 @@ class TurbineClient {
863
1389
  autoToOneJoinMaxRows: config.autoToOneJoinMaxRows,
864
1390
  autoRoundTripMs: config.autoRoundTripMs,
865
1391
  jsonEncoding: config.jsonEncoding,
866
- globalFilters: config.globalFilters,
867
- preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
1392
+ globalFilters,
1393
+ preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? (!config.pool && !pooler)),
868
1394
  // Forwarded so a per-query `forceCustomPlan` can refuse the combination
869
1395
  // this client's own connections would silently defeat, see the
870
1396
  // `planCacheMode` note on QueryInterfaceOptions.
871
1397
  planCacheMode,
872
1398
  sqlCache: config.sqlCache ?? true,
873
1399
  sqlCacheSize: config.sqlCacheSize,
1400
+ streamIdleTimeoutMs,
1401
+ _sqlCommenter: sqlComment,
874
1402
  dialect: config.dialect,
1403
+ diagnostics,
1404
+ logger,
875
1405
  // Non-SQL backends (PowDB) inject a factory that builds their own query
876
1406
  // interface (PowqlInterface) instead of the SQL QueryInterface. SQL engines
877
1407
  // never set this, so `table()` keeps constructing `new QueryInterface`.
@@ -891,7 +1421,7 @@ class TurbineClient {
891
1421
  }
892
1422
  catch (e) {
893
1423
  if (this.logging)
894
- console.error('[turbine] Query listener error:', e);
1424
+ (0, logger_js_1.logTo)(this.logger, 'error', 'Query listener error:', { code: 'query-listener-error', error: e });
895
1425
  }
896
1426
  }
897
1427
  },
@@ -914,11 +1444,24 @@ class TurbineClient {
914
1444
  if (config.pool) {
915
1445
  // External pool, use directly. Turbine doesn't manage its lifecycle.
916
1446
  this.pool = config.pool;
1447
+ // Turbine registers no parsers for a pool it did not open, so its own
1448
+ // queries on this one carry them instead.
1449
+ this.queryPool = this.typedPool(config.pool);
917
1450
  this.ownsPool = false;
918
1451
  if (this.logging) {
919
- console.log(`[turbine] Using external pool, ${Object.keys(schema.tables).length} tables`);
1452
+ const tables = Object.keys(schema.tables).length;
1453
+ (0, logger_js_1.logTo)(this.logger, 'debug', `Using external pool, ${tables} tables`, { code: 'external-pool', tables });
920
1454
  }
921
1455
  }
1456
+ else if (!hasDatabaseTarget(config)) {
1457
+ // Nothing names a database: no pool, connection string, host settings,
1458
+ // DATABASE_URL or PG* variable. The first query fails with the fix,
1459
+ // which beats connecting to a guessed localhost. Failing here instead
1460
+ // would break every build step that imports the client without a database.
1461
+ this.pool = unconfiguredPool(config.connectionString === '');
1462
+ this.queryPool = this.pool;
1463
+ this.ownsPool = true;
1464
+ }
922
1465
  else {
923
1466
  const poolConfig = {
924
1467
  max: config.poolSize ?? config.max ?? 10,
@@ -945,10 +1488,12 @@ class TurbineClient {
945
1488
  poolConfig.connectionString = process.env.DATABASE_URL;
946
1489
  }
947
1490
  else {
948
- poolConfig.host = config.host ?? 'localhost';
949
- poolConfig.port = config.port ?? 5432;
950
- poolConfig.database = config.database ?? 'postgres';
951
- poolConfig.user = config.user ?? 'postgres';
1491
+ // A default filled in here would override the PG* variable pg reads
1492
+ // for the same field, so a field the environment sets is left to pg.
1493
+ poolConfig.host = config.host ?? (pgEnv('PGHOST') ? undefined : 'localhost');
1494
+ poolConfig.port = config.port ?? (pgEnv('PGPORT') ? undefined : 5432);
1495
+ poolConfig.database = config.database ?? (pgEnv('PGDATABASE') ? undefined : 'postgres');
1496
+ poolConfig.user = config.user ?? (pgEnv('PGUSER') ? undefined : 'postgres');
952
1497
  poolConfig.password = config.password;
953
1498
  }
954
1499
  if (config.ssl !== undefined) {
@@ -957,19 +1502,25 @@ class TurbineClient {
957
1502
  // Held as a `pg.Pool` for the length of this block: `PgCompatPool.on` is
958
1503
  // optional (HTTP drivers have no pool events), and here we know we own a
959
1504
  // real pg pool that has one. Same shape as the replica loop below.
960
- const ownPool = new _pg_1.default.Pool(TurbineClient.withPlanCacheMode(poolConfig, this.planCacheMode));
961
- ownPool.on('error', (err) => {
962
- console.error('[turbine] Unexpected pool error:', err.message);
963
- });
964
- (0, connection_guard_js_1.absorbCheckedOutErrors)(ownPool);
1505
+ const ownPool = new _pg_1.default.Pool(TurbineClient.ownedPoolConfig(poolConfig, planCacheMode, statementTimeoutMs, maxLifetimeSeconds));
1506
+ this.watchOwnedPool(ownPool, { pool: 'primary' });
965
1507
  // After a freeze (a serverless function between invocations), wait one
966
1508
  // loop turn before reusing a long-idle connection, so the pool has read
967
1509
  // any close the server sent meanwhile. See connection-guard.ts.
968
1510
  (0, connection_guard_js_1.settleLongIdleCheckouts)(ownPool);
1511
+ // An exhausted pool rejects a checkout with an untyped pg-pool error;
1512
+ // make it a ConnectionError on every path, `pool.query` included.
1513
+ (0, checkout_js_1.typeCheckoutTimeouts)(ownPool);
969
1514
  this.pool = ownPool;
1515
+ this.queryPool = ownPool;
970
1516
  this.ownsPool = true;
971
1517
  if (this.logging) {
972
- console.log(`[turbine] Pool created, max ${poolConfig.max} connections, ${Object.keys(schema.tables).length} tables`);
1518
+ const tables = Object.keys(schema.tables).length;
1519
+ (0, logger_js_1.logTo)(this.logger, 'debug', `Pool created, max ${poolConfig.max} connections, ${tables} tables`, {
1520
+ code: 'pool-created',
1521
+ max: poolConfig.max,
1522
+ tables,
1523
+ });
973
1524
  }
974
1525
  }
975
1526
  // Build read-replica pools (if any). String entries become owned pg.Pools
@@ -979,23 +1530,23 @@ class TurbineClient {
979
1530
  this.ownedReplicaPools = [];
980
1531
  for (const replica of config.replicas ?? []) {
981
1532
  if (typeof replica === 'string') {
982
- const replicaPool = new _pg_1.default.Pool(TurbineClient.withPlanCacheMode({
1533
+ const replicaPool = new _pg_1.default.Pool(TurbineClient.ownedPoolConfig({
983
1534
  connectionString: replica,
984
1535
  max: config.poolSize ?? config.max ?? 10,
985
1536
  idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
986
1537
  connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
987
1538
  ...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
988
- }, this.planCacheMode));
989
- replicaPool.on('error', (err) => {
990
- console.error('[turbine] Unexpected replica pool error:', err.message);
991
- });
992
- (0, connection_guard_js_1.absorbCheckedOutErrors)(replicaPool);
1539
+ }, planCacheMode, statementTimeoutMs, maxLifetimeSeconds));
1540
+ this.watchOwnedPool(replicaPool, { pool: 'replica', replica: this.replicaPools.length });
993
1541
  (0, connection_guard_js_1.settleLongIdleCheckouts)(replicaPool);
994
- this.replicaPools.push(replicaPool);
1542
+ (0, checkout_js_1.typeCheckoutTimeouts)(replicaPool);
1543
+ // Next to a supplied primary no parsers were registered, so this
1544
+ // replica's reads carry them like the primary's do.
1545
+ this.replicaPools.push(this.ownsPool ? replicaPool : this.typedPool(replicaPool));
995
1546
  this.ownedReplicaPools.push(replicaPool);
996
1547
  }
997
1548
  else {
998
- this.replicaPools.push(replica);
1549
+ this.replicaPools.push(this.typedPool(replica));
999
1550
  }
1000
1551
  }
1001
1552
  // `planCacheMode` reaches a pool only where Turbine opens the connections.
@@ -1006,26 +1557,34 @@ class TurbineClient {
1006
1557
  // Deliberate no-op rather than a throw: the option is a performance knob,
1007
1558
  // and an app that moves from an owned pool to a serverless driver should
1008
1559
  // not stop booting over it. Same ownership rule as the type parsers, which
1009
- // also skip external pools silently.
1010
- if (this.planCacheMode !== undefined && !this.ownsPool && process.env.NODE_ENV !== 'production') {
1560
+ // also skip external pools silently. Always on, like every construction
1561
+ // warning: the option is just as ignored in production.
1562
+ if (this.planCacheMode !== undefined && !this.ownsPool) {
1011
1563
  if ((0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.planCacheModeIgnored, this.planCacheMode)) {
1012
1564
  const replicaNote = this.ownedReplicaPools.length > 0
1013
1565
  ? ` It IS applied to the ${this.ownedReplicaPools.length} Turbine-owned read replica pool(s) on this ` +
1014
1566
  'client, so reads and writes would run under different plan-cache policies until the primary is set too.'
1015
1567
  : '';
1016
- console.warn(`[turbine] planCacheMode: '${this.planCacheMode}' was not applied to the primary: this client was given an ` +
1568
+ (0, logger_js_1.logTo)(this.logger, 'warn', `planCacheMode: '${this.planCacheMode}' was not applied to the primary: this client was given an ` +
1017
1569
  'external `pool`, whose connection lifecycle the caller owns, so Turbine never opens its connections. Set ' +
1018
1570
  `\`plan_cache_mode\` in the driver's own connection setup (or run \`SET plan_cache_mode = ${this.planCacheMode}\` ` +
1019
- `on checkout) instead.${replicaNote}`);
1571
+ `on checkout) instead.${replicaNote}`, { code: 'plan-cache-mode-ignored', option: 'planCacheMode', ownedReplicas: this.ownedReplicaPools.length });
1020
1572
  }
1021
1573
  }
1574
+ this.warnExternalPoolOptions(statementTimeoutMs, maxLifetimeSeconds);
1022
1575
  this.replicaTableCaches = this.replicaPools.map(() => new Map());
1023
1576
  if (this.logging && this.replicaPools.length > 0) {
1024
- console.log(`[turbine] ${this.replicaPools.length} read replica(s) configured (${this.ownedReplicaPools.length} owned)`);
1577
+ const replicas = this.replicaPools.length;
1578
+ const owned = this.ownedReplicaPools.length;
1579
+ (0, logger_js_1.logTo)(this.logger, 'debug', `${replicas} read replica(s) configured (${owned} owned)`, {
1580
+ code: 'replicas-configured',
1581
+ replicas,
1582
+ owned,
1583
+ });
1025
1584
  }
1026
1585
  // Auto-create typed table accessors for all tables in the schema
1027
1586
  for (const tableName of Object.keys(schema.tables)) {
1028
- const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1587
+ const camelName = (0, global_filters_config_js_1.tableAccessorName)(tableName);
1029
1588
  if (!(camelName in this)) {
1030
1589
  Object.defineProperty(this, camelName, {
1031
1590
  get: () => this.table(tableName),
@@ -1087,8 +1646,10 @@ class TurbineClient {
1087
1646
  return matched;
1088
1647
  }
1089
1648
  /**
1090
- * Pin `plan_cache_mode` on every connection an OWNED pool opens, by putting
1091
- * it in the pool's **connection parameters** rather than issuing a `SET`.
1649
+ * The pg pool config for a pool Turbine opens: the `maxLifetimeSeconds`
1650
+ * recycling default, plus `plan_cache_mode` and `statement_timeout` pinned
1651
+ * on every connection through the pool's **connection parameters** rather
1652
+ * than a `SET`.
1092
1653
  *
1093
1654
  * PostgreSQL's `options` startup parameter (`-c plan_cache_mode=...`) is
1094
1655
  * applied by the backend as it starts the session, so the setting is in force
@@ -1122,23 +1683,157 @@ class TurbineClient {
1122
1683
  * should set the GUC on the server or role instead
1123
1684
  * (`ALTER ROLE ... SET plan_cache_mode = ...`).
1124
1685
  */
1125
- static withPlanCacheMode(poolConfig, mode) {
1126
- if (mode === undefined)
1127
- return poolConfig;
1128
- // `mode` is a member of PLAN_CACHE_MODES, never caller text (see
1129
- // resolvePlanCacheMode), which is what makes this literal safe: a GUC value
1130
- // cannot be a bind parameter.
1131
- const setting = `-c plan_cache_mode=${mode}`;
1132
- const merged = poolConfig.connectionString
1133
- ? (0, connection_url_js_1.mergeConnectionStringOptions)(poolConfig.connectionString, setting)
1134
- : null;
1135
- if (merged)
1136
- return { ...poolConfig, connectionString: merged };
1137
- // pg reads `config.options` when truthy and `process.env.PGOPTIONS`
1138
- // otherwise, so an unmerged setting would replace the caller's PGOPTIONS
1139
- // rather than add to it.
1140
- const existing = poolConfig.options || (typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined);
1141
- return { ...poolConfig, options: existing ? `${existing} ${setting}` : setting };
1686
+ static ownedPoolConfig(poolConfig, planCacheMode, statementTimeoutMs, maxLifetimeSeconds) {
1687
+ let config = {
1688
+ ...poolConfig,
1689
+ maxLifetimeSeconds: maxLifetimeSeconds ?? DEFAULT_MAX_LIFETIME_SECONDS,
1690
+ };
1691
+ // `planCacheMode` is a member of PLAN_CACHE_MODES and `statementTimeoutMs`
1692
+ // a narrowed integer, never caller text, which is what makes these
1693
+ // literals safe: a GUC value cannot be a bind parameter.
1694
+ if (planCacheMode !== undefined)
1695
+ config = (0, connection_url_js_1.withStartupSetting)(config, `-c plan_cache_mode=${planCacheMode}`);
1696
+ const timeout = statementTimeoutMs === undefined ? null : (0, connection_url_js_1.statementTimeoutSetting)(statementTimeoutMs);
1697
+ if (timeout !== null)
1698
+ config = (0, connection_url_js_1.withStartupSetting)(config, timeout);
1699
+ return config;
1700
+ }
1701
+ /**
1702
+ * Validate `statementTimeoutMs`: a non-negative integer (E003), on
1703
+ * PostgreSQL only (E017), since it is applied as a PostgreSQL connection
1704
+ * parameter.
1705
+ */
1706
+ static resolveStatementTimeout(value, dialect) {
1707
+ const ms = resolveNonNegativeInteger(value, 'statementTimeoutMs', 'milliseconds');
1708
+ if (ms !== undefined && !dialect.capabilities.connectionParameters) {
1709
+ throw new errors_js_1.UnsupportedFeatureError(`The statementTimeoutMs option (statement_timeout = ${ms})`, dialect.name, '`statement_timeout` is applied as a PostgreSQL connection parameter. Remove the option on this engine, ' +
1710
+ 'and bound slow statements with the per-query `timeout` instead.');
1711
+ }
1712
+ return ms;
1713
+ }
1714
+ /**
1715
+ * The first connection target this client opens that names a connection
1716
+ * pooler (see `detectPooler`), as `host:port`, or `undefined`. Covers the
1717
+ * connection string, the DATABASE_URL fallback, host/port settings, and
1718
+ * string replicas, each read the way node-postgres reads it: a host or port
1719
+ * the target leaves unset is `PGHOST` / `PGPORT`.
1720
+ */
1721
+ static poolerEndpoint(config) {
1722
+ const explicit = config.connectionString != null ||
1723
+ config.host != null ||
1724
+ config.port != null ||
1725
+ config.database != null ||
1726
+ config.user != null ||
1727
+ config.password != null;
1728
+ const targets = [];
1729
+ if (config.connectionString)
1730
+ targets.push((0, connection_url_js_1.parseConnectionTarget)(config.connectionString));
1731
+ else if (!explicit && process.env.DATABASE_URL)
1732
+ targets.push((0, connection_url_js_1.parseConnectionTarget)(process.env.DATABASE_URL));
1733
+ else {
1734
+ targets.push({
1735
+ host: config.host ? config.host.toLowerCase() : null,
1736
+ port: typeof config.port === 'number' ? config.port : null,
1737
+ });
1738
+ }
1739
+ for (const replica of config.replicas ?? []) {
1740
+ if (typeof replica === 'string' && replica)
1741
+ targets.push((0, connection_url_js_1.parseConnectionTarget)(replica));
1742
+ }
1743
+ const environment = { host: pgEnv('PGHOST'), port: pgEnv('PGPORT') };
1744
+ for (const target of targets) {
1745
+ const found = (0, connection_url_js_1.detectPoolerTarget)((0, connection_url_js_1.fillTargetFromEnvironment)(target, environment));
1746
+ if (found.pooled)
1747
+ return `${found.host ?? 'localhost'}:${found.port ?? 5432}`;
1748
+ }
1749
+ return undefined;
1750
+ }
1751
+ /** Always on: named statements behind a transaction pooler fail in production first. */
1752
+ static warnPreparedOnPooler(endpoint, logger) {
1753
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.preparedStatementsOnPooler, endpoint))
1754
+ return;
1755
+ (0, logger_js_1.logTo)(logger, 'warn', `preparedStatements: true on ${endpoint}, which looks like a connection pooler. In transaction ` +
1756
+ 'mode a pooler runs each transaction on any server connection, so a statement named on one fails on ' +
1757
+ 'the next ("prepared statement ... does not exist" or "already exists") unless the pooler tracks ' +
1758
+ 'prepared statements: PgBouncer 1.21+ with max_prepared_statements above 0. If yours does not, leave ' +
1759
+ 'preparedStatements unset; Turbine then sends unnamed statements to a pooler.', { code: 'prepared-statements-on-pooler', endpoint });
1760
+ }
1761
+ /**
1762
+ * `pool` as Turbine's own statements should reach it: with Turbine's int8
1763
+ * and temporal parsers on every query, for a PostgreSQL pool built on
1764
+ * node-postgres. Any other pool is returned unchanged.
1765
+ */
1766
+ typedPool(pool) {
1767
+ if (this.dialect.capabilities.connectionDriver !== 'pg-compatible')
1768
+ return pool;
1769
+ return (0, supplied_pool_types_js_1.typedQueryPool)(pool, this.queryOptions.utcTimestamps !== false) ?? pool;
1770
+ }
1771
+ /**
1772
+ * Listen to a pool this client opened: its idle-connection errors become
1773
+ * `poolError` events, a checked-out connection's loss a `connectionLost`
1774
+ * event, and its checkouts are tracked for `disconnect({ timeoutMs })`.
1775
+ * Also the connection guards every owned pool gets (connection-guard.ts):
1776
+ * no unheard `'error'`, a settle after a freeze, typed checkout timeouts.
1777
+ */
1778
+ watchOwnedPool(pool, origin) {
1779
+ pool.on('error', (err) => this.emitPoolEvent('poolError', { error: err, ...origin }));
1780
+ (0, connection_guard_js_1.absorbCheckedOutErrors)(pool);
1781
+ this.checkoutWatches.push((0, connection_guard_js_1.watchCheckouts)(pool, (err) => this.emitPoolEvent('connectionLost', { error: err, ...origin })));
1782
+ }
1783
+ /**
1784
+ * Hand a pool event to its listeners. A `poolError` with no listener is
1785
+ * logged at `error`, always; a listener that throws is ignored, like a query
1786
+ * listener.
1787
+ */
1788
+ emitPoolEvent(kind, event) {
1789
+ const listeners = this.poolListeners[kind];
1790
+ if (listeners.size === 0) {
1791
+ if (kind === 'poolError') {
1792
+ (0, logger_js_1.logTo)(this.logger, 'error', `Unexpected ${event.pool} pool error: ${event.error.message}`, {
1793
+ code: 'pool-error',
1794
+ pool: event.pool,
1795
+ ...(event.replica !== undefined ? { replica: event.replica } : {}),
1796
+ error: event.error,
1797
+ });
1798
+ }
1799
+ return;
1800
+ }
1801
+ for (const listener of listeners) {
1802
+ try {
1803
+ listener(event);
1804
+ }
1805
+ catch (e) {
1806
+ if (this.logging) {
1807
+ (0, logger_js_1.logTo)(this.logger, 'error', `${kind} listener error:`, {
1808
+ code: 'pool-listener-error',
1809
+ listener: kind,
1810
+ error: e,
1811
+ });
1812
+ }
1813
+ }
1814
+ }
1815
+ }
1816
+ /**
1817
+ * `statementTimeoutMs` and an explicit `maxLifetimeSeconds` reach only the
1818
+ * pools Turbine opens. On an external primary they do nothing, which a
1819
+ * warning says once, naming where the setting belongs instead. Always on: an
1820
+ * ignored timeout means no timeout in production.
1821
+ */
1822
+ warnExternalPoolOptions(statementTimeoutMs, maxLifetimeSeconds) {
1823
+ if (this.ownsPool)
1824
+ return;
1825
+ const ownedReplicas = this.ownedReplicaPools.length;
1826
+ if (statementTimeoutMs !== undefined && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.connectionOptionIgnored, 'statementTimeoutMs')) {
1827
+ (0, logger_js_1.logTo)(this.logger, 'warn', `statementTimeoutMs: ${statementTimeoutMs} was not applied to the primary: this client was given an ` +
1828
+ "external `pool`, whose connections Turbine never opens. Set `statement_timeout` in the driver's own " +
1829
+ "connection setup, or on the role (`ALTER ROLE app SET statement_timeout = '30s'`)." +
1830
+ (ownedReplicas > 0 ? ' It IS applied to the Turbine-owned read replica pools.' : ''), { code: 'connection-option-ignored', option: 'statementTimeoutMs', ownedReplicas });
1831
+ }
1832
+ if (maxLifetimeSeconds !== undefined && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.connectionOptionIgnored, 'maxLifetimeSeconds')) {
1833
+ (0, logger_js_1.logTo)(this.logger, 'warn', `maxLifetimeSeconds: ${maxLifetimeSeconds} was not applied to the primary: this client was given ` +
1834
+ "an external `pool`, whose connection lifecycle the caller owns. Set it on that pool's own options." +
1835
+ (ownedReplicas > 0 ? ' It IS applied to the Turbine-owned read replica pools.' : ''), { code: 'connection-option-ignored', option: 'maxLifetimeSeconds', ownedReplicas });
1836
+ }
1142
1837
  }
1143
1838
  /**
1144
1839
  * Refuse a `utcTimestamps` value that contradicts the one an earlier client
@@ -1243,14 +1938,18 @@ class TurbineClient {
1243
1938
  // OUTERMOST, so leaving it out silently defeats all three above.
1244
1939
  this.errorScopedTableCache.clear();
1245
1940
  }
1246
- // -------------------------------------------------------------------------
1247
- // Event emitter, subscribe to query lifecycle events
1248
- // -------------------------------------------------------------------------
1249
- $on(_event, listener) {
1250
- this.queryListeners.add(listener);
1941
+ $on(event, listener) {
1942
+ this.listenersFor(event, '$on').add(listener);
1943
+ }
1944
+ $off(event, listener) {
1945
+ this.listenersFor(event, '$off').delete(listener);
1251
1946
  }
1252
- $off(_event, listener) {
1253
- this.queryListeners.delete(listener);
1947
+ listenersFor(event, api) {
1948
+ if (event === 'query')
1949
+ return this.queryListeners;
1950
+ if (event === 'poolError' || event === 'connectionLost')
1951
+ return this.poolListeners[event];
1952
+ throw new errors_js_1.ValidationError(`${api}: unknown event ${JSON.stringify(event)}. Expected 'query', 'poolError' or 'connectionLost'.`);
1254
1953
  }
1255
1954
  // -------------------------------------------------------------------------
1256
1955
  // Observability, automatic metrics collection
@@ -1340,21 +2039,23 @@ class TurbineClient {
1340
2039
  */
1341
2040
  createErrorModeAccessor(target) {
1342
2041
  const mode = this.errorMessageMode;
2042
+ // Keyed by name AND the function found under it, so a method replaced
2043
+ // after first use (a spy, a mock, an APM patch) is the one that runs.
1343
2044
  const wrapped = new Map();
1344
2045
  return new Proxy(target, {
1345
2046
  get(t, prop, receiver) {
1346
2047
  const value = Reflect.get(t, prop, receiver);
1347
2048
  if (typeof value !== 'function' || typeof prop !== 'string')
1348
2049
  return value;
1349
- let fn = wrapped.get(prop);
1350
- if (!fn) {
1351
- fn = (...args) => {
1352
- const call = () => value.apply(t, args);
1353
- return (0, errors_js_1.errorMessageModesDiverged)() ? (0, errors_js_1.runWithErrorMessageMode)(mode, call) : call();
1354
- };
1355
- wrapped.set(prop, fn);
1356
- }
1357
- return fn;
2050
+ const hit = wrapped.get(prop);
2051
+ if (hit !== undefined && hit.fn === value)
2052
+ return hit.wrapper;
2053
+ const wrapper = (...args) => {
2054
+ const call = () => value.apply(t, args);
2055
+ return (0, errors_js_1.errorMessageModesDiverged)() ? (0, errors_js_1.runWithErrorMessageMode)(mode, call) : call();
2056
+ };
2057
+ wrapped.set(prop, { fn: value, wrapper });
2058
+ return wrapper;
1358
2059
  },
1359
2060
  });
1360
2061
  }
@@ -1377,7 +2078,7 @@ class TurbineClient {
1377
2078
  primaryTableQI(name) {
1378
2079
  let qi = this.tableCache.get(name);
1379
2080
  if (!qi) {
1380
- qi = this.buildTableQI(this.pool, name);
2081
+ qi = this.buildTableQI(this.queryPool, name);
1381
2082
  this.tableCache.set(name, qi);
1382
2083
  }
1383
2084
  return qi;
@@ -1403,7 +2104,7 @@ class TurbineClient {
1403
2104
  /** Construct a QueryInterface bound to `pool` (honoring any injected factory). */
1404
2105
  buildTableQI(pool, name) {
1405
2106
  return this.queryOptions?.queryInterfaceFactory
1406
- ? this.queryOptions.queryInterfaceFactory(pool, name, this.schema, this.middlewares, this.queryOptions)
2107
+ ? (0, table_client_js_1.asQueryInterface)(this.queryOptions.queryInterfaceFactory(pool, name, this.schema, this.middlewares, this.queryOptions))
1407
2108
  : new index_js_1.QueryInterface(pool, name, this.schema, this.middlewares, this.queryOptions);
1408
2109
  }
1409
2110
  /**
@@ -1414,20 +2115,30 @@ class TurbineClient {
1414
2115
  createRoutingAccessor(name) {
1415
2116
  const primaryQI = this.primaryTableQI(name);
1416
2117
  const client = this;
2118
+ // One routing function per operation, so the accessor above can tell a
2119
+ // replaced method from this one by identity.
2120
+ const routed = new Map();
1417
2121
  return new Proxy(primaryQI, {
1418
2122
  get(target, prop, receiver) {
1419
- if (typeof prop === 'string' && exports.READ_OPERATIONS.has(prop)) {
2123
+ // A read replaced on the accessor (a spy, a mock) is an own property of
2124
+ // the primary instance, and runs as installed rather than routed past.
2125
+ if (typeof prop !== 'string' || !exports.READ_OPERATIONS.has(prop) || Object.hasOwn(target, prop)) {
2126
+ return Reflect.get(target, prop, receiver);
2127
+ }
2128
+ let route = routed.get(prop);
2129
+ if (route === undefined) {
1420
2130
  // Pick the replica at CALL time so round-robin advances per operation.
1421
- return (...args) => {
2131
+ route = (...args) => {
1422
2132
  const replicaQI = client.nextReplicaTableQI(name);
1423
2133
  const method = replicaQI[prop];
1424
2134
  if (typeof method !== 'function') {
1425
- return Reflect.get(target, prop, receiver);
2135
+ return Reflect.get(target, prop, receiver).apply(target, args);
1426
2136
  }
1427
2137
  return method.apply(replicaQI, args);
1428
2138
  };
2139
+ routed.set(prop, route);
1429
2140
  }
1430
- return Reflect.get(target, prop, receiver);
2141
+ return route;
1431
2142
  },
1432
2143
  });
1433
2144
  }
@@ -1464,27 +2175,13 @@ class TurbineClient {
1464
2175
  }
1465
2176
  return this.primaryView;
1466
2177
  }
1467
- // -------------------------------------------------------------------------
1468
- // Pipeline, batch multiple queries into one round-trip
1469
- // -------------------------------------------------------------------------
1470
- /**
1471
- * Execute multiple queries in a single database round-trip.
1472
- *
1473
- * Two call styles:
1474
- * - `db.pipeline(q1, q2, q3)`, rest params (backward-compatible)
1475
- * - `db.pipeline([q1, q2, q3], { transactional: false })`, array + options
1476
- *
1477
- * On pg.Pool-backed connections with TCP, this uses the real Postgres
1478
- * extended-query pipeline protocol (one TCP flush, one round-trip).
1479
- * On HTTP-based drivers it falls back to sequential execution.
1480
- */
1481
2178
  async pipeline(...args) {
1482
2179
  let queries;
1483
2180
  let options;
1484
- // Detect which overload was used
1485
- if (args.length > 0 &&
1486
- Array.isArray(args[0]) &&
1487
- args[0].every((item) => item && typeof item === 'object' && 'sql' in item)) {
2181
+ // Detect which overload was used. A deferred query is never an array, so
2182
+ // an array first argument is the array form whatever it contains; its
2183
+ // items are checked below like the rest form's.
2184
+ if (args.length > 0 && Array.isArray(args[0])) {
1488
2185
  // Array form: pipeline([q1, q2], opts?)
1489
2186
  queries = args[0];
1490
2187
  options = args[1];
@@ -1493,8 +2190,18 @@ class TurbineClient {
1493
2190
  // Rest-param form: pipeline(q1, q2, q3)
1494
2191
  queries = args;
1495
2192
  }
2193
+ assertDeferredQueries(queries, 'pipeline', 'pipeline');
2194
+ // A locking read outside a transaction would release its locks before
2195
+ // the caller saw the rows; a transactional pipeline keeps them for the batch.
2196
+ if (options?.transactional === false)
2197
+ (0, row_lock_js_1.assertNoLockInAutocommitPipeline)(queries);
1496
2198
  if (this.logging) {
1497
- console.log(`[turbine] Pipeline: ${queries.length} queries, ${queries.map((q) => q.tag).join(', ')}`);
2199
+ const tags = queries.map((q) => q.tag);
2200
+ (0, logger_js_1.logTo)(this.logger, 'debug', `Pipeline: ${queries.length} queries, ${tags.join(', ')}`, {
2201
+ code: 'pipeline',
2202
+ queries: queries.length,
2203
+ tags,
2204
+ });
1498
2205
  }
1499
2206
  // Scoped like every other entry point. A pipeline builds NotFoundErrors (a
1500
2207
  // `buildFindUniqueOrThrow` slot) and wraps driver errors into
@@ -1503,10 +2210,27 @@ class TurbineClient {
1503
2210
  // where VALUES as soon as any verbose client existed in the process. The
1504
2211
  // scope is established around the await, so every continuation of the batch
1505
2212
  // resolves this client's mode.
2213
+ // Each statement carries the `sqlComment` its query interface would have
2214
+ // given it, computed here in the caller's async context.
2215
+ const comments = this.statementComments(queries);
2216
+ const sent = comments ? queries.map((q, i) => (0, sql_comment_js_1.commentDeferred)(q, comments[i])) : queries;
1506
2217
  const sink = this.queryListeners.size > 0 ? this.queryOptions._onQuery : undefined;
1507
2218
  if (!sink)
1508
- return this.withErrorMode(() => (0, pipeline_js_1.executePipeline)(this.pool, queries, options));
1509
- return this.withErrorMode(() => this.reportedPipeline(sink, queries, options));
2219
+ return this.withErrorMode(() => (0, pipeline_js_1.executePipeline)(this.queryPool, sent, options));
2220
+ return this.withErrorMode(() => this.reportedPipeline(sink, queries, options, sent, comments));
2221
+ }
2222
+ /**
2223
+ * The `sqlComment` of each deferred query, named by its own model and action
2224
+ * (a pipeline can mix tables), or `undefined` when this client adds none.
2225
+ */
2226
+ statementComments(queries) {
2227
+ const commenter = this.sqlCommenter;
2228
+ if (commenter === undefined)
2229
+ return undefined;
2230
+ return queries.map((q) => {
2231
+ const { model, action } = (0, query_events_js_1.deferredIdentity)(q.tag);
2232
+ return commenter.commentFor(model, action)?.comment;
2233
+ });
1510
2234
  }
1511
2235
  /**
1512
2236
  * {@link pipeline} with one `$on('query')` event per statement. Only taken
@@ -1517,9 +2241,9 @@ class TurbineClient {
1517
2241
  * time is split evenly across them and each event carries
1518
2242
  * `batch: 'pipeline'`.
1519
2243
  */
1520
- async reportedPipeline(sink, queries, options) {
2244
+ async reportedPipeline(sink, queries, options, sent, comments) {
1521
2245
  const rowCounts = new Array(queries.length).fill(undefined);
1522
- const observed = queries.map((q, i) => ({
2246
+ const observed = sent.map((q, i) => ({
1523
2247
  ...q,
1524
2248
  transform: (raw) => {
1525
2249
  rowCounts[i] = (0, query_events_js_1.resultRowCount)(raw);
@@ -1529,7 +2253,7 @@ class TurbineClient {
1529
2253
  const start = performance.now();
1530
2254
  let failure;
1531
2255
  try {
1532
- return await (0, pipeline_js_1.executePipeline)(this.pool, observed, options);
2256
+ return await (0, pipeline_js_1.executePipeline)(this.queryPool, observed, options);
1533
2257
  }
1534
2258
  catch (err) {
1535
2259
  failure = err;
@@ -1555,6 +2279,7 @@ class TurbineClient {
1555
2279
  ...(0, query_events_js_1.deferredIdentity)(q.tag),
1556
2280
  rows: rowCounts[i] ?? 0,
1557
2281
  batch: 'pipeline',
2282
+ ...(comments?.[i] === undefined ? {} : { comment: comments[i] }),
1558
2283
  ...(failed ? { error: failure instanceof Error ? failure : new Error(String(failure)) } : {}),
1559
2284
  });
1560
2285
  }
@@ -1567,13 +2292,17 @@ class TurbineClient {
1567
2292
  * drivers (Neon HTTP, Vercel Postgres, etc.) and mock pools.
1568
2293
  */
1569
2294
  async pipelineSupported() {
1570
- return (0, pipeline_js_1.pipelineSupported)(this.pool);
2295
+ return (0, pipeline_js_1.pipelineSupported)(this.queryPool);
1571
2296
  }
1572
2297
  // -------------------------------------------------------------------------
1573
2298
  // Raw SQL, tagged template literal escape hatch
1574
2299
  // -------------------------------------------------------------------------
1575
2300
  /**
1576
2301
  * Execute a raw SQL query with parameter interpolation via tagged templates.
2302
+ * Every `${value}` is sent as a bound parameter. Only a real tagged-template
2303
+ * call is accepted: an array passed in its place throws `ValidationError`
2304
+ * (E003), since its text would reach the SQL unescaped. For statement text
2305
+ * built at runtime, see {@link rawUnsafe}.
1577
2306
  *
1578
2307
  * @example
1579
2308
  * ```ts
@@ -1585,17 +2314,40 @@ class TurbineClient {
1585
2314
  * ```
1586
2315
  */
1587
2316
  async raw(strings, ...values) {
1588
- let sql = '';
1589
- strings.forEach((str, i) => {
1590
- sql += str;
1591
- if (i < values.length) {
1592
- sql += this.dialect.paramPlaceholder(i + 1);
1593
- }
1594
- });
2317
+ const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect, 'raw');
2318
+ return this.runRaw(sql, params, 'raw');
2319
+ }
2320
+ /**
2321
+ * Execute SQL text built at runtime: DDL read from a file, a statement in
2322
+ * another engine's own language (PowQL), a migration step. Values go in
2323
+ * `values` and are referenced from the text with the engine's placeholders
2324
+ * (`$1`, `$2`, ... on PostgreSQL), so they are still bound.
2325
+ *
2326
+ * NOTHING IN `text` IS ESCAPED. It is the one raw entry point where
2327
+ * concatenating input into the statement is possible, which is why it is
2328
+ * separate from {@link raw} and named for it: never build `text` from
2329
+ * request data. Prefer {@link raw} or {@link sql} wherever the statement is
2330
+ * known when the code is written.
2331
+ *
2332
+ * @example
2333
+ * ```ts
2334
+ * for (const statement of ddlStatements) await db.rawUnsafe(statement);
2335
+ * await db.rawUnsafe('SELECT * FROM users WHERE id = $1', userId);
2336
+ * ```
2337
+ */
2338
+ async rawUnsafe(text, ...values) {
2339
+ assertSqlText(text, 'rawUnsafe');
2340
+ return this.runRaw(text, values, 'rawUnsafe');
2341
+ }
2342
+ async runRaw(sql, values, action) {
2343
+ const params = this.rawParams(values, action);
1595
2344
  if (this.logging) {
1596
- console.log(`[turbine] Raw SQL: ${sql.trim().substring(0, 120)}...`);
2345
+ (0, logger_js_1.logTo)(this.logger, 'debug', `Raw SQL: ${sql.trim().substring(0, 120)}...`, {
2346
+ code: 'raw-sql',
2347
+ operation: action,
2348
+ });
1597
2349
  }
1598
- const result = await (0, query_events_js_1.runReportedStatement)(this.queryOptions._onQuery, (0, query_events_js_1.rawIdentity)('raw'), sql, values, () => this.pool.query(sql, values), (err) => this.withErrorMode(() => (0, errors_js_1.wrapPgError)(err)));
2350
+ const result = await (0, query_events_js_1.runReportedStatement)(this.queryOptions._onQuery, (0, query_events_js_1.rawIdentity)(action), sql, params, () => this.queryPool.query(sql, params), (err) => this.withErrorMode(() => (0, errors_js_1.wrapPgError)(err)));
1599
2351
  return result.rows;
1600
2352
  }
1601
2353
  /**
@@ -1608,9 +2360,20 @@ class TurbineClient {
1608
2360
  * the pool. Errors are translated by `wrapPgError`, like {@link raw}.
1609
2361
  */
1610
2362
  async rawQuery(text, params = [], action = 'rawQuery') {
1611
- const result = await (0, query_events_js_1.runReportedStatement)(this.queryOptions._onQuery, (0, query_events_js_1.rawIdentity)(action), text, params, () => this.pool.query(text, params), (err) => this.withErrorMode(() => (0, errors_js_1.wrapPgError)(err)));
2363
+ const bound = this.rawParams(params, action);
2364
+ const result = await (0, query_events_js_1.runReportedStatement)(this.queryOptions._onQuery, (0, query_events_js_1.rawIdentity)(action), text, bound, () => this.queryPool.query(text, bound), (err) => this.withErrorMode(() => (0, errors_js_1.wrapPgError)(err)));
1612
2365
  return { rows: result.rows, rowCount: result.rowCount };
1613
2366
  }
2367
+ /**
2368
+ * Bound Dates as UTC text, on a PostgreSQL client reading zone-less temporal
2369
+ * types as UTC (see {@link utcDateParams}). Typed builder writes convert per
2370
+ * column instead and never come through here.
2371
+ */
2372
+ rawParams(params, api) {
2373
+ return this.dialect.capabilities.utcTimestampRewrite && this.queryOptions.utcTimestamps !== false
2374
+ ? (0, typed_sql_js_1.utcDateParams)(params, api)
2375
+ : params;
2376
+ }
1614
2377
  /**
1615
2378
  * Run `fn` with `tag` attached to every `$on('query')` event it causes, so a
1616
2379
  * listener can attribute queries to the feature or code path that issued
@@ -1664,14 +2427,15 @@ class TurbineClient {
1664
2427
  * ```
1665
2428
  */
1666
2429
  sql(strings, ...values) {
1667
- const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect);
2430
+ const { sql, params: bound } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect);
2431
+ const params = this.rawParams(bound, 'sql');
1668
2432
  // The scope has to be handed to the BUILDER, not wrapped around this call.
1669
2433
  // `TypedSqlQuery` is lazy (it runs on `await` / `.one()` / `.scalar()`), so
1670
2434
  // a scope opened here would close before any row was fetched. The
1671
2435
  // `withErrorMode` docstring already claimed to cover the typed-SQL builder
1672
2436
  // and did not, which left two raw-SQL entry points disagreeing about the
1673
2437
  // same statement, since the adjacent `raw` tag WAS scoped.
1674
- return new typed_sql_js_1.TypedSqlQuery(this.pool, sql, params, this.logging, (fn) => this.withErrorMode(fn), this.queryOptions._onQuery);
2438
+ return new typed_sql_js_1.TypedSqlQuery(this.queryPool, sql, params, this.logging, (fn) => this.withErrorMode(fn), this.queryOptions._onQuery, this.logger);
1675
2439
  }
1676
2440
  // -------------------------------------------------------------------------
1677
2441
  // Transaction support (raw, legacy)
@@ -1681,10 +2445,16 @@ class TurbineClient {
1681
2445
  * connection. For the typed API, use `$transaction()` instead.
1682
2446
  *
1683
2447
  * The client is typed as {@link PgCompatPoolClient}, the driver-neutral
1684
- * contract: `query(text, values)` plus `release()`, which is all any
1685
- * supported driver guarantees. For the pg-only surface (cursors, COPY
1686
- * streams, the `QueryConfig` object form), import `PoolClient` from the pg
1687
- * package and cast: `client as unknown as PoolClient`.
2448
+ * contract: `query(text, values)`, which is all any supported driver
2449
+ * guarantees. For the pg-only surface (cursors, COPY streams, the
2450
+ * `QueryConfig` object form), import `PoolClient` from the pg package and
2451
+ * cast: `client as unknown as PoolClient`.
2452
+ *
2453
+ * Like `$transaction`'s `tx`, the client is closed when the transaction
2454
+ * settles: a query sent through it afterwards throws `ValidationError`
2455
+ * (E003). The callback must not `release()` it, and must await every query
2456
+ * it starts: one still running when the callback returns rolls the
2457
+ * transaction back with E003.
1688
2458
  *
1689
2459
  * @example
1690
2460
  * ```ts
@@ -1702,28 +2472,32 @@ class TurbineClient {
1702
2472
  // driver with one shared engine handle (PowDB embedded) would roll back a
1703
2473
  // DIFFERENT caller's open transaction.
1704
2474
  const { client, checkout } = await (0, checkout_js_1.openCheckout)(this.pool, (c) => runTxControl(c, this.dialect.beginStatement()));
2475
+ const state = new transaction_state_js_1.TransactionState(client, this.dialect, { api: 'transaction' });
2476
+ const guarded = closableRawClient(client, state);
1705
2477
  try {
1706
2478
  // Engine seam: single-writer engines scope their transaction re-entrancy
1707
2479
  // marker to the callback's async subtree (see
1708
2480
  // PgCompatPoolClient.wrapTransactionCallback). Absent everywhere else.
1709
2481
  const wrap = client.wrapTransactionCallback;
1710
2482
  // `.call` erases the generic, so the callback's Promise<T> is re-asserted.
1711
- const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
1712
- await runTxControl(client, this.dialect.commitStatement());
2483
+ const result = wrap ? (await wrap.call(client, () => fn(guarded))) : await fn(guarded);
2484
+ const unawaited = state.busy;
2485
+ state.end('has ended');
2486
+ if (unawaited)
2487
+ throw (0, transaction_state_js_1.unawaitedWorkError)('transaction');
2488
+ // A COMMIT answered with ROLLBACK (a statement failed and the callback
2489
+ // caught it) rejects, with the first failed statement as the cause.
2490
+ await (0, transaction_state_js_1.commitTransaction)(client, this.dialect.commitStatement(), state.firstFailure, 'transaction');
2491
+ state.end('committed');
2492
+ checkout.release();
1713
2493
  return result;
1714
2494
  }
1715
2495
  catch (err) {
1716
- try {
1717
- await client.query(this.dialect.rollbackStatement());
1718
- }
1719
- catch {
1720
- // Best-effort rollback, the connection may have died mid-query.
1721
- }
2496
+ state.end('was rolled back');
2497
+ const clean = await rollbackOnServer(client, state, this.dialect);
2498
+ checkout.release(clean ? undefined : new Error('[turbine] transaction connection discarded: rollback not confirmed'));
1722
2499
  throw (0, errors_js_1.explainConnectionLoss)(err, checkout.lostWith);
1723
2500
  }
1724
- finally {
1725
- checkout.release();
1726
- }
1727
2501
  }
1728
2502
  async $transaction(fnOrQueries, options) {
1729
2503
  // Scope the WHOLE call, so the transaction-scoped QueryInterfaces built
@@ -1733,145 +2507,183 @@ class TurbineClient {
1733
2507
  }
1734
2508
  async runTransaction(fnOrQueries, options) {
1735
2509
  // Batch overload: an array of DeferredQuery objects runs atomically inside
1736
- // one BEGIN…COMMIT, reusing the raw transaction machinery below.
2510
+ // one BEGIN…COMMIT, through the same lifecycle (and options) as a callback.
1737
2511
  if (Array.isArray(fnOrQueries)) {
1738
- return this.transactionBatch(fnOrQueries);
2512
+ assertDeferredQueries(fnOrQueries, '$transaction([...])', 'transaction');
2513
+ if (fnOrQueries.length === 0) {
2514
+ this.resolveTransactionOptions(options);
2515
+ return [];
2516
+ }
2517
+ return this.runInTransaction(options, (client, state) => this.runBatch(client, state, fnOrQueries));
2518
+ }
2519
+ if (typeof fnOrQueries !== 'function') {
2520
+ throw new errors_js_1.ValidationError('$transaction expects a callback, `async (tx) => { ... }`, or an array of deferred queries built with ' +
2521
+ `\`build*()\`, but received ${fnOrQueries === null ? 'null' : typeof fnOrQueries}.`);
1739
2522
  }
1740
2523
  const fn = fnOrQueries;
1741
- // Resolve the isolation level BEFORE taking a pool slot: a bad argument is
1742
- // the caller's bug and should not cost a connection to discover.
2524
+ // Pass the parent pool so its read-only guard + PowDB capabilities flow
2525
+ // into the transaction-scoped proxy pool (see TransactionClient.createTxPool).
2526
+ const sourcePool = this.pool;
2527
+ return this.runInTransaction(options, (client, state) => fn(new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, sourcePool, state)));
2528
+ }
2529
+ /**
2530
+ * Validate every transaction option and build the statements they need,
2531
+ * BEFORE a pool slot is taken: a bad argument is the caller's bug and should
2532
+ * not cost a connection to discover.
2533
+ */
2534
+ resolveTransactionOptions(options) {
1743
2535
  const isolationSql = resolveIsolationLevel(options?.isolationLevel);
1744
- // BEGIN with optional isolation level, the dialect owns the keyword and
1745
- // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`). It
1746
- // runs inside openCheckout, which sends it once more on a fresh connection
1747
- // when the first one turns out to be dead, and which releases the
1748
- // connection itself when BEGIN fails for good. So everything below runs in
1749
- // a transaction that began.
1750
- const { client, checkout } = await (0, checkout_js_1.openCheckout)(this.pool, (c) => runTxControl(c, this.dialect.beginStatement(isolationSql)));
2536
+ const begin = beginStatementFor(this.dialect, isolationSql, resolveReadOnly(options?.readOnly));
2537
+ const sessionConfig = this.resolveSessionContext(options?.sessionContext, options?.allowBuiltinSettings);
1751
2538
  const timeout = options?.timeout;
1752
- /**
1753
- * Track whether the connection has already been released so the finally
1754
- * block doesn't double-release. When a timeout fires we destroy the
1755
- * connection eagerly to abort the in-flight backend query.
1756
- */
1757
- let released = false;
1758
- const releaseOnce = (err) => {
1759
- if (released)
1760
- return;
1761
- released = true;
1762
- try {
1763
- checkout.release(err);
1764
- }
1765
- catch {
1766
- // pg may throw if the client is already released, swallow.
1767
- }
1768
- };
2539
+ if (timeout !== undefined && timeout !== 0 && !(typeof timeout === 'number' && timeout > 0 && timeout < 2 ** 31)) {
2540
+ throw new errors_js_1.ValidationError(`$transaction: timeout must be a positive number of milliseconds (or 0 for none), received ${describeValue(timeout)}.`);
2541
+ }
2542
+ return { begin, sessionConfig, timeout: timeout || undefined };
2543
+ }
2544
+ /**
2545
+ * The one transaction lifecycle both `$transaction` forms share, so an option
2546
+ * one form honours is honoured by the other: BEGIN (isolation level, read
2547
+ * only) -> `sessionContext` GUCs -> body, raced against `timeout` -> COMMIT,
2548
+ * and ROLLBACK on any failure.
2549
+ *
2550
+ * The transaction state is ended as soon as the body settles, BEFORE the
2551
+ * COMMIT, so a `tx` that leaked out of the callback can never slip a
2552
+ * statement in ahead of the COMMIT or onto the connection after it. Work the
2553
+ * body started and did not await is caught there too: the transaction rolls
2554
+ * back rather than committing whatever part of it had run.
2555
+ *
2556
+ * A transaction that will not commit (timed out, failed, unawaited work) is
2557
+ * stopped on the server before its connection is released: the running
2558
+ * statement is cancelled and ROLLBACK sent, so its locks are freed now (see
2559
+ * {@link rollbackOnServer}). A connection that cannot be confirmed clean is
2560
+ * destroyed instead of reused.
2561
+ */
2562
+ async runInTransaction(options, body) {
2563
+ const { begin, sessionConfig, timeout } = this.resolveTransactionOptions(options);
2564
+ // openCheckout sends BEGIN once more on a fresh connection when the first
2565
+ // one turns out to be dead, and releases the connection itself when BEGIN
2566
+ // fails for good. So everything below runs in a transaction that began.
2567
+ const { client, checkout } = await (0, checkout_js_1.openCheckout)(this.queryPool, (c) => runTxControl(c, begin));
2568
+ const state = new transaction_state_js_1.TransactionState(client, this.dialect, { userTransaction: true });
1769
2569
  let timedOut = false;
1770
2570
  try {
1771
- // Apply transaction-local session context (RLS / multi-tenant GUCs).
1772
- // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
1773
- // user fn. Any error here propagates to the catch below and rolls back
1774
- // like any other transaction failure. We use set_config(name, value,
1775
- // is_local=true), the parameterizable, transaction-scoped equivalent of
1776
- // SET LOCAL, so both name and value are BOUND params, never interpolated.
1777
- if (options?.sessionContext) {
1778
- if (!this.dialect.supportsRLS) {
1779
- throw new errors_js_1.UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
1780
- }
1781
- for (const [name, value] of Object.entries(options.sessionContext)) {
1782
- if (!GUC_NAME_REGEX.test(name)) {
1783
- throw new errors_js_1.ValidationError(`Invalid session-context GUC name "${name}", must match ` +
1784
- '/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
1785
- }
1786
- const cfg = this.dialect.buildSetSessionConfig(name, String(value));
1787
- await client.query(cfg.sql, cfg.params);
1788
- }
1789
- }
1790
- // Create the transaction client with typed table accessors. Pass the
1791
- // parent pool so its read-only guard + PowDB capabilities flow into the
1792
- // transaction-scoped proxy pool (see TransactionClient.createTxPool).
1793
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions, this.pool);
1794
- // Dynamically attach table accessors to tx
1795
- for (const tableName of Object.keys(this.schema.tables)) {
1796
- const camelName = tableName.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1797
- if (!(camelName in tx)) {
1798
- Object.defineProperty(tx, camelName, {
1799
- get: () => tx.table(tableName),
1800
- enumerable: true,
1801
- });
1802
- }
1803
- }
1804
- let result;
2571
+ // Transaction-local session context (RLS / multi-tenant GUCs), applied
2572
+ // after BEGIN and before the body via set_config(name, value,
2573
+ // is_local=true), so both name and value are BOUND params.
2574
+ for (const cfg of sessionConfig)
2575
+ await applySessionSetting(client, cfg);
1805
2576
  // Engine seam: when the checked-out connection exposes
1806
- // wrapTransactionCallback (single-writer engines such as PowDB), run the user
1807
- // callback through it so the engine can scope its re-entrancy marker to
1808
- // the callback's async subtree. All other drivers: plain fn(tx).
2577
+ // wrapTransactionCallback (single-writer engines such as PowDB), run the
2578
+ // body through it so the engine can scope its re-entrancy marker to the
2579
+ // body's async subtree. All other drivers: plain body().
1809
2580
  const wrap = client.wrapTransactionCallback;
1810
- const runCallback = () => (wrap ? wrap.call(client, () => fn(tx)) : fn(tx));
2581
+ const runBody = () => state.enter(() => (wrap ? wrap.call(client, () => body(client, state)) : body(client, state)));
2582
+ let result;
1811
2583
  if (timeout) {
1812
- // Race between the function and a timeout. If the timeout fires we
1813
- // need to actually abort the in-flight query, otherwise the backend
1814
- // keeps running until pg's own timeout, holding a pool slot the whole
1815
- // time. The simplest reliable cancellation is to destroy the
1816
- // connection: passing a truthy argument to client.release() tells the
1817
- // pg pool to discard the client (its socket is closed, which causes
1818
- // Postgres to abort the active query and roll back the transaction).
1819
- // The pool will spin up a fresh connection on the next checkout.
1820
2584
  let timer;
1821
2585
  const timeoutPromise = new Promise((_, reject) => {
1822
2586
  timer = setTimeout(() => {
1823
2587
  timedOut = true;
1824
- // Destroy the connection to abort the in-flight backend query.
1825
- // We do this BEFORE rejecting so the socket is gone by the time
1826
- // the caller's catch block runs.
1827
- releaseOnce(new Error('[turbine] Transaction timeout, connection destroyed'));
1828
- reject(new errors_js_1.TimeoutError(timeout, 'Transaction'));
2588
+ state.end('timed out');
2589
+ reject(new errors_js_1.TimeoutError(timeout, 'Transaction', {
2590
+ message: `Transaction timed out after ${timeout}ms and was rolled back, so none of its writes were ` +
2591
+ 'saved. A statement it was running was cancelled on the server.',
2592
+ outcome: 'not-executed',
2593
+ }));
1829
2594
  }, timeout);
1830
2595
  });
1831
2596
  try {
1832
- result = await Promise.race([runCallback(), timeoutPromise]);
2597
+ result = await Promise.race([runBody(), timeoutPromise]);
1833
2598
  }
1834
2599
  finally {
1835
2600
  clearTimeout(timer);
1836
2601
  }
1837
2602
  }
1838
2603
  else {
1839
- result = await runCallback();
2604
+ result = await runBody();
1840
2605
  }
1841
- await runTxControl(client, this.dialect.commitStatement());
2606
+ const unawaited = state.busy;
2607
+ state.end('has ended');
2608
+ if (unawaited)
2609
+ throw (0, transaction_state_js_1.unawaitedWorkError)('$transaction');
2610
+ // A statement inside was cut short by its timeout, and the callback
2611
+ // caught the error: whether it ran is not known, so nothing commits.
2612
+ if (state.abortedWith !== undefined)
2613
+ throw (0, transaction_state_js_1.rolledBackError)(state.abortedWith, 'transaction');
2614
+ await (0, transaction_state_js_1.commitTransaction)(client, this.dialect.commitStatement(), state.firstFailure);
2615
+ state.end('committed');
2616
+ checkout.release();
1842
2617
  if (this.logging) {
1843
- console.log('[turbine] Transaction committed');
2618
+ (0, logger_js_1.logTo)(this.logger, 'debug', 'Transaction committed', {
2619
+ code: 'transaction-committed',
2620
+ operation: '$transaction',
2621
+ });
1844
2622
  }
1845
2623
  return result;
1846
2624
  }
1847
2625
  catch (err) {
1848
- // If the timeout fired we already destroyed the connection, issuing a
1849
- // ROLLBACK on a released client would throw "Client has already been
1850
- // released". Skip the rollback in that case (the backend rolled back
1851
- // when its socket was closed). A BEGIN that failed never gets here, see
1852
- // openCheckout above, so there is always a transaction to roll back.
1853
- if (!timedOut && !released) {
1854
- try {
1855
- await client.query(this.dialect.rollbackStatement());
1856
- }
1857
- catch {
1858
- // Best-effort rollback, the connection may have died mid-query.
1859
- }
1860
- }
2626
+ // A timed-out transaction keeps its 'timed out' wording for any late
2627
+ // statement the callback still tries to send.
2628
+ if (!timedOut)
2629
+ state.end('was rolled back');
2630
+ const clean = await rollbackOnServer(client, state, this.dialect);
2631
+ checkout.release(clean ? undefined : new Error('[turbine] transaction connection discarded: rollback not confirmed'));
1861
2632
  if (this.logging) {
1862
- console.log('[turbine] Transaction rolled back');
2633
+ (0, logger_js_1.logTo)(this.logger, 'debug', 'Transaction rolled back', {
2634
+ code: 'transaction-rolled-back',
2635
+ operation: '$transaction',
2636
+ });
1863
2637
  }
1864
2638
  throw (0, errors_js_1.explainConnectionLoss)(err, checkout.lostWith);
1865
2639
  }
1866
- finally {
1867
- releaseOnce();
2640
+ }
2641
+ /**
2642
+ * Validate `sessionContext` and build its `set_config` statements, before
2643
+ * any connection is taken.
2644
+ *
2645
+ * A name must be a namespaced custom setting (`app.tenant_id`) unless it is
2646
+ * listed in `allowBuiltinSettings`: an un-dotted name is one of PostgreSQL's
2647
+ * own settings (`role`, `search_path`, `statement_timeout`), and a context
2648
+ * built from request data must not be able to reach one. A value that is not
2649
+ * a string, number or boolean is refused, so a missing tenant id cannot reach
2650
+ * a row-level security policy as the text `'undefined'`.
2651
+ */
2652
+ resolveSessionContext(context, allowBuiltinSettings) {
2653
+ const allowed = resolveBuiltinSettingsAllowlist(allowBuiltinSettings);
2654
+ if (!context)
2655
+ return [];
2656
+ if (!this.dialect.supportsRLS) {
2657
+ throw new errors_js_1.UnsupportedFeatureError('sessionContext (RLS session GUCs)', this.dialect.name, 'set_config-based row-level-security context requires PostgreSQL.');
2658
+ }
2659
+ const statements = [];
2660
+ for (const [name, value] of Object.entries(context)) {
2661
+ if (!GUC_NAME_REGEX.test(name)) {
2662
+ throw new errors_js_1.ValidationError(`Invalid session-context GUC name "${name}", must match ` +
2663
+ '/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
2664
+ }
2665
+ if (!name.includes('.') && !allowed.has(name.toLowerCase())) {
2666
+ throw new errors_js_1.ValidationError(`sessionContext["${name}"] names a PostgreSQL setting, not a custom one. A session context may set ` +
2667
+ 'namespaced custom settings only (`app.tenant_id`, with a dot), because a built-in setting such as ' +
2668
+ '`role`, `search_path` or `statement_timeout` changes who the transaction runs as or what limits it ' +
2669
+ `has. Rename it into a namespace, or, if your code sets it on purpose, pass \`allowBuiltinSettings: ['${name}']\`.`);
2670
+ }
2671
+ const kind = typeof value;
2672
+ if (kind !== 'string' && kind !== 'number' && kind !== 'boolean') {
2673
+ throw new errors_js_1.ValidationError(`sessionContext["${name}"] is ${value === null ? 'null' : kind}. A session setting is text, and ` +
2674
+ `${value === undefined ? 'a missing value' : 'this value'} would reach your row-level security ` +
2675
+ `policies as the string "${String(value)}". Pass a string, number or boolean, and check the value ` +
2676
+ 'your request context supplies.');
2677
+ }
2678
+ statements.push({ name, ...this.dialect.buildSetSessionConfig(name, String(value)) });
1868
2679
  }
2680
+ return statements;
1869
2681
  }
1870
2682
  /**
1871
- * Execute a batch of {@link DeferredQuery} objects atomically inside one
1872
- * transaction. Backs the `$transaction([...])` array overload. Reuses the raw
1873
- * {@link transaction} machinery (BEGIN/COMMIT/ROLLBACK + connection release);
1874
- * each result is passed through its query's `transform`.
2683
+ * Run a batch of {@link DeferredQuery} objects on a transaction's connection.
2684
+ * Backs the `$transaction([...])` array overload; the transaction itself
2685
+ * (BEGIN/COMMIT/ROLLBACK, options, release) is {@link runInTransaction}'s.
2686
+ * Each result is passed through its query's `transform`.
1875
2687
  *
1876
2688
  * Execution strategy on the single transaction connection:
1877
2689
  * - **Sequential (default).** Await each statement's reply before sending
@@ -1892,44 +2704,48 @@ class TurbineClient {
1892
2704
  * rethrowing, which keeps the connection's request/reply pairing intact and
1893
2705
  * means ROLLBACK is only issued once no statement is still in flight.
1894
2706
  */
1895
- async transactionBatch(queries) {
1896
- if (queries.length === 0) {
1897
- return [];
1898
- }
2707
+ async runBatch(client, state, queries) {
1899
2708
  // One `$on('query')` event per statement, `batch: 'transaction'`, only
1900
- // when someone is listening; each statement is timed on its own.
2709
+ // when someone is listening; each statement is timed on its own. Every
2710
+ // statement goes through the transaction state, so a timeout can tell
2711
+ // what is still on the wire and stop it.
1901
2712
  const sink = this.queryListeners.size > 0 ? this.queryOptions._onQuery : undefined;
1902
- const reported = (dq, run) => (0, query_events_js_1.runReportedStatement)(sink, { ...(0, query_events_js_1.deferredIdentity)(dq.tag), batch: 'transaction' }, dq.sql, dq.params, run, errors_js_1.wrapPgError);
1903
- return this.transaction(async (client) => {
1904
- const pipelined = client.supportsPipelining === true &&
1905
- this.dialect.resultStrategy !== 'reselect';
1906
- if (pipelined) {
1907
- // Dispatch every statement before awaiting any reply. The driver's
1908
- // FIFO guarantee makes settled[i] the reply to queries[i]. Each
1909
- // statement is timed from its dispatch to its own reply.
1910
- const settled = await Promise.allSettled(queries.map((dq) => reported(dq, () => client.query(dq.sql, dq.params))));
1911
- const results = [];
1912
- for (let i = 0; i < settled.length; i++) {
1913
- const outcome = settled[i];
1914
- if (outcome.status === 'rejected') {
1915
- throw (0, errors_js_1.wrapPgError)(outcome.reason);
1916
- }
1917
- results.push(queries[i].transform(outcome.value));
1918
- }
1919
- return results;
1920
- }
2713
+ // Each statement is sent with its `sqlComment`; its event reports the
2714
+ // commentless text and the comment beside it, as a model call's does.
2715
+ const comments = this.statementComments(queries);
2716
+ const reported = (i, run) => {
2717
+ const dq = queries[i];
2718
+ const comment = comments?.[i];
2719
+ return state.run(() => (0, query_events_js_1.runReportedStatement)(sink, { ...(0, query_events_js_1.deferredIdentity)(dq.tag), batch: 'transaction', ...(comment === undefined ? {} : { comment }) }, dq.sql, dq.params, () => run((0, sql_comment_js_1.commentDeferred)(dq, comment)), errors_js_1.wrapPgError));
2720
+ };
2721
+ const pipelined = client.supportsPipelining === true &&
2722
+ this.dialect.resultStrategy !== 'reselect';
2723
+ if (pipelined) {
2724
+ // Dispatch every statement before awaiting any reply. The driver's
2725
+ // FIFO guarantee makes settled[i] the reply to queries[i]. Each
2726
+ // statement is timed from its dispatch to its own reply.
2727
+ const settled = await Promise.allSettled(queries.map((_, i) => reported(i, (dq) => client.query(dq.sql, dq.params))));
1921
2728
  const results = [];
1922
- for (const dq of queries) {
1923
- // Non-RETURNING engines (resultStrategy 'reselect', e.g. MySQL) attach a
1924
- // reselect plan that runs the write plus a follow-up SELECT; running
1925
- // dq.sql alone would transform a row-less write result.
1926
- const raw = await reported(dq, () => this.dialect.resultStrategy === 'reselect' && dq.reselect
1927
- ? dq.reselect((sql, params) => client.query(sql, params))
1928
- : client.query(dq.sql, dq.params));
1929
- results.push(dq.transform(raw));
2729
+ for (let i = 0; i < settled.length; i++) {
2730
+ const outcome = settled[i];
2731
+ if (outcome.status === 'rejected') {
2732
+ throw (0, errors_js_1.wrapPgError)(outcome.reason);
2733
+ }
2734
+ results.push(queries[i].transform(outcome.value));
1930
2735
  }
1931
2736
  return results;
1932
- });
2737
+ }
2738
+ const results = [];
2739
+ for (let i = 0; i < queries.length; i++) {
2740
+ // Non-RETURNING engines (resultStrategy 'reselect', e.g. MySQL) attach a
2741
+ // reselect plan that runs the write plus a follow-up SELECT; running
2742
+ // dq.sql alone would transform a row-less write result.
2743
+ const raw = await reported(i, (dq) => this.dialect.resultStrategy === 'reselect' && dq.reselect
2744
+ ? dq.reselect((sql, params) => client.query(sql, params))
2745
+ : client.query(dq.sql, dq.params));
2746
+ results.push(queries[i].transform(raw));
2747
+ }
2748
+ return results;
1933
2749
  }
1934
2750
  /**
1935
2751
  * Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
@@ -1978,7 +2794,8 @@ class TurbineClient {
1978
2794
  * ends it instead. Postgres does not hold notifications for a disconnected
1979
2795
  * listener, so anything sent during the gap is lost: use
1980
2796
  * `options.onReconnect` to resynchronise. `options.onError` receives the loss
1981
- * and each failed attempt (default: one `console.error` line each).
2797
+ * and each failed attempt (default: one log line each through `logger`, at
2798
+ * `warn` while it reconnects and `error` once it stops).
1982
2799
  *
1983
2800
  * @example
1984
2801
  * ```ts
@@ -1999,11 +2816,11 @@ class TurbineClient {
1999
2816
  (0, realtime_js_1.validateChannel)(channel);
2000
2817
  const quoted = (0, utils_js_1.quoteIdent)(channel);
2001
2818
  if (this.logging) {
2002
- console.log(`[turbine] LISTEN ${quoted}`);
2819
+ (0, logger_js_1.logTo)(this.logger, 'debug', `LISTEN ${quoted}`, { code: 'listen', channel });
2003
2820
  }
2004
2821
  const sub = await (0, realtime_js_1.createSubscription)(this.pool, channel, quoted, handler, (closed) => {
2005
2822
  this.activeSubscriptions.delete(closed);
2006
- }, options);
2823
+ }, options, this.logger);
2007
2824
  this.activeSubscriptions.add(sub);
2008
2825
  return sub;
2009
2826
  }
@@ -2026,7 +2843,7 @@ class TurbineClient {
2026
2843
  }
2027
2844
  (0, realtime_js_1.validateChannel)(channel);
2028
2845
  if (this.logging) {
2029
- console.log(`[turbine] NOTIFY ${channel}`);
2846
+ (0, logger_js_1.logTo)(this.logger, 'debug', `NOTIFY ${channel}`, { code: 'notify', channel });
2030
2847
  }
2031
2848
  try {
2032
2849
  await this.pool.query('SELECT pg_notify($1, $2)', [channel, payload ?? '']);
@@ -2064,29 +2881,45 @@ class TurbineClient {
2064
2881
  * Throws if the connection fails.
2065
2882
  */
2066
2883
  async connect() {
2067
- // A connection the server closed while idle says nothing about whether
2068
- // the database is reachable, so the check runs on a fresh one when the
2069
- // first turns out to be dead (see openCheckout).
2884
+ await this.roundTrip();
2885
+ if (this.logging) {
2886
+ (0, logger_js_1.logTo)(this.logger, 'debug', 'Connection verified', { code: 'connection-verified' });
2887
+ }
2888
+ }
2889
+ /**
2890
+ * Run one cheap round trip (`SELECT 1`) on the primary and resolve with its
2891
+ * latency in milliseconds, for health checks and for setting
2892
+ * `autoRoundTripMs`. The time measures the statement on a checked-out
2893
+ * connection, not the wait for one: an exhausted pool rejects with
2894
+ * `ConnectionError` (E004) after `connectionTimeoutMs` instead. Rejects with
2895
+ * the typed connection error when the database cannot be reached.
2896
+ */
2897
+ async $ping() {
2898
+ return this.roundTrip();
2899
+ }
2900
+ /**
2901
+ * `SELECT 1` on a checked-out primary connection, timed. A connection the
2902
+ * server closed while idle says nothing about whether the database is
2903
+ * reachable, so it runs on a fresh one when the first turns out to be dead
2904
+ * (see openCheckout).
2905
+ */
2906
+ async roundTrip() {
2907
+ let latency = 0;
2070
2908
  const { checkout } = await (0, checkout_js_1.openCheckout)(this.pool, async (client) => {
2909
+ const start = performance.now();
2071
2910
  try {
2072
2911
  await client.query('SELECT 1');
2073
2912
  }
2074
2913
  catch (err) {
2075
2914
  throw (0, errors_js_1.wrapPgError)(err);
2076
2915
  }
2916
+ latency = performance.now() - start;
2077
2917
  });
2078
2918
  checkout.release();
2079
- if (this.logging) {
2080
- console.log('[turbine] Connection verified');
2081
- }
2919
+ return latency;
2082
2920
  }
2083
- /**
2084
- * Gracefully shut down the connection pool.
2085
- *
2086
- * If Turbine was given an external pool via `TurbineConfig.pool`, this
2087
- * method is a no-op, the caller is responsible for the pool's lifecycle.
2088
- */
2089
- async disconnect() {
2921
+ async disconnect(options) {
2922
+ const timeoutMs = resolveNonNegativeInteger(options?.timeoutMs, 'disconnect timeoutMs', 'milliseconds');
2090
2923
  // Tear down any live LISTEN subscriptions first. Each holds a dedicated
2091
2924
  // pooled connection checked out; if we ended the pool (or returned for an
2092
2925
  // external pool) without releasing them, pool.end() would wait forever for
@@ -2102,33 +2935,63 @@ class TurbineClient {
2102
2935
  }
2103
2936
  this.activeSubscriptions.clear();
2104
2937
  }
2105
- // Close owned (string-configured) replica pools regardless of whether the
2106
- // primary is owned, external replica pools are left untouched (caller owns
2107
- // their lifecycle), same contract as an external primary.
2108
- for (const replicaPool of this.ownedReplicaPools) {
2109
- try {
2110
- await replicaPool.end();
2111
- }
2112
- catch (err) {
2113
- if (this.logging) {
2114
- console.error('[turbine] Error closing replica pool:', err.message);
2115
- }
2938
+ // Owned (string-configured) replica pools are closed whether or not the
2939
+ // primary is owned; external replica pools are the caller's, like an
2940
+ // external primary.
2941
+ const endReplicas = Promise.all(this.ownedReplicaPools.map((replicaPool) => replicaPool.end().catch((err) => {
2942
+ if (this.logging) {
2943
+ (0, logger_js_1.logTo)(this.logger, 'error', `Error closing replica pool: ${err.message}`, {
2944
+ code: 'replica-close-error',
2945
+ error: err,
2946
+ });
2116
2947
  }
2948
+ })));
2949
+ if (!this.ownsPool && this.logging) {
2950
+ (0, logger_js_1.logTo)(this.logger, 'debug', 'disconnect() skipped, external primary pool is not owned by Turbine', {
2951
+ code: 'disconnect-skipped',
2952
+ });
2117
2953
  }
2118
- if (!this.ownsPool) {
2119
- if (this.logging) {
2120
- console.log('[turbine] disconnect() skipped, external primary pool is not owned by Turbine');
2954
+ const ending = Promise.all([endReplicas, this.ownsPool ? this.pool.end() : undefined]);
2955
+ if (timeoutMs === undefined || (await settlesWithin(ending, timeoutMs))) {
2956
+ await ending;
2957
+ if (this.logging && this.ownsPool) {
2958
+ (0, logger_js_1.logTo)(this.logger, 'debug', 'Pool disconnected', { code: 'pool-disconnected', timedOut: false });
2121
2959
  }
2122
- return;
2960
+ return options === undefined ? undefined : { timedOut: false, forcedConnections: 0 };
2123
2961
  }
2124
- await this.pool.end();
2962
+ const forcedConnections = await this.forceCloseCheckedOut();
2963
+ // Closed connections reach the pool as their holders' queries fail; give
2964
+ // that one grace period, then return either way rather than hang on a
2965
+ // holder that never releases.
2966
+ await settlesWithin(ending, TX_ABORT_GRACE_MS);
2125
2967
  if (this.logging) {
2126
- console.log('[turbine] Pool disconnected');
2968
+ (0, logger_js_1.logTo)(this.logger, 'debug', `Pool disconnected after ${timeoutMs}ms, ${forcedConnections} connection(s) closed in use`, { code: 'pool-disconnected', timedOut: true, timeoutMs, forcedConnections });
2127
2969
  }
2970
+ return { timedOut: true, forcedConnections };
2128
2971
  }
2129
- /** Alias for disconnect() */
2130
- async end() {
2131
- return this.disconnect();
2972
+ /**
2973
+ * Cancel the running statement of every connection still checked out of a
2974
+ * pool this client opened, then close the connection. Cancelling first is
2975
+ * what stops the work on the server: a closed socket alone is noticed only
2976
+ * when the backend next writes to it. Returns how many were closed.
2977
+ */
2978
+ async forceCloseCheckedOut() {
2979
+ const clients = this.checkoutWatches.flatMap((watch) => [...watch.checkedOut]);
2980
+ await Promise.all(clients.map(async (client) => {
2981
+ await (0, utils_js_1.sendCancelRequest)(client, TX_ABORT_GRACE_MS);
2982
+ const c = client;
2983
+ const closing = Promise.resolve()
2984
+ .then(() => c.end?.())
2985
+ .catch(() => { });
2986
+ // A backend that does not answer the terminate message within the
2987
+ // grace period has its socket destroyed instead.
2988
+ if (!(await settlesWithin(closing, TX_ABORT_GRACE_MS)))
2989
+ c.connection?.stream?.destroy?.();
2990
+ }));
2991
+ return clients.length;
2992
+ }
2993
+ async end(options) {
2994
+ return options === undefined ? this.disconnect() : this.disconnect(options);
2132
2995
  }
2133
2996
  /**
2134
2997
  * Pool statistics for monitoring. Returns zeros for pools that don't
@@ -2143,3 +3006,15 @@ class TurbineClient {
2143
3006
  }
2144
3007
  }
2145
3008
  exports.TurbineClient = TurbineClient;
3009
+ // What prisma-compat (and anything else that wraps a client) logs through: the
3010
+ // client's logger and resolved diagnostics, under a `Symbol.for` key so a CJS
3011
+ // and an ESM copy agree. Defined outside the class body so it stays off the
3012
+ // published declaration of `TurbineClient`.
3013
+ Object.defineProperty(TurbineClient.prototype, logger_js_1.CLIENT_LOG_CONTEXT, {
3014
+ get() {
3015
+ const self = this;
3016
+ return { logger: self.logger, diagnostics: self.diagnostics };
3017
+ },
3018
+ enumerable: false,
3019
+ configurable: false,
3020
+ });