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