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
@@ -12,105 +12,75 @@
12
12
  */
13
13
  import { acquireConnection, openCheckout } from '../checkout.js';
14
14
  import { settleEventLoop } from '../connection-guard.js';
15
+ import { resolveDiagnostics } from '../diagnostics.js';
15
16
  import { postgresDialect } from '../dialect.js';
16
- import { explainConnectionLoss, isStaleConnectionError, NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from '../errors.js';
17
+ import { explainConnectionLoss, INVARIANT_REPORT_HINT, InvariantError, isStaleConnectionError, NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from '../errors.js';
17
18
  import { missingIndexForRelation, schemaHasIndexInfo } from '../index-advisor.js';
19
+ import { consoleLogger, logTo } from '../logger.js';
18
20
  import { executeNestedCreate, executeNestedUpdate, extractRelationFields, hasRelationFields, } from '../nested-write.js';
19
21
  import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
22
+ import { transactionStateOf, withSavepoint } from '../transaction-state.js';
20
23
  import * as aggMod from './aggregates.js';
21
24
  import { assertProjectionShape, defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
22
- import { assertMutationWhereIdentifiesOneRow, assertWhereIdentifiesOneRow, expandCompoundUniqueWhere, } from './compound-unique.js';
23
- import { dedupeColumnList, dedupeOrderEntries, isEmptyOrderBy, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
25
+ import { assertMutationWhereIdentifiesOneRow, assertWhereIdentifiesOneRow, expandCompoundUniqueWhere, uniqueKeyNames, whereIdentifiesOneRow, } from './compound-unique.js';
26
+ import { resolveExcludedFromCreate } from './expr.js';
27
+ import { cursorFieldValue, dedupeColumnList, dedupeOrderEntries, isEmptyOrderBy, isJsonPathOrderBy, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, operandColumnKind, orderByEntries, sortedEntries, } from './filters.js';
28
+ import { assertCursorNotEmptied, assertCursorWritten, assertWhereNotEmptiedByPruning, hasCursor, isCursorInclusive, pruneUndefinedArgs, } from './normalize-args.js';
24
29
  import { warnUnknownQueryOptions } from './option-surface.js';
25
- import { applyStableRelationOrderTo, normalizeWithClause } from './relation-names.js';
30
+ import { applyStableRelationOrderTo, normalizeWithClause, normalizeWithPagination } from './relation-names.js';
26
31
  import * as relationsMod from './relations.js';
32
+ import { lockOnStreamMessage, lockOutsideTransactionMessage, lockWithDistinctMessage, resolveRowLock, } from './row-lock.js';
33
+ import { resolveSqlComment } from './sql-comment.js';
34
+ import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, newStreamIdleState, resolveStreamIdleTimeout, StreamIdleGuard, streamBackstopStatement, streamIdleExpiredError, watchStreamConnection, } from './stream-idle.js';
27
35
  import { resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './types.js';
28
- import { availableClause, isTemporalInfinity, LRUCache, normalizePagination, ownLookup, parseDbDate, resolveColumnName, resolveRelation, resolveRelationDef, sqlToPreparedName, unknownFieldMessage, warnRedundantSortTerm, } from './utils.js';
29
- import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
36
+ import { assertMiddlewareArgsKept, availableClause, boundedPreparedName, canCancelBackend, isNodePostgresPool, isTemporalInfinity, LRUCache, normalizePagination, ownLookup, parseDbDate, readJsonFloat, readJsonFloatArray, readOnlyMiddlewareParams, readTextArray, resolveColumnName, resolveRelation, resolveRelationDef, sendCancelRequest, sqlToPreparedName, unknownFieldMessage, warnRedundantSortTerm, } from './utils.js';
37
+ import { resetWarnOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
30
38
  import * as whereMod from './where.js';
31
39
  import * as writesMod from './writes.js';
32
40
  /**
33
- * SQL-cache lockstep cross-check gate.
41
+ * SQL-cache lockstep cross-check.
34
42
  *
35
43
  * The SQL template cache requires three code paths to enumerate where-clause
36
44
  * keys identically: `fingerprintWhere` (builds the cache key),
37
45
  * `buildWhereClause` (builds SQL + `$N` params on a MISS), and
38
46
  * `collectWhereParams` (re-collects params on a HIT without rebuilding). Since
39
47
  * 0.36 all three consume ONE shared where-key walk (see `where-compile.ts`), so
40
- * enumeration drift is structurally eliminated; this check remains as a
48
+ * enumeration drift is structurally eliminated; these checks remain as a
41
49
  * tripwire in case a future leaf builder or its collect mirror falls out of
42
- * step. It rebuilds the SQL + params fresh on a cache HIT and compares them
43
- * against what the cache-hit path produced.
50
+ * step. Every failure is an {@link InvariantError} (E020), thrown before the
51
+ * statement is sent.
44
52
  *
45
- * Modes (decided per HIT; env read inline, not captured once, so tests and
46
- * perf-sensitive traffic can toggle per process):
47
- * - `'dev'`: `NODE_ENV !== 'production'` and `TURBINE_DISABLE_CACHE_CHECK !== '1'`
48
- * (always checks; the historical default, the whole unit suite runs under it).
49
- * - `'off'`: dev with `TURBINE_DISABLE_CACHE_CHECK=1`, OR production with no
50
- * sampling configured (the hot path is untouched).
51
- * - `'sampled'`: production with `TURBINE_CACHE_CHECK_SAMPLE` set to a float in
52
- * `(0,1]`, re-verify that fraction of cache hits (per-hit `Math.random()`;
53
- * `>= 1` checks every hit). A mismatch logs once per distinct fingerprint AND
54
- * throws, same as dev. `0`, unset, or an unparseable value means never check.
55
- */
56
- function cacheCrossCheckMode() {
57
- const env = crossCheckEnv ?? readCrossCheckEnv();
58
- if (env.mode !== 'sampled')
59
- return env.mode;
60
- // The SAMPLING decision, and only it, is genuinely per hit.
61
- return env.rate >= 1 || Math.random() < env.rate ? 'sampled' : 'off';
62
- }
63
- /**
64
- * The env-derived half of {@link cacheCrossCheckMode}, resolved ONCE per
65
- * process and memoized.
66
- *
67
- * `process.env` is not a plain object: every property read crosses a C++
68
- * interceptor. Measured at 112.7 ns per read against 3.6 ns for a module-level
69
- * constant, and this function ran TWO reads on every cache hit, which put it
70
- * at the top of the SQL-build CPU profile (12.53% self time, with a further
71
- * ~12% inside the interceptor) for a value that does not change.
72
- *
73
- * It is memoized rather than hoisted to a `const` because the toggle IS
74
- * observed at runtime, contrary to what a quick look suggests: the
75
- * cross-check and sampling suites flip `NODE_ENV` /
76
- * `TURBINE_DISABLE_CACHE_CHECK` / `TURBINE_CACHE_CHECK_SAMPLE` around a
77
- * synchronous block in-process, not at spawn. So the read is deferred to first
78
- * use and {@link resetCacheCrossCheckEnv} re-arms it; that is the entire
79
- * contract, and the only thing lost versus reading every time is that a
80
- * process which mutates these variables mid-run must say so.
81
- */
82
- let crossCheckEnv;
83
- function readCrossCheckEnv() {
84
- let resolved;
85
- if (process.env.NODE_ENV !== 'production') {
86
- resolved = { mode: process.env.TURBINE_DISABLE_CACHE_CHECK === '1' ? 'off' : 'dev' };
87
- }
88
- else {
89
- const raw = process.env.TURBINE_CACHE_CHECK_SAMPLE;
90
- const rate = raw === undefined ? Number.NaN : Number.parseFloat(raw);
91
- resolved = !Number.isFinite(rate) || rate <= 0 ? { mode: 'off' } : { mode: 'sampled', rate };
92
- }
93
- crossCheckEnv = resolved;
94
- return resolved;
95
- }
96
- /**
97
- * Discard the memoized cross-check environment so the next cache hit re-reads
98
- * `process.env`.
53
+ * Two checks always run, whatever the diagnostics mode, because each costs one
54
+ * array compare and the alternative is executing parameters known to be wrong:
55
+ * - On a MISS, the params the build closure pushed are compared with the params
56
+ * the collect path produced (`sql-cache-lockstep`). The miss already paid
57
+ * for both walks, and a shape run once per instance is otherwise never
58
+ * compared at all.
59
+ * - On a HIT, the collected param count must equal the count the entry was
60
+ * built with (`sql-cache-param-count`). Otherwise the server would reject
61
+ * the statement with a bind error that reads like a connection problem.
99
62
  *
100
- * @internal Exposed for the tests that toggle these variables in-process (see
101
- * {@link crossCheckEnv}). Production code sets them before the first query and
102
- * never again.
63
+ * The full rebuild-and-compare on a HIT follows the client's
64
+ * `diagnostics.cacheCheck`, resolved once at construction (see diagnostics.ts;
65
+ * the legacy `TURBINE_DISABLE_CACHE_CHECK` / `TURBINE_CACHE_CHECK_SAMPLE`
66
+ * variables are read there and nowhere else):
67
+ * - `'every-hit'` (the development default): every hit is compared, and a
68
+ * mismatch throws.
69
+ * - `{ sample }`: that fraction of hits is compared (per-hit `Math.random()`;
70
+ * a sample of 1 compares every hit). A mismatch is logged at `error` once
71
+ * per distinct fingerprint, then throws.
72
+ * - `'off'` (the production default): never rebuilt on a hit.
103
73
  */
104
- export function resetCacheCrossCheckEnv() {
105
- crossCheckEnv = undefined;
106
- }
107
74
  /**
108
- * Distinct cache-mismatch fingerprints already logged by the sampled production
109
- * cross-check, so a hot mismatch logs `console.error` exactly once per shape
110
- * instead of flooding the log. Dev mode does not log (it throws immediately),
111
- * so this set is only ever touched on the `'sampled'` path.
75
+ * Warn-once namespace for the sampled cross-check's mismatch log, so a hot
76
+ * mismatch logs exactly once per table and shape instead of flooding the log.
77
+ * `'every-hit'` does not log (it throws immediately), so only the sampled path
78
+ * records here. Keyed on the TABLE as well as the fingerprint: the fingerprint
79
+ * alone carries no table, so a mismatch on one table used to silence the same
80
+ * shape's mismatch on every other table. The shared registry also bounds the
81
+ * set (`WARN_ONCE_CAP`) and is what `resetWarnOnce` clears.
112
82
  */
113
- const loggedCacheMismatchFingerprints = new Set();
83
+ const CACHE_MISMATCH_WARN_NS = 'cacheMismatch';
114
84
  /**
115
85
  * Marginal cost of keeping a to-one relation on the JOIN plan, per parent row.
116
86
  *
@@ -268,6 +238,29 @@ const ROW_PLAN_CACHE_SIZE = 128;
268
238
  * probe's own limit.
269
239
  */
270
240
  const STREAM_SPECULATION_MAX_BATCH = 1000;
241
+ /** Per-cell decode work in a {@link RowDecodePlan}. */
242
+ const CELL_PLAIN = 0;
243
+ const CELL_TEMPORAL = 1;
244
+ const CELL_FLOAT = 2;
245
+ const CELL_FLOAT_ARRAY = 3;
246
+ const CELL_TEXT_ARRAY = 4;
247
+ /** The dialect's own decode for a type whose storage differs (see `Dialect.decodeColumnValue`). */
248
+ const CELL_DIALECT = 5;
249
+ const FLOAT_TYPES = new Set(['float4', 'float8', 'real', 'double precision']);
250
+ const FLOAT_ARRAY_TYPES = new Set(['_float4', '_float8', 'float4[]', 'float8[]', 'real[]', 'double precision[]']);
251
+ /**
252
+ * The decode a column of `pgType` needs beyond the driver's, other than
253
+ * temporal: a float can arrive from JSON as "NaN" / "Infinity", and an enum
254
+ * array, which the driver has no parser for, as array text.
255
+ */
256
+ function cellKindOf(pgType, enums) {
257
+ if (FLOAT_TYPES.has(pgType))
258
+ return CELL_FLOAT;
259
+ if (FLOAT_ARRAY_TYPES.has(pgType))
260
+ return CELL_FLOAT_ARRAY;
261
+ const element = pgType.startsWith('_') ? pgType.slice(1) : pgType.endsWith('[]') ? pgType.slice(0, -2) : undefined;
262
+ return element !== undefined && enums !== undefined && Object.hasOwn(enums, element) ? CELL_TEXT_ARRAY : CELL_PLAIN;
263
+ }
271
264
  /**
272
265
  * Strict structural equality for a single SQL parameter value. Handles the
273
266
  * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
@@ -410,35 +403,95 @@ export function unlockNestedWriteTx(tx) {
410
403
  */
411
404
  // Moved to ./filters.ts in 0.74.0 so PowqlInterface consumes the SAME
412
405
  // predicate rather than a second copy of it. See applyStableRelationOrderTo.
406
+ /**
407
+ * Marks `findMany` args built for a `findFirst`: one row, not a page, so
408
+ * {@link QueryInterface.completePageOrder} leaves its order as written. A
409
+ * symbol, so it survives the spreads at the args seam and no JSON body can
410
+ * set it. Set through {@link firstRowOnly} only.
411
+ */
412
+ const FIRST_ROW_ONLY = Symbol('turbine.firstRowOnly');
413
+ /**
414
+ * The {@link FIRST_ROW_ONLY} mark for a read the caller bounded with neither
415
+ * `limit` nor `offset`. An `offset` makes the row depend on which tied rows it
416
+ * skipped, so `findFirst({ orderBy, offset })` is a page of one and gets the
417
+ * page tiebreaker like any other page.
418
+ */
419
+ function firstRowOnly(args) {
420
+ return args?.limit === undefined && args?.offset === undefined ? { [FIRST_ROW_ONLY]: true } : {};
421
+ }
422
+ /** The largest delay `setTimeout` honours; Node fires anything larger after 1 ms. */
423
+ const MAX_TIMEOUT_MS = 2_147_483_647;
424
+ /** The accepted `relationLoadStrategy` values. */
425
+ const RELATION_LOAD_STRATEGIES = Object.freeze([
426
+ 'join',
427
+ 'batched',
428
+ 'auto',
429
+ 'flatten',
430
+ ]);
431
+ /** A per-query `timeout`: milliseconds up to {@link MAX_TIMEOUT_MS}, or 0 for none (the same rule as `$transaction`). */
432
+ function isValidTimeout(value) {
433
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= MAX_TIMEOUT_MS;
434
+ }
435
+ /** A short, value-safe rendering of a rejected option value. */
436
+ function describeOptionValue(value) {
437
+ if (typeof value === 'number' || typeof value === 'boolean')
438
+ return String(value);
439
+ if (typeof value === 'string')
440
+ return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}...` : value);
441
+ return value === null ? 'null' : `a value of type ${typeof value}`;
442
+ }
443
+ function relationLoadStrategyMessage(value) {
444
+ return (`\`relationLoadStrategy\` must be one of ${RELATION_LOAD_STRATEGIES.map((v) => `'${v}'`).join(', ')} ` +
445
+ `(received ${describeOptionValue(value)}).`);
446
+ }
447
+ /** A value the findUnique simple path may bind as it is: a primitive, Date or Buffer. */
448
+ function isFastPathScalar(value) {
449
+ switch (typeof value) {
450
+ case 'string':
451
+ case 'number':
452
+ case 'bigint':
453
+ case 'boolean':
454
+ return true;
455
+ case 'object':
456
+ return value instanceof Date || (value !== null && ArrayBuffer.isView(value));
457
+ default:
458
+ return false;
459
+ }
460
+ }
461
+ /** Resolves a race when a deadline passes first. */
462
+ const TIMED_OUT = Symbol('timedOut');
463
+ /**
464
+ * How long a statement that outlived its `timeout` gets, after the cancel
465
+ * request is sent, to answer before its connection is destroyed instead of
466
+ * reused. A cancel normally lands within a round trip or two.
467
+ */
468
+ const CANCEL_GRACE_MS = 2000;
469
+ /** Whether `err` is PostgreSQL's query_canceled (57014), as the driver raised it or as Turbine wrapped it. */
470
+ function isQueryCanceled(err) {
471
+ const e = err;
472
+ return e?.code === '57014' || e?.cause?.code === '57014';
473
+ }
474
+ /** The {@link DeferredQuery.rowLock} marker for a locking read, or nothing. */
475
+ function rowLockMarker(rowLock) {
476
+ return rowLock ? { rowLock: true } : {};
477
+ }
413
478
  /** The two accepted {@link JsonEncoding} values, frozen so the check is total. */
414
479
  const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
415
480
  /**
416
481
  * The relation JSON encoding a client on `dialect` gets when it names none.
417
482
  *
418
- * `'positional'` (`json_build_array`) on PostgreSQL, `'object'`
419
- * (`json_build_object`) everywhere else. The positional form drops the repeated
420
- * key names from every relation row: measured on a 50-parent / ~10-child read
421
- * against local PostgreSQL 17, server time 0.685 ms → 0.350 ms and 152 KB →
422
- * 100 KB on the wire, returning byte-identical parsed rows.
423
- *
424
- * ## Why the test is `dialect.name`, not "does the dialect have buildJsonArray"
425
- *
426
- * Because a presence test does not distinguish engines HERE. `buildJsonArray`
427
- * is declared on `postgresDialect`, and sqlite.ts / mysql.ts / mssql.ts /
428
- * powdb.ts each build their dialect by SPREADING it, so every engine inherits
429
- * the hook and an "is it absent" fallback never fires. That is the documented
430
- * inheritance trap (see `buildPartitionLimit` in {@link
431
- * QueryInterface.batchedContext} and the `distinct` gate in
432
- * {@link QueryInterface.buildFindMany}), and it is exactly the shape that
433
- * silently turned the partition-limit window on for SQLite.
483
+ * `'positional'` (`json_build_array`) where the engine has it (PostgreSQL),
484
+ * `'object'` (`json_build_object`) everywhere else. The positional form drops
485
+ * the repeated key names from every relation row: measured on a 50-parent /
486
+ * ~10-child read against local PostgreSQL 17, server time 0.685 ms → 0.350 ms
487
+ * and 152 KB → 100 KB on the wire, returning byte-identical parsed rows.
434
488
  *
435
- * A new capability flag would work, but only if every engine set it
436
- * explicitly, and it would be a SECOND authority on the same question:
437
- * `buildSelectWithRelations` (relations.ts) already refuses `'positional'` with
438
- * E017 on `dialect.name !== 'postgresql'`. Deriving the default from the
439
- * identical predicate is what makes it impossible for the default to select an
440
- * encoding the builder then refuses. A flag could drift from that refusal; this
441
- * cannot.
489
+ * Read from `capabilities.positionalJson`, never from whether the dialect has
490
+ * `buildJsonArray`: that hook is on `postgresDialect` and every engine inherits
491
+ * it by spreading, so a presence test would hand every engine the positional
492
+ * default. `buildSelectWithRelations` (relations.ts) refuses `'positional'` on
493
+ * the SAME flag, so the default can never select an encoding the builder then
494
+ * refuses: one authority, not two.
442
495
  *
443
496
  * A wire-compatible engine that reaches this on `postgresDialect` itself
444
497
  * (CockroachDB, YugabyteDB, AlloyDB, Timescale, all of which are ADAPTERS over
@@ -446,9 +499,8 @@ const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
446
499
  * which is correct: they speak `json_build_array`.
447
500
  */
448
501
  function defaultJsonEncoding(dialect) {
449
- return dialect.name === 'postgresql' ? 'positional' : 'object';
502
+ return dialect.capabilities.positionalJson ? 'positional' : 'object';
450
503
  }
451
- // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known", intentional for untyped table access
452
504
  export class QueryInterface {
453
505
  pool;
454
506
  table;
@@ -469,6 +521,13 @@ export class QueryInterface {
469
521
  * inline, not through the top-level cache).
470
522
  */
471
523
  lastCacheHit = false;
524
+ /**
525
+ * The params the build closure pushed on the most recent MISS, held until
526
+ * {@link crossCheckCache} compares them with the collected params and clears
527
+ * them. `undefined` after a hit. Same single-flag reasoning as
528
+ * {@link lastCacheHit}.
529
+ */
530
+ lastMissParams;
472
531
  /**
473
532
  * Set while a build closure runs when the statement's WHERE (or HAVING)
474
533
  * carries a caller-written `AND`/`OR` combinator array, i.e. when its SQL text
@@ -480,6 +539,21 @@ export class QueryInterface {
480
539
  */
481
540
  variableArityShape = false;
482
541
  middlewares;
542
+ /** The client's resolved `diagnostics` (see diagnostics.ts), fixed for this instance's life. */
543
+ diagnostics;
544
+ /** `streamIdleTimeoutMs`: how long a cursor stream may wait for its consumer. `0` disables. */
545
+ streamIdleTimeoutMs;
546
+ /** `sqlComment`, resolved; `undefined` when statements carry no comment (one falsy check per send). */
547
+ sqlCommenter;
548
+ /** Where this instance's log lines go: the client's `logger`, else the console logger. */
549
+ logger;
550
+ /**
551
+ * {@link logger} when `diagnostics.advice` is on, else `undefined`. Every
552
+ * per-query advice site starts with `const log = this.advice; if (!log)
553
+ * return;`, so with advice off a site costs one property read and builds no
554
+ * message.
555
+ */
556
+ advice;
483
557
  defaultLimit;
484
558
  warnOnUnlimited;
485
559
  scopedConnect;
@@ -581,7 +655,6 @@ export class QueryInterface {
581
655
  * the audit's "Set<string>" guidance and leaves room for future
582
656
  * cross-table sharing.
583
657
  */
584
- warnedTables = new Set();
585
658
  /** Cache hit/miss counters for diagnostics */
586
659
  cacheHits = 0;
587
660
  cacheMisses = 0;
@@ -602,6 +675,10 @@ export class QueryInterface {
602
675
  * to Date as well (otherwise nested dates leak through as strings).
603
676
  */
604
677
  camelDateFieldCache = new Map();
678
+ /** Per-table memo of {@link cellKindOf}, keyed by column name and field name. */
679
+ cellKindCache = new Map();
680
+ /** The dialect decode of each {@link CELL_DIALECT} column, by table, then by column and field name. */
681
+ dialectDecodeCache = new Map();
605
682
  /**
606
683
  * Per-table memo of `Object.entries(meta.relations)`, consumed by the nested
607
684
  * row parser (see `getRelationEntries` in relations.ts). Same rationale and
@@ -675,7 +752,14 @@ export class QueryInterface {
675
752
  }
676
753
  this.tableMeta = meta;
677
754
  this.middlewares = middlewares ?? [];
755
+ // Resolved by the client once; a QueryInterface built directly (tests,
756
+ // tooling) resolves here, at construction, never per call.
757
+ this.diagnostics = resolveDiagnostics(options?.diagnostics);
758
+ this.logger = options?.logger ?? consoleLogger;
759
+ this.advice = this.diagnostics.advice ? this.logger : undefined;
678
760
  this.defaultLimit = options?.defaultLimit;
761
+ this.streamIdleTimeoutMs =
762
+ resolveStreamIdleTimeout(options?.streamIdleTimeoutMs, 'streamIdleTimeoutMs') ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
679
763
  // Default to ON: surfacing accidental full-table scans is more valuable
680
764
  // than the (small) risk of noisy logs. Callers opt out globally with
681
765
  // `warnOnUnlimited: false`, per table with `warnOnUnlimited: { users:
@@ -712,6 +796,17 @@ export class QueryInterface {
712
796
  this.sqlCacheEnabled = options?.sqlCache !== false && sqlCacheSize !== 0;
713
797
  this.sqlTemplateCache = new LRUCache(sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
714
798
  this.dialect = options?.dialect ?? postgresDialect;
799
+ this.sqlCommenter =
800
+ options?._sqlCommenter ??
801
+ resolveSqlComment(options?.sqlComment, this.logger, {
802
+ dialect: this.dialect.name,
803
+ preparesEveryStatement: this.dialect.capabilities.preparesEveryStatement,
804
+ buildsSql: true,
805
+ });
806
+ if (options?.relationLoadStrategy !== undefined &&
807
+ !RELATION_LOAD_STRATEGIES.includes(options.relationLoadStrategy)) {
808
+ throw new ValidationError(relationLoadStrategyMessage(options.relationLoadStrategy));
809
+ }
715
810
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'auto';
716
811
  this.stableRelationOrder = options?.stableRelationOrder === true;
717
812
  this.implicitPkOrdering = options?.implicitPkOrdering === true;
@@ -778,6 +873,8 @@ export class QueryInterface {
778
873
  // per `build*` call, so it is exposed as a LIVE getter (not a copied value).
779
874
  const self = this;
780
875
  this.ctx = {
876
+ diagnostics: this.diagnostics,
877
+ logger: this.logger,
781
878
  dialect: this.dialect,
782
879
  table: this.table,
783
880
  schema: this.schema,
@@ -844,7 +941,7 @@ export class QueryInterface {
844
941
  };
845
942
  }
846
943
  /**
847
- * Dev-only, once per table: the columns whose database type is absent from
944
+ * Advice (`diagnostics.advice`), once per table: the columns whose database type is absent from
848
945
  * BOTH the column entry and the table-level type maps, the residual case
849
946
  * after the two-source resolution above.
850
947
  *
@@ -869,23 +966,24 @@ export class QueryInterface {
869
966
  * Postgres rejects with `22007 invalid input syntax for type time`.
870
967
  * - `timestamptz` and every non-temporal type: unaffected.
871
968
  *
872
- * PostgreSQL only: the UTC bind rewrite is Postgres-gated (see
873
- * `utcDateTimeWrites` in writes.ts), so on the other engines a missing type
874
- * changes nothing about how a `Date` is bound. Suppressed under
875
- * `NODE_ENV=production` like the other dev diagnostics and deduped through
876
- * the shared registry, so a hot table logs one line for the process.
969
+ * PostgreSQL only: the zone-less `timestamp` rewrite is Postgres-gated (see
970
+ * `utcDateTimeRewrite` in where.ts), so on the other engines a missing type
971
+ * costs only the `date` rewrite. Silent when `diagnostics.advice` is off
972
+ * (the production default) like the other advice, and deduped through the
973
+ * shared registry, so a hot table logs one line for the process.
877
974
  *
878
975
  * Cannot throw on odd metadata: it walks `tableMeta.columns`, the array the
879
976
  * constructor loop above has already iterated (and that client.ts validates
880
977
  * as an array), never `dateColumns`, which is a `Set` in every first-party
881
978
  * metadata path but arrives as a plain object from JSON-round-tripped
882
- * metadata. A dev-only diagnostic that crashes a shape production would serve
979
+ * metadata. An advisory diagnostic that crashes a shape production would serve
883
980
  * is worse than the bug it reports.
884
981
  */
885
982
  warnUntypedColumns() {
886
- if (process.env.NODE_ENV === 'production')
983
+ const log = this.advice;
984
+ if (!log)
887
985
  return;
888
- if (this.dialect.name !== 'postgresql')
986
+ if (!this.dialect.capabilities.utcTimestampRewrite)
889
987
  return;
890
988
  const untyped = [];
891
989
  for (const col of this.tableMeta.columns) {
@@ -901,14 +999,14 @@ export class QueryInterface {
901
999
  const MAX_NAMED = 12;
902
1000
  const named = untyped.slice(0, MAX_NAMED).join(', ');
903
1001
  const rest = untyped.length > MAX_NAMED ? ` (+${untyped.length - MAX_NAMED} more)` : '';
904
- console.warn(`[turbine] table "${this.table}": no database type in metadata for column(s) ${named}${rest} (neither the ` +
1002
+ logTo(log, 'warn', `table "${this.table}": no database type in metadata for column(s) ${named}${rest} (neither the ` +
905
1003
  "column entry's `dialectType`/`pgType` nor the table-level `dialectTypes`/`pgTypes` map). Turbine cannot " +
906
1004
  'tell which of them are zone-less, so it skips the UTC bind rewrite and a `Date` goes to the driver ' +
907
1005
  "as-is: right for `timestamptz`, but a zone-less `date`/`timestamp` then stores the PROCESS's local " +
908
1006
  'calendar fields rather than UTC (silently, since the read path shifts back by the same offset), and a ' +
909
1007
  '`time`/`timetz` column rejects the value outright (`22007 invalid input syntax for type time`). ' +
910
1008
  'Columns whose type IS resolved, every `timestamptz` among them, are unaffected. Fix: regenerate with ' +
911
- '`npx turbine generate`, or set the column types in `defineSchema`.');
1009
+ '`npx turbine generate`, or set the column types in `defineSchema`.', { code: 'untyped-columns', table: this.table, columns: untyped });
912
1010
  }
913
1011
  /** Quote an identifier through the active SQL dialect. */
914
1012
  q(name) {
@@ -1075,24 +1173,63 @@ export class QueryInterface {
1075
1173
  * query/relation-names.ts.
1076
1174
  */
1077
1175
  withDeclaredRelationNames(args) {
1078
- if (!args?.with)
1176
+ const withClause = args?.with;
1177
+ if (!withClause)
1178
+ return args;
1179
+ const normalized = normalizeWithClause(this.schema, this.table, withClause);
1180
+ return normalized === withClause ? args : { ...args, with: normalized };
1181
+ }
1182
+ /**
1183
+ * `args` with `take` / `skip` folded into `limit` / `offset` at every `with`
1184
+ * level (see `normalizeWithPagination` in query/relation-names.ts). Runs after
1185
+ * the relation-name pass, so it resolves the declared spelling.
1186
+ */
1187
+ withRelationPagination(args) {
1188
+ const withClause = args?.with;
1189
+ if (!withClause)
1079
1190
  return args;
1080
- const normalized = normalizeWithClause(this.schema, this.table, args.with);
1081
- return normalized === args.with ? args : { ...args, with: normalized };
1191
+ const normalized = normalizeWithPagination(this.schema, this.table, withClause);
1192
+ return normalized === withClause ? args : { ...args, with: normalized };
1082
1193
  }
1083
1194
  /**
1084
- * Caller args in canonical form: declared relation spellings, and the Prisma
1085
- * pagination aliases folded into `limit` / `offset`.
1195
+ * Caller args in canonical form: `undefined` pruned (query/normalize-args.ts),
1196
+ * declared relation spellings, and the Prisma pagination aliases folded into
1197
+ * `limit` / `offset`, with `take` / `skip` folded inside every `with` level too.
1086
1198
  *
1087
- * ONE method rather than two calls at each seam, because the two
1088
- * normalizations have the same requirement and the same failure mode: both
1089
- * must happen before `withFingerprint` / the cache key, and a seam that
1090
- * applies one but not the other is a seam where the alias survives into a
1091
- * fingerprint. Both return their input by reference when there was nothing to
1092
- * change, so the common path still allocates nothing.
1199
+ * Called at the seam of EVERY public operation and its `build*` twin, so the
1200
+ * guards, the fingerprint, the build and the collect all read one shape. Each
1201
+ * step returns its input by reference when there was nothing to change, so a
1202
+ * second call on already-normalized args is free and the common path
1203
+ * allocates nothing.
1204
+ *
1205
+ * `refuseEmptiedWhere` names the single-row read (`findFirst`,
1206
+ * `findFirstOrThrow`) that refuses a `where` whose every key was `undefined`.
1207
+ */
1208
+ normalizeArgs(args, refuseEmptiedWhere) {
1209
+ const pruned = pruneUndefinedArgs(this.schema, this.table, args);
1210
+ if (pruned !== args) {
1211
+ if (refuseEmptiedWhere)
1212
+ assertWhereNotEmptiedByPruning(this.table, refuseEmptiedWhere, args, pruned);
1213
+ assertCursorNotEmptied(this.table, args, pruned);
1214
+ }
1215
+ if (pruned)
1216
+ this.assertQueryOptions(pruned);
1217
+ return normalizePagination(this.withRelationPagination(this.withDeclaredRelationNames(pruned)));
1218
+ }
1219
+ /**
1220
+ * Validate the per-query options whose bad values used to be ignored or
1221
+ * misread: `timeout` (Node clamps a non-positive, non-finite or oversized
1222
+ * delay to 1 ms) and `relationLoadStrategy`.
1093
1223
  */
1094
- normalizeArgs(args) {
1095
- return normalizePagination(this.withDeclaredRelationNames(args));
1224
+ assertQueryOptions(args) {
1225
+ const { timeout, relationLoadStrategy } = args;
1226
+ if (timeout !== undefined && !isValidTimeout(timeout)) {
1227
+ throw new ValidationError(`\`timeout\` on ${this.table} must be a positive number of milliseconds up to ${MAX_TIMEOUT_MS}, or 0 for ` +
1228
+ `no timeout (received ${describeOptionValue(timeout)}). Leave it out for no timeout.`);
1229
+ }
1230
+ if (relationLoadStrategy !== undefined && !RELATION_LOAD_STRATEGIES.includes(relationLoadStrategy)) {
1231
+ throw new ValidationError(relationLoadStrategyMessage(relationLoadStrategy));
1232
+ }
1096
1233
  }
1097
1234
  /**
1098
1235
  * Refuse a `findUnique` whose `where` names no unique key. The rule and the
@@ -1102,6 +1239,30 @@ export class QueryInterface {
1102
1239
  assertFindUniqueKey(where) {
1103
1240
  assertWhereIdentifiesOneRow(this.tableMeta, this.table, where);
1104
1241
  }
1242
+ /**
1243
+ * Refuse a locking read that is not inside a transaction, at the execute
1244
+ * entry of each find method and before any strategy runs. The `build*`
1245
+ * methods accept `lock` (the SQL is right wherever it runs); executing it
1246
+ * in autocommit releases the lock before the caller sees the rows.
1247
+ *
1248
+ * The value is validated first, so an engine with no row locks answers
1249
+ * E017 rather than asking for a transaction it would also refuse.
1250
+ * "Inside a transaction" is `_txScoped`: the accessors a TransactionClient
1251
+ * hands out, at any nesting depth.
1252
+ */
1253
+ assertLockInTransaction(args, operation) {
1254
+ if (args?.lock === undefined)
1255
+ return;
1256
+ resolveRowLock(args.lock, this.dialect, this.table);
1257
+ if (!this.txScoped)
1258
+ throw new ValidationError(lockOutsideTransactionMessage(this.table, operation));
1259
+ }
1260
+ /** Refuse `lock` on a streaming read (a cursor is not a locking read). */
1261
+ assertNoStreamLock(args, operation) {
1262
+ if (args?.lock !== undefined) {
1263
+ throw new ValidationError(lockOnStreamMessage(this.table, operation));
1264
+ }
1265
+ }
1105
1266
  /**
1106
1267
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
1107
1268
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -1134,146 +1295,325 @@ export class QueryInterface {
1134
1295
  const fields = pk.map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
1135
1296
  return fields.length === 1 ? { [fields[0]]: 'asc' } : fields.map((f) => ({ [f]: 'asc' }));
1136
1297
  }
1298
+ /** Memo for {@link totalOrderKeys}; table metadata is immutable. */
1299
+ totalOrderKeysMemo;
1137
1300
  /**
1138
- * The field names a `cursor` actually seeks on (its own keys with a defined
1139
- * value), in canonical sorted order. This is the diagnostic / implicit-
1140
- * ordering view; the seek itself orders the fields by their `orderBy`
1141
- * precedence (see {@link cursorSeekEntries}). Empty for a missing cursor or
1142
- * one whose every value is `undefined` (which emits no seek condition at
1143
- * all, so it does not paginate).
1301
+ * The unique keys whose columns are all NOT NULL, primary key first: the
1302
+ * column sets that make an order total (no two rows can tie on them).
1144
1303
  */
1145
- cursorFields(cursor) {
1146
- if (!cursor || typeof cursor !== 'object')
1304
+ totalOrderKeys() {
1305
+ if (this.totalOrderKeysMemo)
1306
+ return this.totalOrderKeysMemo;
1307
+ const meta = this.tableMeta;
1308
+ const nullable = (column) => !meta.primaryKey.includes(column) && meta.columns.find((c) => c.name === column)?.nullable === true;
1309
+ this.totalOrderKeysMemo = uniqueKeyNames(meta).filter((cols) => cols.every((c) => !nullable(c)));
1310
+ return this.totalOrderKeysMemo;
1311
+ }
1312
+ /**
1313
+ * THE tiebreaker rule, shared by cursor pages ({@link planCursor}) and the
1314
+ * pages before them ({@link completePageOrder}), so every page of one walk
1315
+ * is cut from the same total order: the columns to append to an order over
1316
+ * `ordered` so that no two rows tie. `[]` when the order already contains a
1317
+ * unique NOT NULL key; the first such key's missing columns otherwise;
1318
+ * `undefined` when the table has none.
1319
+ */
1320
+ tiebreakColumns(ordered) {
1321
+ const keys = this.totalOrderKeys();
1322
+ if (keys.some((cols) => cols.every((c) => ordered.has(c))))
1147
1323
  return [];
1148
- return Object.entries(cursor)
1149
- .filter(([, v]) => v !== undefined)
1150
- .map(([k]) => k)
1151
- .sort();
1324
+ return keys[0]?.filter((c) => !ordered.has(c));
1152
1325
  }
1153
1326
  /**
1154
- * The ascending ordering implied by a `cursor`, or `undefined` when the shape
1155
- * is too ambiguous to order safely.
1156
- *
1157
- * A cursor seek compares against the cursor's own field(s) (`col > $n`, `<`
1158
- * when the orderBy says desc), so the ONLY ordering coherent with it is on
1159
- * those fields: ordering a seek on column X by column Y walks the table in an
1160
- * order the seek does not follow, which skips and repeats rows just as badly
1161
- * as no order at all. That is why this orders on the cursor field rather than
1162
- * blindly on the primary key when the two differ.
1327
+ * A paginated `findMany` (`limit` / `offset`) whose `orderBy` can tie gets
1328
+ * the tiebreaker appended, in the last term's direction, so a page boundary
1329
+ * cannot fall between tied rows in a different order on each page (skipping
1330
+ * or repeating one), and the first page of a cursor walk uses the order its
1331
+ * cursor pages use. Returns `args` itself when the order is already total,
1332
+ * the query is not paginated, or it pages by `cursor` or `distinct`, which
1333
+ * order themselves. A `findFirst` with no `offset` is one row, not a page,
1334
+ * and is left alone.
1163
1335
  *
1164
- * Returns `undefined` (warn, inject nothing) for two shapes:
1165
- * - a MULTI-field cursor. The seek is a proper keyset predicate, but its
1166
- * column PRECEDENCE comes from the `orderBy` ({@link cursorSeekEntries}),
1167
- * and with no orderBy there is nothing to derive it from: the cursor
1168
- * object's key order is canonicalized away, so `(a asc, b asc)` would be a
1169
- * guess at which key the caller meant to lead. The caller must state the
1170
- * order, and the unordered-page warning says so.
1171
- * - a field that does not resolve to a real column. Column validation belongs
1172
- * to the normal build path, which raises a precise error; synthesizing an
1173
- * ORDER BY on it here would only change which error the caller sees.
1336
+ * An order containing a vector distance gets no tiebreaker: a KNN index
1337
+ * serves only `ORDER BY <distance> LIMIT k`, a compound order turns that
1338
+ * into a scan and sort of the whole table, and two equal distances are
1339
+ * rare. A `cursor` still refuses such an order (see {@link planCursor}).
1174
1340
  */
1175
- cursorOrderBy(cursor) {
1176
- const fields = this.cursorFields(cursor);
1177
- if (fields.length !== 1)
1178
- return undefined;
1179
- const field = fields[0];
1180
- try {
1181
- this.toColumn(field);
1182
- }
1183
- catch {
1184
- return undefined;
1341
+ completePageOrder(args) {
1342
+ if (args.orderBy === undefined || (args.limit === undefined && args.offset === undefined))
1343
+ return args;
1344
+ if (hasCursor(args.cursor) || (args.distinct && args.distinct.length > 0))
1345
+ return args;
1346
+ if (args[FIRST_ROW_ONLY])
1347
+ return args;
1348
+ const entries = orderByEntries(args.orderBy).filter(([, d]) => d !== undefined);
1349
+ if (entries.some(([, d]) => isVectorOrderBy(d)))
1350
+ return args;
1351
+ const ordered = new Set();
1352
+ let lastDesc = false;
1353
+ for (const [key, dir] of entries) {
1354
+ lastDesc = false;
1355
+ if (isVectorOrderBy(dir) || isJsonPathOrderBy(dir) || this.isRelationOrderByValue(dir))
1356
+ continue;
1357
+ const column = resolveColumnName(this.tableMeta, key);
1358
+ if (column === undefined)
1359
+ continue;
1360
+ ordered.add(column);
1361
+ lastDesc = normalizeOrderBy(dir).dir === 'DESC';
1185
1362
  }
1186
- return { [field]: 'asc' };
1363
+ const missing = this.tiebreakColumns(ordered);
1364
+ if (!missing || missing.length === 0)
1365
+ return args;
1366
+ const dir = lastDesc ? 'desc' : 'asc';
1367
+ const field = (column) => this.tableMeta.reverseColumnMap[column] ?? column;
1368
+ return {
1369
+ ...args,
1370
+ orderBy: [...entries.map(([k, v]) => ({ [k]: v })), ...missing.map((c) => ({ [field(c)]: dir }))],
1371
+ };
1187
1372
  }
1188
1373
  /**
1189
- * The fields a `cursor` seeks on, in KEYSET order with their seek direction.
1190
- * THE single authority the cache fingerprint (`cur=`), the SQL build and the
1191
- * cache-hit param collect all consume, so the three cannot disagree about
1192
- * which value binds to which `$n`.
1374
+ * Plan a `cursor` page: THE single authority the cache key, the SQL build and
1375
+ * the cache-hit param collect all read, so the three cannot disagree.
1193
1376
  *
1194
- * Keyset order is the `orderBy` precedence: the predicate for
1195
- * `orderBy: [{ viewCount }, { id }]` must test `view_count` first and `id`
1196
- * only within equal `view_count`, whatever order the caller wrote the cursor
1197
- * object in (its keys are canonicalized for the cache anyway). A cursor field
1198
- * the orderBy does not name trails the named ones in sorted key order and
1199
- * seeks ascending, the same default a single-field cursor with no orderBy has
1200
- * always had.
1377
+ * The page is ordered by the EFFECTIVE order: the caller's `orderBy` plus a
1378
+ * tiebreaker (the primary key, else a unique key on NOT NULL columns) when
1379
+ * the orderBy does not already end in one, in the last term's direction. A
1380
+ * cursor query is always ordered, because a seek over rows the engine may
1381
+ * return in any order skips and repeats rows. `args.orderBy` is rewritten to
1382
+ * that order (by reference when nothing changes).
1201
1383
  *
1202
- * Directions are indexed by the RESOLVED COLUMN, never the caller's key: both
1203
- * `cursor` and `orderBy` take either spelling, so a cursor written
1204
- * `{ created_at }` against `orderBy: { createdAt: 'desc' }` used to miss the
1205
- * lookup, default to ascending, and seek the wrong side of the page. The
1206
- * `{ sort, nulls }` spec form is normalized for the same reason. A relation /
1207
- * JSON-path / vector orderBy key resolves to no column and is skipped.
1384
+ * Two ways to place the page, picked from what the cursor carries:
1385
+ * - VALUES: the cursor has a value for every column of the effective order,
1386
+ * so the seek compares against them directly (`col > $1`, expanded to a
1387
+ * keyset across several columns). Extra cursor fields are ignored.
1388
+ * - ROW: otherwise the cursor must identify one row by a unique key (the
1389
+ * findUnique rule), and the seek reads that row's ordered values in
1390
+ * scalar subqueries, guarded by `EXISTS` so a deleted cursor row gives an
1391
+ * empty page rather than a wrong one.
1392
+ * Anything else is refused (E003) naming the missing fields.
1208
1393
  *
1209
- * An unknown cursor field throws the same E003 here that the build path
1210
- * threw before this helper existed; the fingerprint simply meets it first.
1394
+ * NULL sort keys are placed where the engine sorts them (PostgreSQL: last
1395
+ * ascending, first descending; SQLite / MySQL / SQL Server: NULL is smallest)
1396
+ * or where an explicit `nulls` puts them, and each row is visited exactly
1397
+ * once. On PostgreSQL a nullable ordered column has its placement written
1398
+ * out, so a Postgres-compatible engine with a different default reads the
1399
+ * same order the seek assumes.
1211
1400
  */
1212
- cursorSeekEntries(cursor, orderBy) {
1213
- const orderPos = new Map();
1214
- const orderDesc = new Map();
1215
- for (const [ok, od] of orderByEntries(orderBy)) {
1216
- const ocol = resolveColumnName(this.tableMeta, ok);
1217
- if (ocol === undefined)
1401
+ planCursor(args) {
1402
+ const raw = args.cursor;
1403
+ if (!hasCursor(raw))
1404
+ return { args, plan: undefined };
1405
+ const meta = this.tableMeta;
1406
+ const field = (column) => meta.reverseColumnMap[column] ?? column;
1407
+ const nullableColumn = (column) => !meta.primaryKey.includes(column) && meta.columns.find((c) => c.name === column)?.nullable === true;
1408
+ // The cursor's own values, by resolved column (either spelling, or a
1409
+ // compound-unique selector, is accepted). Each is a row's value, checked
1410
+ // by the rule every engine shares (cursorFieldValue).
1411
+ const flat = expandCompoundUniqueWhere(meta, raw);
1412
+ const cursorValues = new Map();
1413
+ for (const [key, written] of sortedEntries(flat)) {
1414
+ if (written === undefined)
1218
1415
  continue;
1219
- if (!orderPos.has(ocol))
1220
- orderPos.set(ocol, orderPos.size);
1221
- // Last wins, matching object-key semantics (duplicates are refused
1222
- // upstream anyway, so in practice there is exactly one).
1223
- orderDesc.set(ocol, isOrderBySpec(od) ? od.sort === 'desc' : od === 'desc');
1224
- }
1225
- const entries = sortedEntries(cursor)
1226
- .filter(([, v]) => v !== undefined)
1227
- .map(([k, value]) => {
1228
- const column = this.toColumn(k);
1229
- return { column, value, desc: orderDesc.get(column) === true };
1230
- });
1231
- // Stable sort over the canonical (sorted-key) list: orderBy precedence
1232
- // first, unnamed fields after it in their sorted order.
1233
- return entries.sort((a, b) => (orderPos.get(a.column) ?? Number.MAX_SAFE_INTEGER) - (orderPos.get(b.column) ?? Number.MAX_SAFE_INTEGER));
1416
+ const column = this.toColumn(key);
1417
+ const value = cursorFieldValue(key, written, {
1418
+ column,
1419
+ table: this.table,
1420
+ kind: operandColumnKind(this.getColumnPgType(column)),
1421
+ nullable: nullableColumn(column),
1422
+ });
1423
+ cursorValues.set(column, value);
1424
+ }
1425
+ if (cursorValues.size === 0)
1426
+ return { args, plan: undefined };
1427
+ // The caller's order, plain columns only: a relation, JSON-path or vector
1428
+ // ordering has no value a seek can compare against.
1429
+ const terms = [];
1430
+ const orderEntries = orderByEntries(args.orderBy).filter(([, d]) => d !== undefined);
1431
+ for (const [key, dir] of orderEntries) {
1432
+ if (isVectorOrderBy(dir) || isJsonPathOrderBy(dir) || this.isRelationOrderByValue(dir)) {
1433
+ throw new ValidationError(`findMany on "${this.table}" refused: \`cursor\` cannot page past the orderBy entry "${key}", which ` +
1434
+ 'orders by a relation, a JSON path or a vector distance rather than a column. Order the cursor ' +
1435
+ 'query by columns only, or page it with `offset`.');
1436
+ }
1437
+ const column = this.toColumn(key);
1438
+ if (terms.some((t) => t.column === column))
1439
+ continue;
1440
+ const { dir: d, nulls } = normalizeOrderBy(dir);
1441
+ terms.push({ column, key, desc: d === 'DESC', nulls, nullable: nullableColumn(column) });
1442
+ }
1443
+ // Tiebreaker, the same one a first page gets (see tiebreakColumns).
1444
+ const tiebreak = this.tiebreakColumns(new Set(terms.map((t) => t.column)));
1445
+ if (!tiebreak) {
1446
+ throw new ValidationError(`findMany on "${this.table}" refused: a \`cursor\` page needs an order with no ties, and "${this.table}" ` +
1447
+ 'has no primary key or unique key on NOT NULL columns to break them. Add a primary key, or page ' +
1448
+ 'this table with `offset`.');
1449
+ }
1450
+ const desc = terms.length > 0 ? terms[terms.length - 1].desc : false;
1451
+ for (const column of tiebreak) {
1452
+ terms.push({ column, key: field(column), desc, nulls: undefined, nullable: false });
1453
+ }
1454
+ const appended = tiebreak.length > 0;
1455
+ // Where NULLs sort, per term. Where the engine sorts them high (PostgreSQL)
1456
+ // a nullable column's placement is written out, so a Postgres-compatible
1457
+ // engine with a different default reads the order the seek assumes.
1458
+ const { nullsSortHigh, nullsOrderingClause } = this.dialect.capabilities;
1459
+ const pinNulls = nullsSortHigh && nullsOrderingClause;
1460
+ let pinned = false;
1461
+ for (const t of terms) {
1462
+ if (t.nulls === undefined && t.nullable && pinNulls) {
1463
+ t.nulls = t.desc ? 'first' : 'last';
1464
+ t.pin = true;
1465
+ pinned = true;
1466
+ }
1467
+ }
1468
+ const nullsFirst = (t) => t.nulls !== undefined ? t.nulls === 'first' : nullsSortHigh ? t.desc : !t.desc;
1469
+ let nextArgs = args;
1470
+ if (appended || pinned) {
1471
+ const orderBy = terms.map((t) => {
1472
+ const dir = t.desc ? 'desc' : 'asc';
1473
+ const original = orderEntries.find(([k]) => k === t.key)?.[1];
1474
+ return { [t.key]: t.pin ? { sort: dir, nulls: t.nulls } : (original ?? dir) };
1475
+ });
1476
+ nextArgs = { ...args, orderBy };
1477
+ }
1478
+ const inclusive = isCursorInclusive(args);
1479
+ const coerce = (column, value) => whereMod.coerceWhereOperand(this.ctx, meta, column, value);
1480
+ const keysetTerms = terms.map((t) => ({
1481
+ column: t.column,
1482
+ desc: t.desc,
1483
+ nullsFirst: nullsFirst(t),
1484
+ nullable: t.nullable,
1485
+ }));
1486
+ // An inclusive cursor (prisma-compat) names a ROW, as Prisma's does, so it
1487
+ // always reads that row: a deleted cursor row gives an empty page, never a
1488
+ // page placed where the row used to be.
1489
+ const namesRow = inclusive && whereIdentifiesOneRow(meta, flat);
1490
+ if (!namesRow && terms.every((t) => cursorValues.has(t.column))) {
1491
+ const values = terms.map((t) => {
1492
+ const v = cursorValues.get(t.column);
1493
+ return v === null ? null : coerce(t.column, v);
1494
+ });
1495
+ const fp = `v:${keysetTerms.map((t, i) => `${t.column}:${t.desc ? 'd' : 'a'}${t.nullable ? (t.nullsFirst ? 'f' : 'l') : ''}${values[i] === null ? '0' : ''}`).join(',')}` +
1496
+ (inclusive ? ':i' : '');
1497
+ return {
1498
+ args: nextArgs,
1499
+ plan: { mode: 'values', terms: keysetTerms, values, lookup: [], inclusive, fingerprint: fp },
1500
+ };
1501
+ }
1502
+ if (!whereIdentifiesOneRow(meta, flat)) {
1503
+ const missing = terms.filter((t) => !cursorValues.has(t.column)).map((t) => `"${field(t.column)}"`);
1504
+ const order = terms.map((t) => `${field(t.column)} ${t.desc ? 'desc' : 'asc'}`).join(', ');
1505
+ const keys = uniqueKeyNames(meta).map((cols) => cols.length === 1 ? `{ ${field(cols[0])} }` : `{ ${cols.map(field).join(', ')} }`);
1506
+ throw new ValidationError(`findMany on "${this.table}" refused: the \`cursor\` has no value for ${missing.join(', ')}, which the ` +
1507
+ `page order (${order}) needs, and it does not name one row by a unique key. Give the cursor a value ` +
1508
+ `for every ordered field, or name the cursor row by ${keys.length > 0 ? keys.join(' or ') : 'a unique key'}.`);
1509
+ }
1510
+ const lookup = [...cursorValues].map(([column, v]) => ({ column, value: v === null ? null : coerce(column, v) }));
1511
+ const fp = `r:${keysetTerms.map((t) => `${t.column}:${t.desc ? 'd' : 'a'}${t.nullable ? (t.nullsFirst ? 'f' : 'l') : ''}`).join(',')}` +
1512
+ `|k=${lookup.map((l) => `${l.column}${l.value === null ? '0' : ''}`).join(',')}` +
1513
+ (inclusive ? ':i' : '');
1514
+ return {
1515
+ args: nextArgs,
1516
+ plan: { mode: 'row', terms: keysetTerms, values: [], lookup, inclusive, fingerprint: fp },
1517
+ };
1234
1518
  }
1235
1519
  /**
1236
- * The keyset predicate for cursor terms in keyset order.
1237
- *
1238
- * One field is the plain `col > $1` (byte-identical to every single-field
1239
- * cursor ever emitted). Two or more expand to
1240
- * `(c1 > $1 OR (c1 = $1 AND c2 > $2) OR (c1 = $1 AND c2 = $2 AND c3 > $3))`,
1241
- * with `<` for a `desc` field: the row-value form `(c1, c2) > ($1, $2)` is
1242
- * shorter, but it cannot express a MIXED direction at all and SQL Server has
1243
- * no row-value comparison, so the expanded form is the one path every engine
1244
- * and every direction set share. Each value is bound ONCE and its `$n` is
1245
- * referenced by every branch that needs it (legal on every supported
1246
- * placeholder syntax), which is what keeps the param count equal to the field
1247
- * count on both the build and the collect side.
1520
+ * Render a {@link CursorPlan}'s seek predicate, pushing its params. Run by
1521
+ * the build AND by the cache-hit collect (which discards the SQL), so the two
1522
+ * cannot bind different params.
1248
1523
  *
1249
- * The former `c1 > $1 AND c2 > $2` was a conjunction, not a seek: it skipped
1250
- * every row whose leading key EQUALED the cursor's, so a table with few
1251
- * distinct leading values lost most of its rows across a page walk, silently.
1524
+ * Each value is bound once and referenced by every branch that needs it. One
1525
+ * non-null column is the plain `col > $1`; several expand to
1526
+ * `(c1 > $1 OR (c1 = $1 AND c2 > $2) OR ...)`, with `<` for a descending
1527
+ * column (a row-value comparison cannot mix directions, and SQL Server has
1528
+ * none). A nullable column gets NULL-aware equality and "after" terms.
1252
1529
  */
1253
- keysetPredicate(terms) {
1254
- const compare = (t) => `${t.column} ${t.desc ? '<' : '>'} ${t.placeholder}`;
1255
- if (terms.length === 1)
1256
- return compare(terms[0]);
1257
- const branches = terms.map((term, i) => {
1530
+ renderCursorPredicate(plan, params) {
1531
+ const qt = this.q(this.table);
1532
+ const col = (t) => `${qt}.${this.q(t.column)}`;
1533
+ let ref;
1534
+ let guard = '';
1535
+ if (plan.mode === 'values') {
1536
+ const refs = plan.values.map((v) => {
1537
+ if (v === null)
1538
+ return null;
1539
+ params.push(v);
1540
+ return this.p(params.length);
1541
+ });
1542
+ ref = (i) => refs[i] ?? null;
1543
+ }
1544
+ else {
1545
+ const alias = 'cursor_row';
1546
+ const conds = plan.lookup.map((l) => {
1547
+ if (l.value === null)
1548
+ return `${alias}.${this.q(l.column)} IS NULL`;
1549
+ params.push(l.value);
1550
+ return `${alias}.${this.q(l.column)} = ${this.p(params.length)}`;
1551
+ });
1552
+ const gf = whereMod.targetGlobalFilterAlias(this.ctx, this.table, alias, params);
1553
+ if (gf)
1554
+ conds.push(gf);
1555
+ const from = `FROM ${qt} ${alias} WHERE ${conds.join(' AND ')}`;
1556
+ ref = (i) => `(SELECT ${alias}.${this.q(plan.terms[i].column)} ${from})`;
1557
+ guard = `EXISTS (SELECT 1 ${from})`;
1558
+ }
1559
+ // Value mode knows each value; row mode knows only whether the column can
1560
+ // hold NULL, so its comparisons stay NULL-aware for nullable columns.
1561
+ const rowMode = plan.mode === 'row';
1562
+ const eq = (i) => {
1563
+ const t = plan.terms[i];
1564
+ const r = ref(i);
1565
+ if (r === null)
1566
+ return `${col(t)} IS NULL`;
1567
+ if (rowMode && t.nullable)
1568
+ return `(${col(t)} = ${r} OR (${col(t)} IS NULL AND ${r} IS NULL))`;
1569
+ return `${col(t)} = ${r}`;
1570
+ };
1571
+ const after = (i, orEqual) => {
1572
+ const t = plan.terms[i];
1573
+ const r = ref(i);
1574
+ const op = `${t.desc ? '<' : '>'}${orEqual ? '=' : ''}`;
1575
+ if (r === null)
1576
+ return t.nullsFirst ? `${col(t)} IS NOT NULL` : null;
1577
+ if (!t.nullable)
1578
+ return `${col(t)} ${op} ${r}`;
1579
+ if (rowMode) {
1580
+ return t.nullsFirst
1581
+ ? `((${r} IS NULL AND ${col(t)} IS NOT NULL) OR ${col(t)} ${op} ${r})`
1582
+ : `(${r} IS NOT NULL AND (${col(t)} ${op} ${r} OR ${col(t)} IS NULL))`;
1583
+ }
1584
+ return t.nullsFirst ? `${col(t)} ${op} ${r}` : `(${col(t)} ${op} ${r} OR ${col(t)} IS NULL)`;
1585
+ };
1586
+ const n = plan.terms.length;
1587
+ const last = n - 1;
1588
+ // An inclusive page folds "equal on every column" into the last branch when
1589
+ // that column is NOT NULL and its value known non-null (`>=`); otherwise it
1590
+ // is its own branch.
1591
+ const foldEqual = plan.inclusive && !plan.terms[last].nullable && ref(last) !== null;
1592
+ const branches = [];
1593
+ for (let i = 0; i < n; i++) {
1594
+ const a = after(i, foldEqual && i === last);
1595
+ if (a === null)
1596
+ continue;
1258
1597
  if (i === 0)
1259
- return compare(term);
1260
- const prefix = terms.slice(0, i).map((t) => `${t.column} = ${t.placeholder}`);
1261
- return `(${[...prefix, compare(term)].join(' AND ')})`;
1262
- });
1263
- return `(${branches.join(' OR ')})`;
1598
+ branches.push(a);
1599
+ else
1600
+ branches.push(`(${[...plan.terms.slice(0, i).map((_, j) => eq(j)), a].join(' AND ')})`);
1601
+ }
1602
+ if (plan.inclusive && !foldEqual) {
1603
+ branches.push(n === 1 ? eq(0) : `(${plan.terms.map((_, j) => eq(j)).join(' AND ')})`);
1604
+ }
1605
+ const keyset = branches.length === 0 ? '1 = 0' : branches.length === 1 ? branches[0] : `(${branches.join(' OR ')})`;
1606
+ return guard ? `(${guard} AND ${keyset})` : keyset;
1264
1607
  }
1265
1608
  /**
1266
- * Whether a findMany paginates (`limit` / `take` / `offset` / `cursor`) but
1267
- * declares no ordering, which makes the returned page NON-DETERMINISTIC:
1268
- * Postgres is free to return different rows for the same unordered `LIMIT`
1269
- * once the heap changes underneath it, so a row can appear on two pages or on
1270
- * none.
1609
+ * Whether a findMany paginates (`limit` / `take` / `offset`) but declares no
1610
+ * ordering, which makes the returned page NON-DETERMINISTIC: Postgres is
1611
+ * free to return different rows for the same unordered `LIMIT` once the heap
1612
+ * changes underneath it, so a row can appear on two pages or on none.
1271
1613
  *
1272
- * `cursor` counts, and is the worst case rather than an exception: a keyset
1273
- * seek with no ORDER BY is exactly this bug (`WHERE id > $1 LIMIT $2` walks
1274
- * the heap in whatever order the plan happens to produce). An empty orderBy
1275
- * (`[]`, or an object whose every value is `undefined`) counts as absent,
1276
- * because it emits no ordering.
1614
+ * A `cursor` page never counts: it is always ordered by its effective order
1615
+ * (see {@link planCursor}). An empty orderBy (`[]`, or an object whose every
1616
+ * value is `undefined`) counts as absent, because it emits no ordering.
1277
1617
  *
1278
1618
  * `distinct` is still excluded: that path re-orders in an outer wrapper around
1279
1619
  * a `DISTINCT ON` whose ordering picks the representative row, so an implicit
@@ -1284,11 +1624,11 @@ export class QueryInterface {
1284
1624
  return false;
1285
1625
  if (args.distinct !== undefined)
1286
1626
  return false;
1627
+ if (hasCursor(args.cursor))
1628
+ return false;
1287
1629
  if (!isEmptyOrderBy(args.orderBy))
1288
1630
  return false;
1289
- if (args.limit !== undefined || args.offset !== undefined)
1290
- return true;
1291
- return this.cursorFields(args.cursor).length > 0;
1631
+ return args.limit !== undefined || args.offset !== undefined;
1292
1632
  }
1293
1633
  /**
1294
1634
  * Opt-in (`implicitPkOrdering`) primary-key ascending ordering for a paginating
@@ -1296,47 +1636,42 @@ export class QueryInterface {
1296
1636
  *
1297
1637
  * OFF by default in CORE, deliberately: turning it on would add an `ORDER BY`
1298
1638
  * to SQL that existing applications already emit, changing both the rows a
1299
- * given page returns and the plan the engine picks. That is a breaking change
1300
- * in everything but the type signature, so it waits for a major. The
1639
+ * given page returns and the plan the engine picks. The
1301
1640
  * `turbine-orm/prisma-compat` layer defaults it ON instead, because reproducing
1302
1641
  * Prisma's semantics is that layer's whole contract.
1303
1642
  *
1304
1643
  * An explicit `orderBy` always wins, a PK-less table is left alone (nothing
1305
1644
  * stable to order by), and a composite PK orders on every column in
1306
- * declaration order. A `cursor` query orders on the CURSOR's field instead
1307
- * (see {@link cursorOrderBy}), and is left alone when that shape is ambiguous.
1645
+ * declaration order. A `cursor` page is ordered by its own plan, not here, and
1308
1646
  * `distinct` shapes are skipped (see {@link isUnorderedPage}). With the flag
1309
- * off this returns `undefined` before touching anything, so the emitted SQL is
1310
- * byte-identical to before.
1647
+ * off this returns `undefined` before touching anything.
1311
1648
  */
1312
1649
  implicitPkOrderBy(args) {
1313
1650
  if (!this.implicitPkOrdering)
1314
1651
  return undefined;
1315
1652
  if (!this.isUnorderedPage(args))
1316
1653
  return undefined;
1317
- if (this.cursorFields(args?.cursor).length > 0)
1318
- return this.cursorOrderBy(args?.cursor);
1319
1654
  return this.pkOrderBy();
1320
1655
  }
1321
1656
  /**
1322
- * Dev-only, once per query shape: an unordered paginating findMany returns a
1657
+ * Advice (`diagnostics.advice`), once per query shape: an unordered paginating findMany returns a
1323
1658
  * non-deterministic page (see {@link isUnorderedPage}), and on real data it is
1324
1659
  * also usually the slower plan (an unordered `LIMIT` can discard tens of
1325
1660
  * thousands of heap rows that an index scan on the key would have skipped).
1326
1661
  *
1327
- * Gated exactly like the other dev diagnostics (silent under
1328
- * `NODE_ENV=production`) and consistent with `warnOnUnlimited`: a per-call
1662
+ * Gated exactly like the other advice (silent when `diagnostics.advice` is
1663
+ * off) and consistent with `warnOnUnlimited`: a per-call
1329
1664
  * `warnOnUnlimited: false` silences it, `true` forces it past a config-level
1330
1665
  * opt-out, and a config/per-table `warnOnUnlimited: false` silences it. Deduped
1331
1666
  * process-wide through the shared warn registry, so it can never spam.
1332
1667
  *
1333
1668
  * Suppressed only when `implicitPkOrdering` will ACTUALLY order this query.
1334
- * The flag being on is not enough: a PK-less table and an ambiguous
1335
- * multi-field cursor both get no injected ordering, and those are precisely
1336
- * the shapes that still need saying out loud.
1669
+ * The flag being on is not enough: a PK-less table gets no injected
1670
+ * ordering, and that is precisely the shape that still needs saying out loud.
1337
1671
  */
1338
1672
  maybeWarnUnorderedPage(args) {
1339
- if (process.env.NODE_ENV === 'production')
1673
+ const log = this.advice;
1674
+ if (!log)
1340
1675
  return;
1341
1676
  if (this.implicitPkOrdering && this.implicitPkOrderBy(args) !== undefined)
1342
1677
  return;
@@ -1347,30 +1682,18 @@ export class QueryInterface {
1347
1682
  return;
1348
1683
  if (!this.isUnorderedPage(args))
1349
1684
  return;
1350
- const cursorFields = this.cursorFields(args?.cursor);
1351
- const shape = [
1352
- cursorFields.length > 0 ? 'cursor' : '',
1353
- args?.limit !== undefined ? 'limit' : '',
1354
- args?.offset !== undefined ? 'offset' : '',
1355
- ]
1685
+ const shape = [args?.limit !== undefined ? 'limit' : '', args?.offset !== undefined ? 'offset' : '']
1356
1686
  .filter(Boolean)
1357
1687
  .join('+');
1358
1688
  if (!shouldWarnOnce(WARN_NS.unorderedPage, `${this.table}|${shape}`))
1359
1689
  return;
1360
- const asOrderBy = (fields) => fields.length === 1 ? `{ ${fields[0]}: 'asc' }` : `[${fields.map((f) => `{ ${f}: 'asc' }`).join(', ')}]`;
1361
1690
  const pk = (this.tableMeta.primaryKey ?? []).map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
1362
- // A cursor seeks on its own field, so that is the ordering to recommend:
1363
- // the primary key would be the wrong advice whenever the two differ.
1364
- const orderFields = cursorFields.length > 0 ? cursorFields : pk;
1365
- const suggestion = orderFields.length > 0 ? `Add \`orderBy: ${asOrderBy(orderFields)}\`` : 'Add an `orderBy` on a unique column';
1366
- const seek = cursorFields.length > 0
1367
- ? 'a `cursor` seek with no orderBy compares against rows the engine is free to return in any order, so ' +
1368
- 'paging with it skips and repeats rows: '
1369
- : '';
1370
- console.warn(`[turbine] findMany on "${this.table}" paginates (${shape}) with no orderBy: ${seek}the page is NOT ` +
1691
+ const asOrderBy = pk.length === 1 ? `{ ${pk[0]}: 'asc' }` : `[${pk.map((f) => `{ ${f}: 'asc' }`).join(', ')}]`;
1692
+ const suggestion = pk.length > 0 ? `Add \`orderBy: ${asOrderBy}\`` : 'Add an `orderBy` on a unique column';
1693
+ logTo(log, 'warn', `findMany on "${this.table}" paginates (${shape}) with no orderBy: the page is NOT ` +
1371
1694
  'deterministic (the same query can return different rows as the table changes, so a row may appear ' +
1372
1695
  `on two pages or on none). ${suggestion}, or set \`implicitPkOrdering: true\` in the client config ` +
1373
- 'to order paginated queries automatically (by the cursor field, or by the primary key).');
1696
+ 'to order paginated queries by the primary key automatically.', { code: 'unordered-page', table: this.table, operation: 'findMany', paging: shape });
1374
1697
  }
1375
1698
  // -------------------------------------------------------------------------
1376
1699
  // relationLoadStrategy: 'auto', per-relation batched fallback when the
@@ -1597,7 +1920,7 @@ export class QueryInterface {
1597
1920
  return split;
1598
1921
  }
1599
1922
  /**
1600
- * Dev-only once-per-relation note that `'auto'` engaged the batched fallback.
1923
+ * Advice, once per relation: `'auto'` engaged the batched fallback.
1601
1924
  *
1602
1925
  * Both lines follow the same four parts: the CONDITION that tripped the rule,
1603
1926
  * the MECHANISM (which plan shape was replaced by which, and what that costs),
@@ -1606,20 +1929,21 @@ export class QueryInterface {
1606
1929
  * as a bug, so the mechanism sentence is not optional.
1607
1930
  */
1608
1931
  emitAutoNotes(engaged) {
1609
- if (process.env.NODE_ENV === 'production')
1932
+ const log = this.advice;
1933
+ if (!log)
1610
1934
  return;
1611
1935
  for (const e of engaged) {
1612
1936
  if (!shouldWarnOnce(WARN_NS.autoStrategy, `${this.table}.${e.relation}`))
1613
1937
  continue;
1614
1938
  if (e.reason === 'to-one-cardinality') {
1615
- console.warn(`[turbine] auto strategy: to-one relation "${e.relation}" on "${this.table}" loads batched ` +
1939
+ logTo(log, 'warn', `auto strategy: to-one relation "${e.relation}" on "${this.table}" loads batched ` +
1616
1940
  `(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows). On the join plan ` +
1617
1941
  'a to-one relation is a correlated subquery the engine re-evaluates once per parent row, a per-row ' +
1618
1942
  'cost that stands even on a unique index; batched replaces it with ONE follow-up statement ' +
1619
1943
  '(`key = ANY(...)` for the whole page), so it trades that per-row cost for a single extra round ' +
1620
1944
  'trip. Bound the query with a smaller `limit`, tune `autoToOneJoinMaxRows` (the break-even is ' +
1621
1945
  "round-trip time / per-row cost), or set `relationLoadStrategy: 'join'` to force the " +
1622
- 'single-statement plan.');
1946
+ 'single-statement plan.', { code: 'auto-strategy', table: this.table, relation: e.relation, reason: e.reason });
1623
1947
  continue;
1624
1948
  }
1625
1949
  const probe = e.miss
@@ -1637,9 +1961,9 @@ export class QueryInterface {
1637
1961
  : ' On the join plan that relation is a correlated subquery re-evaluated once per parent row, so an ' +
1638
1962
  `unindexed probe is one full scan of "${child}" per parent row; the batched follow-up scans it ` +
1639
1963
  'once for the whole page.';
1640
- console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}).${why} ` +
1964
+ logTo(log, 'warn', `auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}).${why} ` +
1641
1965
  "Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
1642
- 'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
1966
+ 'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.', { code: 'auto-strategy', table: this.table, relation: e.relation, reason: e.reason });
1643
1967
  }
1644
1968
  }
1645
1969
  /**
@@ -1730,16 +2054,15 @@ export class QueryInterface {
1730
2054
  // a closure rather than passing `this.dialect` so the loader keeps
1731
2055
  // needing nothing else from the dialect.
1732
2056
  //
1733
- // The `dialect.name` test is NOT redundant with the hook's presence, and
1734
- // this is the same trap the `distinct` gate documents: every engine
1735
- // dialect is built by SPREADING `postgresDialect`, so an optional hook
1736
- // added there is INHERITED by sqlite / mysql / mssql, whose "absent"
1737
- // fallback would then never be taken. The wrapper is not portable as
1738
- // written either (T-SQL rejects an ORDER BY inside a derived table
1739
- // without TOP/OFFSET, and the compiled child query carries one), so the
1740
- // pushdown stays with the dialect that owns the implementation until
1741
- // another engine ships its own and asserts it.
1742
- buildPartitionLimit: this.dialect.name === 'postgresql' && this.dialect.buildPartitionLimit
2057
+ // The capability is NOT redundant with the hook's presence: every engine
2058
+ // dialect is built by SPREADING `postgresDialect`, so the hook is
2059
+ // INHERITED by sqlite / mysql / mssql, whose "absent" fallback would then
2060
+ // never be taken. The wrapper is not portable as written either (T-SQL
2061
+ // rejects an ORDER BY inside a derived table without TOP/OFFSET, and the
2062
+ // compiled child query carries one), so the pushdown stays with the
2063
+ // dialect that owns the implementation until another engine ships its own
2064
+ // and asserts it.
2065
+ buildPartitionLimit: this.dialect.capabilities.partitionLimitPushdown && this.dialect.buildPartitionLimit
1743
2066
  ? (input) => this.dialect.buildPartitionLimit(input)
1744
2067
  : undefined,
1745
2068
  skipGlobalFilters: skip,
@@ -1878,15 +2201,14 @@ export class QueryInterface {
1878
2201
  *
1879
2202
  * When `sqlCache` is disabled, always calls `build()` without caching.
1880
2203
  *
1881
- * `build` receives a fresh `$N` param scratch array. On a miss those params
1882
- * are discarded (the returned params come from each call site's dedicated
1883
- * collect path); the array exists so the build path can number placeholders
1884
- * via `params.length` exactly as it does today. On a HIT, `build` is skipped
1885
- * here but re-run by {@link crossCheckCache} (dev only) with a fresh array to
1886
- * verify the collect path stayed in lockstep with the build path.
2204
+ * `build` receives a fresh `$N` param scratch array. The executed params
2205
+ * always come from each call site's dedicated collect path; on a miss the
2206
+ * scratch params are kept in {@link lastMissParams} so `crossCheckCache` can
2207
+ * compare the two. On a HIT, `build` is skipped here and re-run by
2208
+ * {@link crossCheckCache} only when `diagnostics.cacheCheck` asks for it.
1887
2209
  *
1888
- * Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows whether a
1889
- * cross-check is warranted.
2210
+ * Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows which
2211
+ * comparison applies.
1890
2212
  */
1891
2213
  acquireSql(cacheKey, build) {
1892
2214
  if (!this.sqlCacheEnabled) {
@@ -1897,6 +2219,7 @@ export class QueryInterface {
1897
2219
  if (cached) {
1898
2220
  this.cacheHits++;
1899
2221
  this.lastCacheHit = true;
2222
+ this.lastMissParams = undefined;
1900
2223
  return cached;
1901
2224
  }
1902
2225
  this.lastCacheHit = false;
@@ -1926,40 +2249,68 @@ export class QueryInterface {
1926
2249
  */
1927
2250
  buildCacheEntry(build) {
1928
2251
  this.variableArityShape = false;
1929
- const sql = build([]);
2252
+ this.lastMissParams = undefined;
2253
+ const built = [];
2254
+ const sql = build(built);
1930
2255
  const variableArity = this.variableArityShape;
1931
2256
  this.variableArityShape = false;
1932
2257
  this.cacheMisses++;
1933
- return { sql, name: variableArity ? '' : sqlToPreparedName(sql) };
2258
+ this.lastMissParams = built;
2259
+ // With `sqlComment` putting a comment on every statement, the template's own
2260
+ // name is never sent (the execute seam sends a `c_` name derived from it),
2261
+ // so it is not claimed from the budget: only names that reach the wire do.
2262
+ const name = variableArity
2263
+ ? ''
2264
+ : this.sqlCommenter?.alwaysComments
2265
+ ? sqlToPreparedName(sql)
2266
+ : boundedPreparedName(sql);
2267
+ return { sql, name, paramCount: built.length };
1934
2268
  }
1935
2269
  /**
1936
- * Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
2270
+ * The SQL-cache lockstep checks (see the comment above `CACHE_MISMATCH_WARN_NS`).
1937
2271
  *
1938
- * Runs only when the most recent {@link acquireSql} was a cache HIT and the
1939
- * check is enabled. Rebuilds the SQL + `$N` params fresh via the same `build`
1940
- * closure the caller passed to `acquireSql`, then compares:
2272
+ * On a MISS: the collected params must equal the params the build pushed.
2273
+ * On a HIT: the collected param count must equal the entry's, always; then,
2274
+ * when `diagnostics.cacheCheck` selects this hit, the SQL and params are
2275
+ * rebuilt fresh through the same closure and compared:
1941
2276
  * (a) the cached SQL string byte-for-byte against the fresh SQL, and
1942
- * (b) the params the cache-hit collect path produced against the fresh
1943
- * build-path params (length and element-wise strict deep-equal).
2277
+ * (b) the collected params against the fresh build-path params (length
2278
+ * and element-wise strict deep-equal).
1944
2279
  *
1945
2280
  * A mismatch means the fingerprint / build / collect paths have drifted out
1946
2281
  * of lockstep (the exact class of bug that has silently corrupted results
1947
- * before), so it throws a {@link ValidationError} (E003) naming the
1948
- * fingerprint, the operation, and both SQL strings (truncated). Failing loud
1949
- * in dev/test is the point. Production never reaches the comparison.
2282
+ * before), so it throws an {@link InvariantError} (E020) naming the
2283
+ * fingerprint, the operation, and both SQL strings (truncated), never a
2284
+ * parameter value.
1950
2285
  *
1951
2286
  * @param op human label of the calling build method (for the error message).
1952
- * @param cacheKey the cache fingerprint that HIT.
1953
- * @param entry the cached SQL entry that will be executed.
2287
+ * @param cacheKey the cache fingerprint.
2288
+ * @param entry the SQL entry that will be executed.
1954
2289
  * @param build the same closure passed to `acquireSql`; re-run here to
1955
2290
  * capture the fresh build-path SQL + params.
1956
2291
  * @param collectedParams the params the caller's collect path produced.
1957
2292
  */
1958
2293
  crossCheckCache(op, cacheKey, entry, build, collectedParams) {
1959
- if (!this.lastCacheHit)
2294
+ if (!this.lastCacheHit) {
2295
+ const built = this.lastMissParams;
2296
+ this.lastMissParams = undefined;
2297
+ if (built === undefined || cacheParamsEqual(collectedParams, built))
2298
+ return;
2299
+ throw this.lockstepError(op, cacheKey, [
2300
+ `built params and collected params diverge on a cache miss (collected ${collectedParams.length}, built ${built.length})`,
2301
+ ], false);
2302
+ }
2303
+ if (entry.paramCount !== undefined && collectedParams.length !== entry.paramCount) {
2304
+ throw new InvariantError('sql-cache-param-count', `SQL cache param count mismatch on ${op} (fingerprint "${cacheKey}"): the cached statement has ` +
2305
+ `${entry.paramCount} placeholders but the cache hit collected ${collectedParams.length} params, so ` +
2306
+ 'the statement was not sent.');
2307
+ }
2308
+ const check = this.diagnostics.cacheCheck;
2309
+ if (check === 'off')
1960
2310
  return;
1961
- const mode = cacheCrossCheckMode();
1962
- if (mode === 'off')
2311
+ const sampled = typeof check === 'object';
2312
+ // The sampling decision, and only it, is per hit.
2313
+ if (sampled && check.sample < 1 && Math.random() >= check.sample)
1963
2314
  return;
1964
2315
  const freshParams = [];
1965
2316
  const freshSql = build(freshParams);
@@ -1975,34 +2326,39 @@ export class QueryInterface {
1975
2326
  if (!paramsOk) {
1976
2327
  details.push(`cache-hit params and freshly-built params diverge (collected ${collectedParams.length}, built ${freshParams.length})`);
1977
2328
  }
1978
- const message = `SQL cache lockstep violation on ${op} (fingerprint "${cacheKey}"). ` +
1979
- `This is a Turbine internal invariant violation, please report it at ` +
1980
- `https://github.com/zvndev/turbine-orm/issues. The fingerprint, SQL-build, and ` +
1981
- `param-collect paths must enumerate where-clause keys identically.\n${details.join('\n')}`;
1982
- // Sampled production path: log once per distinct fingerprint so the failure
1983
- // is durably recorded in the server log before the throw unwinds the query.
1984
- // (Dev mode throws straight away; its behavior is unchanged.)
1985
- if (mode === 'sampled' && !loggedCacheMismatchFingerprints.has(cacheKey)) {
1986
- loggedCacheMismatchFingerprints.add(cacheKey);
1987
- console.error(message);
2329
+ throw this.lockstepError(op, cacheKey, details, sampled);
2330
+ }
2331
+ /**
2332
+ * The `sql-cache-lockstep` {@link InvariantError}. When `log` is set (the
2333
+ * sampled hit path) the failure is also logged once per table and shape, so
2334
+ * it is durably recorded in the server log before the throw unwinds the
2335
+ * query; the other paths throw straight away.
2336
+ */
2337
+ lockstepError(op, cacheKey, details, log) {
2338
+ const err = new InvariantError('sql-cache-lockstep', `SQL cache lockstep violation on ${op} (fingerprint "${cacheKey}"). The fingerprint, SQL-build, and ` +
2339
+ `param-collect paths must enumerate where-clause keys identically. ${INVARIANT_REPORT_HINT}\n` +
2340
+ details.join('\n'));
2341
+ if (log && shouldWarnOnce(CACHE_MISMATCH_WARN_NS, `${this.table}|${cacheKey}`)) {
2342
+ logTo(this.logger, 'error', err.message, { code: 'cache-mismatch', table: this.table, operation: op });
1988
2343
  }
1989
- throw new ValidationError(message);
2344
+ return err;
1990
2345
  }
1991
2346
  /**
1992
- * Reset the per-instance unlimited-query warning dedupe set.
1993
- * Exposed for tests so a single test process can verify the warning fires
1994
- * exactly once per table without bleeding state between assertions.
2347
+ * Reset the unlimited-query warning dedupe, which is process-wide (see
2348
+ * {@link maybeWarnUnlimited}). Exposed for tests so a single test process can
2349
+ * verify the warning fires once per table without bleeding state between
2350
+ * assertions.
1995
2351
  */
1996
2352
  resetUnlimitedWarnings() {
1997
- this.warnedTables.clear();
2353
+ resetWarnOnce(WARN_NS.unlimitedFindMany);
1998
2354
  }
1999
- emitQueryEvent(sql, params, duration, action, rows, error, retried) {
2355
+ emitQueryEvent(out, params, duration, action, rows, error, retried) {
2000
2356
  const onQuery = this.options?._onQuery;
2001
2357
  if (!onQuery)
2002
2358
  return;
2003
2359
  try {
2004
2360
  onQuery({
2005
- sql,
2361
+ sql: out.sql,
2006
2362
  params,
2007
2363
  duration,
2008
2364
  model: this.table,
@@ -2012,12 +2368,28 @@ export class QueryInterface {
2012
2368
  error,
2013
2369
  strategy: this.currentStrategyTag,
2014
2370
  ...(retried ? { retried: true } : {}),
2371
+ ...(out.comment === undefined ? {} : { comment: out.comment }),
2015
2372
  });
2016
2373
  }
2017
2374
  catch {
2018
2375
  // Listener errors must never crash a query
2019
2376
  }
2020
2377
  }
2378
+ /**
2379
+ * The statement `sql` as it goes out under `preparedName`: with the
2380
+ * `sqlComment` appended, and under the name that comment calls for (see
2381
+ * query/sql-comment.ts). This is the one place a comment is added, at the
2382
+ * execute seam and after the SQL-template cache, so the cache, its keys and
2383
+ * its lockstep checks only ever see commentless text. Without `sqlComment`
2384
+ * it is the statement unchanged.
2385
+ */
2386
+ outgoing(sql, preparedName, action) {
2387
+ const commenter = this.sqlCommenter;
2388
+ const c = commenter?.commentFor(this.table, action);
2389
+ if (commenter === undefined || c === undefined)
2390
+ return { sql, text: sql, name: preparedName, comment: undefined };
2391
+ return { sql, text: `${sql} ${c.comment}`, name: commenter.preparedName(preparedName, c), comment: c.comment };
2392
+ }
2021
2393
  /**
2022
2394
  * Resolve the prepared-statement name a read should execute under, honouring
2023
2395
  * the per-query {@link FindManyArgs.forceCustomPlan} opt-in.
@@ -2108,13 +2480,31 @@ export class QueryInterface {
2108
2480
  }
2109
2481
  /**
2110
2482
  * Execute a pool.query with an optional timeout.
2111
- * If timeout is set, races the query against a timer and rejects on expiry.
2483
+ *
2484
+ * On a node-postgres pool outside a transaction the timeout is enforced on
2485
+ * the SERVER ({@link queryCancellable}). Anywhere else (a transaction's
2486
+ * connection, an engine shim, an HTTP pool) it races the query against a
2487
+ * timer: the call rejects on time but the statement keeps running, so the
2488
+ * error's `outcome` is `'unknown'`.
2489
+ *
2112
2490
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
2113
2491
  * `retryIfStale` is {@link readWithTimeout}'s; nothing else sets it.
2114
2492
  */
2115
- async queryWithTimeout(sql, params, timeout, preparedName, retryIfStale = false) {
2116
- let start = performance.now();
2493
+ async queryWithTimeout(sql, params, requestedTimeout, preparedName, retryIfStale = false, pool = this.pool) {
2117
2494
  const action = this.currentAction;
2495
+ const out = this.outgoing(sql, preparedName, action);
2496
+ // 0 is no timeout, as it is for `$transaction`'s option and the server's statement_timeout.
2497
+ const timeout = requestedTimeout === 0 ? undefined : requestedTimeout;
2498
+ if (timeout !== undefined && pool === this.pool && this.cancelsOnTimeout()) {
2499
+ return this.queryCancellable(out, params, timeout, retryIfStale, action);
2500
+ }
2501
+ if (timeout !== undefined && pool === this.pool && this.txScoped && this.dialect.capabilities.backendCancel) {
2502
+ const state = transactionStateOf(this.pool);
2503
+ if (state !== undefined && canCancelBackend(state.client)) {
2504
+ return this.queryCancellableInTransaction(out, params, timeout, state, action);
2505
+ }
2506
+ }
2507
+ let start = performance.now();
2118
2508
  // Build the query argument, use object form with `name` for prepared
2119
2509
  // statements, or the plain (text, values) form otherwise.
2120
2510
  //
@@ -2122,19 +2512,19 @@ export class QueryInterface {
2122
2512
  // pool shims only speak `(text, values)`), so it is reached through a
2123
2513
  // cast. Guarded at runtime by `preparedStatementsEnabled`, which only the
2124
2514
  // drivers that accept it turn on.
2125
- const usePrepared = preparedName && this.preparedStatementsEnabled;
2515
+ const usePrepared = out.name && this.preparedStatementsEnabled;
2126
2516
  const send = () => usePrepared
2127
- ? this.pool.query({
2128
- name: preparedName,
2129
- text: sql,
2517
+ ? pool.query({
2518
+ name: out.name,
2519
+ text: out.text,
2130
2520
  values: params,
2131
2521
  })
2132
- : this.pool.query(sql, params);
2522
+ : pool.query(out.text, params);
2133
2523
  let exec = send();
2134
2524
  // Set when the caller's timeout has already answered, so a first attempt
2135
2525
  // that fails AFTER that does not send a retry nobody is waiting for.
2136
2526
  let abandoned = false;
2137
- if (retryIfStale && !this.txScoped) {
2527
+ if (retryIfStale && !this.txScoped && pool === this.pool) {
2138
2528
  exec = exec.catch(async (err) => {
2139
2529
  if (abandoned || !isStaleConnectionError(err))
2140
2530
  throw err;
@@ -2142,7 +2532,7 @@ export class QueryInterface {
2142
2532
  // so a listener counting errors sees it and one alerting on failed
2143
2533
  // CALLS can skip it. The final event below times the retry alone.
2144
2534
  const wrapped = wrapPgError(err);
2145
- this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined, true);
2535
+ this.emitQueryEvent(out, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined, true);
2146
2536
  await settleEventLoop();
2147
2537
  if (abandoned)
2148
2538
  throw err;
@@ -2153,12 +2543,12 @@ export class QueryInterface {
2153
2543
  if (!timeout) {
2154
2544
  try {
2155
2545
  const result = await exec;
2156
- this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
2546
+ this.emitQueryEvent(out, params, performance.now() - start, action, result.rowCount ?? 0);
2157
2547
  return result;
2158
2548
  }
2159
2549
  catch (err) {
2160
2550
  const wrapped = wrapPgError(err);
2161
- this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
2551
+ this.emitQueryEvent(out, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
2162
2552
  throw wrapped;
2163
2553
  }
2164
2554
  }
@@ -2166,23 +2556,240 @@ export class QueryInterface {
2166
2556
  const timeoutPromise = new Promise((_, reject) => {
2167
2557
  timer = setTimeout(() => {
2168
2558
  abandoned = true;
2169
- reject(new TimeoutError(timeout));
2559
+ reject(new TimeoutError(timeout, 'Query', {
2560
+ outcome: 'unknown',
2561
+ message: `Query timed out after ${timeout}ms. It could not be cancelled on the server and may still ` +
2562
+ 'complete, so a write may have been applied: check before retrying it.',
2563
+ }));
2170
2564
  }, timeout);
2171
2565
  });
2172
2566
  try {
2173
2567
  const result = await Promise.race([exec, timeoutPromise]);
2174
- this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
2568
+ this.emitQueryEvent(out, params, performance.now() - start, action, result.rowCount ?? 0);
2175
2569
  return result;
2176
2570
  }
2177
2571
  catch (err) {
2178
2572
  const wrapped = wrapPgError(err);
2179
- this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
2573
+ this.emitQueryEvent(out, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
2180
2574
  throw wrapped;
2181
2575
  }
2182
2576
  finally {
2183
2577
  clearTimeout(timer);
2184
2578
  }
2185
2579
  }
2580
+ /**
2581
+ * Whether a timed statement can be stopped on the server: an engine with a
2582
+ * protocol cancel (PostgreSQL) on a node-postgres pool, outside a transaction
2583
+ * (inside one, the connection belongs to the transaction and is not this
2584
+ * method's to cancel or destroy).
2585
+ */
2586
+ cancelsOnTimeout() {
2587
+ return !this.txScoped && this.dialect.capabilities.backendCancel && isNodePostgresPool(this.pool);
2588
+ }
2589
+ /**
2590
+ * {@link queryWithTimeout} with a timeout the server enforces. The statement
2591
+ * runs on a connection checked out for it, so when the deadline passes the
2592
+ * backend running it is known and gets a protocol cancel request.
2593
+ *
2594
+ * The call then waits, at most {@link CANCEL_GRACE_MS}, for the statement to
2595
+ * answer, and the answer decides the result:
2596
+ * - cancelled (57014): TimeoutError, `outcome: 'not-executed'`;
2597
+ * - finished first: the result, returned late, because reporting a write
2598
+ * that committed as a timeout invites a retry that applies it twice;
2599
+ * - failed: that error;
2600
+ * - no answer: TimeoutError, `outcome: 'unknown'`, and the connection is
2601
+ * destroyed rather than handed to the next caller still busy.
2602
+ * A deadline that passes while still waiting for a connection sends nothing
2603
+ * (`'not-executed'`). Either way the connection goes back to the pool only
2604
+ * once its backend is idle, so timed-out statements cannot hold the pool.
2605
+ */
2606
+ async queryCancellable(out, params, timeout, retryIfStale, action) {
2607
+ let start = performance.now();
2608
+ const config = out.name && this.preparedStatementsEnabled ? { name: out.name, text: out.text, values: params } : null;
2609
+ const report = (err, retried) => {
2610
+ const wrapped = err instanceof TimeoutError ? err : wrapPgError(err);
2611
+ const error = wrapped instanceof Error ? wrapped : undefined;
2612
+ this.emitQueryEvent(out, params, performance.now() - start, action, 0, error, retried);
2613
+ return wrapped;
2614
+ };
2615
+ let expired = false;
2616
+ let expire = () => { };
2617
+ const deadline = new Promise((resolve) => {
2618
+ expire = resolve;
2619
+ });
2620
+ const timer = setTimeout(() => {
2621
+ expired = true;
2622
+ expire(TIMED_OUT);
2623
+ }, timeout);
2624
+ const neverSent = () => new TimeoutError(timeout, 'Query', {
2625
+ outcome: 'not-executed',
2626
+ message: `Query timed out after ${timeout}ms before it reached the server. It made no change.`,
2627
+ });
2628
+ try {
2629
+ for (let attempt = 0;; attempt++) {
2630
+ const checkingOut = acquireConnection(this.pool);
2631
+ let held;
2632
+ try {
2633
+ held = await Promise.race([checkingOut, deadline]);
2634
+ }
2635
+ catch (err) {
2636
+ throw report(err);
2637
+ }
2638
+ if (held === TIMED_OUT) {
2639
+ checkingOut.then((late) => late.checkout.release(), () => { });
2640
+ throw report(neverSent());
2641
+ }
2642
+ const { client, checkout } = held;
2643
+ const running = (config
2644
+ ? client.query(config)
2645
+ : client.query(out.text, params)).then((result) => ({ result }), (error) => ({ error }));
2646
+ const first = await Promise.race([running, deadline]);
2647
+ const settled = first === TIMED_OUT ? await this.cancelTimedOut(held, running, timeout) : first;
2648
+ if ('result' in settled) {
2649
+ if (first !== TIMED_OUT)
2650
+ checkout.release();
2651
+ this.emitQueryEvent(out, params, performance.now() - start, action, settled.result.rowCount ?? 0);
2652
+ return settled.result;
2653
+ }
2654
+ if ('timeout' in settled)
2655
+ throw report(settled.timeout);
2656
+ const err = settled.error;
2657
+ if (first === TIMED_OUT)
2658
+ throw report(err);
2659
+ checkout.release(err instanceof Error ? err : true);
2660
+ if (!(retryIfStale && attempt === 0 && isStaleConnectionError(err)))
2661
+ throw report(err);
2662
+ report(err, true);
2663
+ await settleEventLoop();
2664
+ if (expired)
2665
+ throw report(neverSent());
2666
+ start = performance.now();
2667
+ }
2668
+ }
2669
+ finally {
2670
+ clearTimeout(timer);
2671
+ }
2672
+ }
2673
+ /**
2674
+ * A timed statement inside a transaction, cancelled on the server when the
2675
+ * deadline passes, on the transaction's own connection.
2676
+ *
2677
+ * A cancelled statement made no change, but PostgreSQL aborts the
2678
+ * transaction it ran in, so the timeout ends the transaction too: it is
2679
+ * marked to roll back ({@link TransactionState.abort}), which also covers a
2680
+ * cancel that arrived after the statement finished. The error is
2681
+ * `outcome: 'not-executed'` either way, because nothing it did can commit.
2682
+ * A statement that finishes before the deadline is returned as usual.
2683
+ */
2684
+ async queryCancellableInTransaction(out, params, timeout, state, action) {
2685
+ const start = performance.now();
2686
+ const running = (out.name && this.preparedStatementsEnabled
2687
+ ? this.pool.query({
2688
+ name: out.name,
2689
+ text: out.text,
2690
+ values: params,
2691
+ })
2692
+ : this.pool.query(out.text, params)).then((result) => ({ result }), (error) => ({ error }));
2693
+ let timer;
2694
+ const deadline = new Promise((resolve) => {
2695
+ timer = setTimeout(() => resolve(TIMED_OUT), timeout);
2696
+ });
2697
+ let settled;
2698
+ try {
2699
+ const first = await Promise.race([running, deadline]);
2700
+ settled = first === TIMED_OUT ? await this.cancelInTransaction(state, running, timeout) : first;
2701
+ }
2702
+ finally {
2703
+ clearTimeout(timer);
2704
+ }
2705
+ if ('result' in settled) {
2706
+ this.emitQueryEvent(out, params, performance.now() - start, action, settled.result.rowCount ?? 0);
2707
+ return settled.result;
2708
+ }
2709
+ const err = 'timeout' in settled ? settled.timeout : wrapPgError(settled.error);
2710
+ this.emitQueryEvent(out, params, performance.now() - start, action, 0, err instanceof Error ? err : undefined);
2711
+ throw err;
2712
+ }
2713
+ /** Cancel a timed-out statement in a transaction and end the transaction. See {@link queryCancellableInTransaction}. */
2714
+ async cancelInTransaction(state, running, timeout) {
2715
+ let graceTimer;
2716
+ const graceOver = new Promise((resolve) => {
2717
+ graceTimer = setTimeout(() => resolve(TIMED_OUT), CANCEL_GRACE_MS);
2718
+ });
2719
+ try {
2720
+ await Promise.race([sendCancelRequest(state.client, CANCEL_GRACE_MS), graceOver]);
2721
+ const answer = await Promise.race([running, graceOver]);
2722
+ if (answer !== TIMED_OUT && 'result' in answer)
2723
+ return answer;
2724
+ const cancelled = answer !== TIMED_OUT && isQueryCanceled(answer.error);
2725
+ if (answer !== TIMED_OUT && !cancelled)
2726
+ return answer;
2727
+ const error = new TimeoutError(timeout, 'Query', {
2728
+ outcome: 'not-executed',
2729
+ ...(cancelled ? { cause: answer.error } : {}),
2730
+ message: (cancelled
2731
+ ? `Query timed out after ${timeout}ms and was cancelled on the server, so it made no change. `
2732
+ : `Query timed out after ${timeout}ms, and the server did not confirm its cancellation within ` +
2733
+ `${CANCEL_GRACE_MS}ms. `) +
2734
+ 'The transaction it ran in is over: it rolls back instead of committing, and no statement in it is ' +
2735
+ 'kept. Let this error leave the transaction callback and retry the whole transaction, or run a ' +
2736
+ 'statement that may time out in a nested `tx.$transaction(...)`, which rolls back only that block.',
2737
+ });
2738
+ state.abort(error);
2739
+ return { timeout: error };
2740
+ }
2741
+ finally {
2742
+ clearTimeout(graceTimer);
2743
+ }
2744
+ }
2745
+ /**
2746
+ * Cancel a statement that outlived its timeout, wait for it to answer, and
2747
+ * release its connection accordingly. See {@link queryCancellable}.
2748
+ */
2749
+ async cancelTimedOut(held, running, timeout) {
2750
+ let graceTimer;
2751
+ const graceOver = new Promise((resolve) => {
2752
+ graceTimer = setTimeout(() => resolve(TIMED_OUT), CANCEL_GRACE_MS);
2753
+ });
2754
+ try {
2755
+ const delivered = await Promise.race([sendCancelRequest(held.client, CANCEL_GRACE_MS), graceOver]);
2756
+ const answer = await Promise.race([running, graceOver]);
2757
+ if (answer === TIMED_OUT) {
2758
+ held.checkout.release(new Error('the statement did not stop after a cancel request'));
2759
+ return {
2760
+ timeout: new TimeoutError(timeout, 'Query', {
2761
+ outcome: 'unknown',
2762
+ message: `Query timed out after ${timeout}ms and the server did not confirm its cancellation within ` +
2763
+ `${CANCEL_GRACE_MS}ms, so it may still complete and a write may have been applied: check before ` +
2764
+ 'retrying it. The connection was closed.',
2765
+ }),
2766
+ };
2767
+ }
2768
+ // A cancel that was sent but not confirmed could still land on the NEXT
2769
+ // statement this connection runs, so such a connection is not reused.
2770
+ const reusable = delivered === true;
2771
+ if ('result' in answer) {
2772
+ held.checkout.release(reusable ? undefined : true);
2773
+ return answer;
2774
+ }
2775
+ const err = answer.error;
2776
+ if (reusable && err?.code === '57014') {
2777
+ held.checkout.release();
2778
+ return {
2779
+ timeout: new TimeoutError(timeout, 'Query', {
2780
+ outcome: 'not-executed',
2781
+ cause: err,
2782
+ message: `Query timed out after ${timeout}ms and was cancelled on the server. It made no change.`,
2783
+ }),
2784
+ };
2785
+ }
2786
+ held.checkout.release(err instanceof Error ? err : true);
2787
+ return answer;
2788
+ }
2789
+ finally {
2790
+ clearTimeout(graceTimer);
2791
+ }
2792
+ }
2186
2793
  /**
2187
2794
  * Execute a write `DeferredQuery` (create/update/delete/upsert) according to
2188
2795
  * the active dialect's {@link Dialect.resultStrategy}, then apply its
@@ -2197,18 +2804,104 @@ export class QueryInterface {
2197
2804
  * write and a follow-up SELECT; the SELECT's rows feed the transform.
2198
2805
  */
2199
2806
  async executeMutation(deferred, timeout) {
2200
- if (this.dialect.resultStrategy === 'reselect' && deferred.reselect) {
2201
- const exec = (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, preparedName);
2202
- const result = await deferred.reselect(exec);
2203
- return deferred.transform(result);
2807
+ const reselect = deferred.reselect;
2808
+ if (this.dialect.resultStrategy === 'reselect' && reselect) {
2809
+ // One transaction, so the row returned is the row this call wrote, and
2810
+ // the transform runs before COMMIT so a refusal it raises writes nothing.
2811
+ return this.inOneTransaction(async (exec) => deferred.transform(await reselect(exec)), timeout, `A ${this.dialect.name} write`, 'Pass a pool that can check out a connection.');
2204
2812
  }
2205
2813
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, timeout, deferred.preparedName);
2206
2814
  return deferred.transform(result);
2207
2815
  }
2816
+ /**
2817
+ * The executor for a write's statements, sharing one deadline: each one gets
2818
+ * what is left of `timeout`, so a chunked `createMany` or a lock, write and
2819
+ * re-read cannot run for N times the timeout. A statement due past the
2820
+ * deadline is not sent. `onClient` routes them to a held connection.
2821
+ */
2822
+ deadlineExec(timeout, onClient) {
2823
+ const deadline = timeout ? performance.now() + timeout : undefined;
2824
+ return async (sql, params, preparedName) => {
2825
+ let left = timeout;
2826
+ if (deadline !== undefined) {
2827
+ left = Math.ceil(deadline - performance.now());
2828
+ if (left <= 0) {
2829
+ throw new TimeoutError(timeout, 'Query', {
2830
+ outcome: 'not-executed',
2831
+ message: `Query timed out after ${timeout}ms: the write's statements share one deadline, and it passed ` +
2832
+ 'before the next one was sent. Its earlier statements are rolled back, so it makes no change.',
2833
+ });
2834
+ }
2835
+ }
2836
+ return onClient
2837
+ ? this.queryWithTimeout(sql, params, left, preparedName, false, onClient)
2838
+ : this.queryWithTimeout(sql, params, left, preparedName);
2839
+ };
2840
+ }
2841
+ /**
2842
+ * Run a write that takes several statements as one transaction on one
2843
+ * connection: the caller's transaction when there is one, else one opened
2844
+ * here. A statement that times out is cancelled where the engine allows it,
2845
+ * and its connection is closed rather than rolled back (a ROLLBACK would wait
2846
+ * behind it); the transaction never commits, so the write made no change.
2847
+ * `timeout` is one deadline for every statement (see {@link deadlineExec}).
2848
+ */
2849
+ async inOneTransaction(work, timeout, operation, alternative) {
2850
+ if (this.txScoped) {
2851
+ // In the caller's transaction, a savepoint keeps the write whole: a later
2852
+ // statement that fails and is caught cannot leave the earlier ones behind.
2853
+ return withSavepoint(this.pool, () => work(this.deadlineExec(timeout)));
2854
+ }
2855
+ if (typeof this.pool.connect !== 'function') {
2856
+ throw new UnsupportedFeatureError(`${operation} on a pool without connect()`, this.dialect.name, `${operation} runs several statements in one transaction, which needs a connection of its own, and ` +
2857
+ `this pool cannot check one out (a stateless HTTP driver). ${alternative}`);
2858
+ }
2859
+ const { client, checkout } = await openCheckout(this.pool, (c) => c.query(this.dialect.beginStatement()));
2860
+ const onClient = {
2861
+ query: (text, values) => typeof text === 'string'
2862
+ ? client.query(text, values)
2863
+ : client.query(text),
2864
+ };
2865
+ try {
2866
+ const value = await work(this.deadlineExec(timeout, onClient));
2867
+ await client.query(this.dialect.commitStatement());
2868
+ checkout.release();
2869
+ return value;
2870
+ }
2871
+ catch (err) {
2872
+ if (err instanceof TimeoutError) {
2873
+ if (await sendCancelRequest(client, CANCEL_GRACE_MS)) {
2874
+ checkout.release(err);
2875
+ }
2876
+ else {
2877
+ // No cancel reached the server (or the engine has none), so the
2878
+ // statement may still be running. The ROLLBACK queues behind it on
2879
+ // this connection, and the connection goes back to the pool only
2880
+ // after it: never with the transaction still open.
2881
+ client.query(this.dialect.rollbackStatement()).then(() => checkout.release(), (e) => checkout.release(e instanceof Error ? e : true));
2882
+ }
2883
+ throw new TimeoutError(err.timeoutMs, 'Query', {
2884
+ outcome: 'not-executed',
2885
+ cause: err,
2886
+ message: `Query timed out after ${err.timeoutMs}ms. Its transaction is rolled back rather than committed, ` +
2887
+ 'so it makes no change.',
2888
+ });
2889
+ }
2890
+ try {
2891
+ await client.query(this.dialect.rollbackStatement());
2892
+ }
2893
+ catch {
2894
+ // Best effort: the connection may be gone.
2895
+ }
2896
+ checkout.release();
2897
+ throw explainConnectionLoss(wrapPgError(err), checkout.lostWith);
2898
+ }
2899
+ }
2208
2900
  // ---------------------------------------------------------------------------
2209
2901
  // Write compilation (extracted to writes.ts).
2210
2902
  // ---------------------------------------------------------------------------
2211
2903
  buildCreate(args) {
2904
+ args = this.normalizeArgs(args);
2212
2905
  return writesMod.buildCreate(this.ctx, args, this.resolveWriteProjection(args));
2213
2906
  }
2214
2907
  /**
@@ -2230,22 +2923,26 @@ export class QueryInterface {
2230
2923
  return { columns };
2231
2924
  }
2232
2925
  buildCreateMany(args) {
2233
- return writesMod.buildCreateMany(this.ctx, args);
2926
+ args = this.normalizeArgs(args);
2927
+ return writesMod.buildCreateMany(this.ctx, args, 'refuse', this.resolveWriteProjection(args));
2234
2928
  }
2235
2929
  buildUpdate(args) {
2930
+ args = this.normalizeArgs(args);
2236
2931
  return writesMod.buildUpdate(this.ctx, args, this.resolveWriteProjection(args));
2237
2932
  }
2238
2933
  buildDelete(args) {
2934
+ args = this.normalizeArgs(args);
2239
2935
  return writesMod.buildDelete(this.ctx, args, this.resolveWriteProjection(args));
2240
2936
  }
2241
2937
  buildUpsert(args) {
2938
+ args = this.normalizeArgs(args);
2242
2939
  return writesMod.buildUpsert(this.ctx, args, this.resolveWriteProjection(args));
2243
2940
  }
2244
2941
  buildUpdateMany(args) {
2245
- return writesMod.buildUpdateMany(this.ctx, args);
2942
+ return writesMod.buildUpdateMany(this.ctx, this.normalizeArgs(args));
2246
2943
  }
2247
2944
  buildDeleteMany(args) {
2248
- return writesMod.buildDeleteMany(this.ctx, args);
2945
+ return writesMod.buildDeleteMany(this.ctx, this.normalizeArgs(args));
2249
2946
  }
2250
2947
  /**
2251
2948
  * Best-effort extraction of an auto-generated primary key from a write
@@ -2261,11 +2958,10 @@ export class QueryInterface {
2261
2958
  * If no middlewares are registered, executes directly.
2262
2959
  *
2263
2960
  * Middleware can inspect and log query parameters, measure timing, and
2264
- * transform the result returned by `next()`. Note: query SQL is generated
2265
- * BEFORE middleware runs, `params.args` is a read-only snapshot, and
2266
- * mutating it does NOT change the executed SQL. Cross-cutting filters
2267
- * (e.g. soft deletes) belong in the query itself: pass an explicit
2268
- * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
2961
+ * transform the result returned by `next()`. `params.args` is a plain copy
2962
+ * (see `readOnlyMiddlewareParams`): the query is compiled from the caller's
2963
+ * args, so `next()` throws a TypeError when the copy was changed. Cross-cutting
2964
+ * filters (e.g. soft deletes) belong in the query itself, or in `globalFilters`.
2269
2965
  */
2270
2966
  async executeWithMiddleware(action, args, executor) {
2271
2967
  this.currentAction = action;
@@ -2274,14 +2970,17 @@ export class QueryInterface {
2274
2970
  // can be complete. Putting it in each method instead would mean fifteen
2275
2971
  // sites and a new one every time an operation is added, which is precisely
2276
2972
  // how the surface it checks came to need checking.
2277
- warnUnknownQueryOptions(this.table, action, args);
2973
+ warnUnknownQueryOptions(this.table, action, args, this.advice ?? null);
2278
2974
  if (this.middlewares.length === 0) {
2279
2975
  return executor();
2280
2976
  }
2281
- const params = { model: this.table, action, args: { ...args } };
2977
+ // A copy: the query is compiled from `args`, not from what middleware
2978
+ // sees, so `next()` throws on a changed copy rather than doing nothing.
2979
+ const params = readOnlyMiddlewareParams(this.table, action, args);
2282
2980
  // Build middleware chain
2283
2981
  let index = 0;
2284
2982
  const next = async (p) => {
2983
+ assertMiddlewareArgsKept(params, p);
2285
2984
  if (index < this.middlewares.length) {
2286
2985
  const mw = this.middlewares[index++];
2287
2986
  return mw(p, next);
@@ -2304,6 +3003,7 @@ export class QueryInterface {
2304
3003
  // nothing else). It is here so the invariant holds at the seam rather than
2305
3004
  // depending on every consumer to re-establish it.
2306
3005
  args = this.normalizeArgs(args);
3006
+ this.assertLockInTransaction(args, 'findUnique');
2307
3007
  return this.executeWithMiddleware('findUnique', args, async () => {
2308
3008
  if (args.with) {
2309
3009
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
@@ -2356,6 +3056,9 @@ export class QueryInterface {
2356
3056
  return entity;
2357
3057
  }
2358
3058
  buildFindUnique(args) {
3059
+ // Canonical args FIRST: the empty-where guard and the unique-key rule below
3060
+ // must read the pruned `where`, never one still carrying `undefined` keys.
3061
+ args = this.normalizeArgs(args);
2359
3062
  this.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
2360
3063
  // Pinned before the cache key, which carries it (see buildFindMany).
2361
3064
  const jsonEncoding = this.resolveJsonEncoding(args.jsonEncoding);
@@ -2377,9 +3080,10 @@ export class QueryInterface {
2377
3080
  // deliberately so: a tenant filter is not a unique selector, and letting it
2378
3081
  // satisfy this would still hand back an arbitrary row from inside the tenant.
2379
3082
  //
2380
- // `findFirst` is intentionally NOT guarded. "The first row matching an
2381
- // optional filter" is its whole contract and Prisma's `findFirst` behaves
2382
- // the same way; ask for one row by identity with `findUnique`.
3083
+ // `findFirst` takes no such guard: "the first row matching an optional
3084
+ // filter" is its contract, and `where: {}` or no `where` asks for any row.
3085
+ // It refuses only a `where` that was emptied by `undefined` values (see
3086
+ // normalizeArgs), which never meant "any row".
2383
3087
  if (whereMod.userPredicateIsEmpty(this.ctx, (args.where ?? {}))) {
2384
3088
  throw new ValidationError(`findUnique on "${this.table}" refused: the \`where\` clause has no predicate, ` +
2385
3089
  'so this would return an arbitrary row rather than a specific one. ' +
@@ -2399,9 +3103,9 @@ export class QueryInterface {
2399
3103
  // a global filter is not an identity, and letting one satisfy this would
2400
3104
  // hand back an arbitrary row from inside the tenant.
2401
3105
  this.assertFindUniqueKey(args.where);
2402
- // Declared relation spellings first, before stable-order and the
2403
- // fingerprint (see buildFindMany).
2404
- args = this.normalizeArgs(args);
3106
+ // The locking clause, read by the cache key and all three SQL paths below.
3107
+ const rowLock = resolveRowLock(args.lock, this.dialect, this.table);
3108
+ const lockSql = rowLock?.sql ?? '';
2405
3109
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2406
3110
  // relations before fingerprinting (see buildFindMany).
2407
3111
  if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
@@ -2431,7 +3135,9 @@ export class QueryInterface {
2431
3135
  // `|je=`: same rule and same hazard as in buildFindMany. findUnique is never
2432
3136
  // flatten-planned, so this is the only plan-shape segment it needs.
2433
3137
  const encodingFp = `|je=${jsonEncoding === 'positional' ? 'p' : 'o'}`;
2434
- const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}|pii=${includePii ? 1 : 0}${encodingFp}${this.globalFilterCacheSegment()}`;
3138
+ // Empty without a lock, so every unlocked key is unchanged.
3139
+ const lockFp = rowLock ? `|lk=${rowLock.fp}` : '';
3140
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}|pii=${includePii ? 1 : 0}${encodingFp}${lockFp}${this.globalFilterCacheSegment()}`;
2435
3141
  const params = [];
2436
3142
  // Check if all where values are simple (plain equality, no operators/null/OR).
2437
3143
  // Keys are sorted to match fingerprintWhere, insertion order here would let
@@ -2439,14 +3145,19 @@ export class QueryInterface {
2439
3145
  const whereKeys = Object.keys(whereObj)
2440
3146
  .filter((k) => whereObj[k] !== undefined)
2441
3147
  .sort();
3148
+ // Only a scalar the general path would accept as it is (a primitive, Date
3149
+ // or Buffer) on a non-JSON column takes the simple path, so no operand
3150
+ // reaches SQL without the general path's validation. Resolved, not looked
3151
+ // up: a relation filter spelled the snake_case way must not read as a
3152
+ // plain equality.
2442
3153
  const isSimpleWhere = !whereObj.OR &&
2443
3154
  !whereObj.AND &&
2444
3155
  !whereObj.NOT &&
2445
3156
  whereKeys.every((k) => {
2446
- const v = whereObj[k];
2447
- // Resolved, not looked up: a relation filter spelled the snake_case
2448
- // way must not read as a plain equality and take the simple path.
2449
- return v !== null && !isWhereOperator(v) && !resolveRelationDef(this.tableMeta.relations, k);
3157
+ if (!isFastPathScalar(whereObj[k]) || resolveRelationDef(this.tableMeta.relations, k))
3158
+ return false;
3159
+ const column = resolveColumnName(this.tableMeta, k);
3160
+ return column !== undefined && !this.isJsonColumnType(this.getColumnPgType(column));
2450
3161
  });
2451
3162
  // Simple path: plain equality, no operators/null/OR.
2452
3163
  //
@@ -2467,7 +3178,7 @@ export class QueryInterface {
2467
3178
  });
2468
3179
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
2469
3180
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
2470
- return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
3181
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}${lockSql}`;
2471
3182
  };
2472
3183
  const entry = this.acquireSql(ck, buildSql);
2473
3184
  // Collect params (same order and same coercion as build)
@@ -2484,6 +3195,7 @@ export class QueryInterface {
2484
3195
  },
2485
3196
  tag: `${this.table}.findUnique`,
2486
3197
  preparedName: entry.name,
3198
+ ...rowLockMarker(rowLock),
2487
3199
  };
2488
3200
  }
2489
3201
  // General path (with operators, null, OR, with clause)
@@ -2493,7 +3205,7 @@ export class QueryInterface {
2493
3205
  const whereSql = clause ? ` WHERE ${clause}` : '';
2494
3206
  const qt = this.q(this.table);
2495
3207
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
2496
- return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
3208
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}${lockSql}`;
2497
3209
  };
2498
3210
  const entry = this.acquireSql(ck, buildSql);
2499
3211
  // Collect params
@@ -2508,6 +3220,7 @@ export class QueryInterface {
2508
3220
  },
2509
3221
  tag: `${this.table}.findUnique`,
2510
3222
  preparedName: entry.name,
3223
+ ...rowLockMarker(rowLock),
2511
3224
  };
2512
3225
  }
2513
3226
  // Nested queries with `with` clause.
@@ -2519,7 +3232,7 @@ export class QueryInterface {
2519
3232
  const clause = this.buildWhereClause(whereObj, freshParams);
2520
3233
  const whereSql = clause ? ` WHERE ${clause}` : '';
2521
3234
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList, undefined, undefined, includePii);
2522
- return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
3235
+ return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}${lockSql}`;
2523
3236
  };
2524
3237
  const entry = this.acquireSql(ck, buildSql);
2525
3238
  // Collect params in exact build order: where first, then with-clause relations
@@ -2536,6 +3249,7 @@ export class QueryInterface {
2536
3249
  },
2537
3250
  tag: `${this.table}.findUnique`,
2538
3251
  preparedName: entry.name,
3252
+ ...rowLockMarker(rowLock),
2539
3253
  };
2540
3254
  }
2541
3255
  // -------------------------------------------------------------------------
@@ -2551,19 +3265,20 @@ export class QueryInterface {
2551
3265
  // nothing else). It is here so the invariant holds at the seam rather than
2552
3266
  // depending on every consumer to re-establish it.
2553
3267
  args = this.normalizeArgs(args);
3268
+ this.assertLockInTransaction(args, 'findMany');
2554
3269
  this.maybeWarnUnlimited(args);
2555
3270
  this.maybeWarnUnorderedPage(args);
2556
- // Dev-only: warn on deeply nested with clauses
2557
- if (process.env.NODE_ENV !== 'production') {
2558
- if (args?.with) {
2559
- const depth = this.measureWithDepth(args.with);
2560
- if (depth > 5 && shouldWarnOnce(WARN_NS.deepWith, this.table)) {
2561
- console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}": every level is a ` +
2562
- 'correlated subquery the engine re-evaluates once per row of the level above, so the work ' +
2563
- 'multiplies down the tree and the whole subtree is built as JSON inside each parent row. Split ' +
2564
- "into separate queries, or use `relationLoadStrategy: 'batched'` (one flat statement per " +
2565
- 'relation, no per-parent re-evaluation). Dev-only: silent under `NODE_ENV=production`.');
2566
- }
3271
+ // Advice: warn on deeply nested with clauses.
3272
+ const advice = this.advice;
3273
+ if (advice && args?.with) {
3274
+ const depth = this.measureWithDepth(args.with);
3275
+ if (depth > 5 && shouldWarnOnce(WARN_NS.deepWith, this.table)) {
3276
+ logTo(advice, 'warn', `Deep with clause (depth ${depth}) on "${this.tableMeta.name}": every level is a ` +
3277
+ 'correlated subquery the engine re-evaluates once per row of the level above, so the work ' +
3278
+ 'multiplies down the tree and the whole subtree is built as JSON inside each parent row. Split ' +
3279
+ "into separate queries, or use `relationLoadStrategy: 'batched'` (one flat statement per " +
3280
+ 'relation, no per-parent re-evaluation). Advice: silent when `diagnostics.advice` is off, as it ' +
3281
+ 'is by default in production.', { code: 'deep-with', table: this.table, operation: 'findMany', depth });
2567
3282
  }
2568
3283
  }
2569
3284
  return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
@@ -2638,11 +3353,13 @@ export class QueryInterface {
2638
3353
  }
2639
3354
  }
2640
3355
  /**
2641
- * Emit a one-time `console.warn` when {@link findMany} is called without an
3356
+ * Emit a one-time warning when {@link findMany} is called without an
2642
3357
  * explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
2643
3358
  *
2644
- * Deduped per QueryInterface instance via {@link warnedTables} so a busy
2645
- * loop calling `db.users.findMany()` thousands of times only logs once.
3359
+ * Said once per table per process (`WARN_NS.unlimitedFindMany`), so a busy
3360
+ * loop calling `db.users.findMany()` thousands of times logs once, and so
3361
+ * does the same call inside every `$transaction`, which builds its own
3362
+ * query interfaces.
2646
3363
  * Suppressed when `defaultLimit` is configured (the caller has already
2647
3364
  * opted in to a bounded query) and when the user passed an explicit
2648
3365
  * `limit`, `take`, or `cursor`. A per-call `warnOnUnlimited` overrides the
@@ -2658,19 +3375,18 @@ export class QueryInterface {
2658
3375
  return;
2659
3376
  if (this.defaultLimit !== undefined)
2660
3377
  return;
2661
- const hasExplicitLimit = args?.limit !== undefined || args?.cursor !== undefined;
3378
+ const hasExplicitLimit = args?.limit !== undefined || hasCursor(args?.cursor);
2662
3379
  if (hasExplicitLimit)
2663
3380
  return;
2664
3381
  if (this.whereMatchesAtMostOneRow(args?.where))
2665
3382
  return;
2666
- if (this.warnedTables.has(this.table))
3383
+ if (!shouldWarnOnce(WARN_NS.unlimitedFindMany, this.table))
2667
3384
  return;
2668
- this.warnedTables.add(this.table);
2669
- console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: the statement is emitted with no row limit, ` +
3385
+ logTo(this.logger, 'warn', `warning: findMany on "${this.table}" has no limit: the statement is emitted with no row limit, ` +
2670
3386
  'so the engine returns every matching row and the driver materializes all of them as objects before ' +
2671
3387
  'this call resolves (the cost grows with the table, not with the rows you use). Pass `limit`/`take`, ' +
2672
3388
  'or set `defaultLimit` in the client config; silence with `warnOnUnlimited: false` (per call, per ' +
2673
- 'table, or in config).');
3389
+ 'table, or in config).', { code: 'unlimited-find-many', table: this.table, operation: 'findMany' });
2674
3390
  }
2675
3391
  /**
2676
3392
  * Whether `where` can match at most one row, because it pins every column of
@@ -2717,7 +3433,7 @@ export class QueryInterface {
2717
3433
  }
2718
3434
  /**
2719
3435
  * Recursively measure the maximum depth of a `with` clause tree.
2720
- * Used by the dev-only deep-with warning guard.
3436
+ * Used by the deep-with advice.
2721
3437
  */
2722
3438
  measureWithDepth(withClause) {
2723
3439
  let maxDepth = 1;
@@ -2768,7 +3484,7 @@ export class QueryInterface {
2768
3484
  if (args?.orderBy !== undefined) {
2769
3485
  const deduped = dedupeOrderEntries(this.tableMeta, orderByEntries(args.orderBy), this.table);
2770
3486
  if (deduped) {
2771
- warnRedundantSortTerm(this.table, 'orderBy', deduped.dropped);
3487
+ warnRedundantSortTerm(this.advice, this.table, 'orderBy', deduped.dropped);
2772
3488
  args = {
2773
3489
  ...args,
2774
3490
  orderBy: deduped.entries.map(([k, v]) => ({ [k]: v })),
@@ -2778,7 +3494,7 @@ export class QueryInterface {
2778
3494
  if (args?.distinct && args.distinct.length > 0) {
2779
3495
  const deduped = dedupeColumnList(this.tableMeta, args.distinct);
2780
3496
  if (deduped) {
2781
- warnRedundantSortTerm(this.table, 'distinct', deduped.dropped);
3497
+ warnRedundantSortTerm(this.advice, this.table, 'distinct', deduped.dropped);
2782
3498
  args = { ...args, distinct: deduped.columns };
2783
3499
  }
2784
3500
  }
@@ -2789,29 +3505,40 @@ export class QueryInterface {
2789
3505
  const implicitOrder = this.implicitPkOrderBy(args);
2790
3506
  if (implicitOrder)
2791
3507
  args = { ...args, orderBy: implicitOrder };
3508
+ // A `cursor` that names no row (`null`, `{}`) is refused before either
3509
+ // reader below asks whether there is one.
3510
+ assertCursorWritten(this.table, 'findMany', args?.cursor);
3511
+ // A page whose order can tie gets the cursor tiebreaker (completePageOrder),
3512
+ // before fingerprinting, so the cache key carries it.
3513
+ if (args)
3514
+ args = this.completePageOrder(args);
3515
+ // A cursor page is planned (and its orderBy completed to the effective
3516
+ // order) BEFORE fingerprinting, so the key, the SQL and the param collect
3517
+ // all read one plan. See planCursor.
3518
+ let cursorPlan;
3519
+ if (hasCursor(args?.cursor))
3520
+ ({ args, plan: cursorPlan } = this.planCursor(args));
2792
3521
  // `distinct` compiles to `SELECT DISTINCT ON (...)`, which is PostgreSQL
2793
3522
  // syntax and nothing else's. Every other engine parsed it as far as the
2794
3523
  // word `ON` and answered with a RAW driver error carrying no TURBINE_ code
2795
3524
  // (SQLite: `near "ON": syntax error`; MySQL: ER_PARSE_ERROR), i.e. the one
2796
3525
  // shape the typed-error surface exists to prevent. Refused with the same
2797
- // E017 the OTHER spelling of this feature already used, `groupBy({
2798
- // distinctOn })` in aggregates.ts, which was gated correctly all along.
2799
- //
2800
- // `dialect.name` rather than a new capability flag, deliberately, and this
2801
- // is the exception to the "capability flags, not engine names" rule the
2802
- // rest of the dialect follows: every engine dialect is built by SPREADING
2803
- // `postgresDialect`, so a flag defaulting to `true` would be inherited by
2804
- // all of them (which is the bug) and one defaulting to `false` would have
2805
- // to be re-declared on Postgres-compatible dialects that already work.
2806
- // The established precedent for exactly this feature is the `dialect.name`
2807
- // test in `buildDistinctOnSource`.
3526
+ // E017, on the same `distinctOn` capability, as the OTHER spelling of this
3527
+ // feature, `groupBy({ distinctOn })` in aggregates.ts.
2808
3528
  //
2809
3529
  // Before the cache, like the two guards below, so a warmed template cannot
2810
3530
  // serve the query the cold path refuses.
2811
- if (args?.distinct && args.distinct.length > 0 && this.dialect.name !== 'postgresql') {
3531
+ if (args?.distinct && args.distinct.length > 0 && !this.dialect.capabilities.distinctOn) {
2812
3532
  throw new UnsupportedFeatureError('DISTINCT ON (findMany distinct)', this.dialect.name, 'findMany({ distinct }) requires PostgreSQL: SELECT DISTINCT ON is not portable. Group in ' +
2813
3533
  'application code, or use groupBy({ by }) for one row per combination.');
2814
3534
  }
3535
+ // The locking clause, validated and rendered once: the cache key and the
3536
+ // SQL both read this value. PostgreSQL refuses a lock on a DISTINCT query
3537
+ // (0A000), so that pair is refused here, before the cache.
3538
+ const rowLock = resolveRowLock(args?.lock, this.dialect, this.table);
3539
+ if (rowLock && args?.distinct && args.distinct.length > 0) {
3540
+ throw new ValidationError(lockWithDistinctMessage(this.table));
3541
+ }
2815
3542
  // `distinct` + relation orderBy is refused up front (E003): the distinct
2816
3543
  // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
2817
3544
  // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
@@ -2858,14 +3585,9 @@ export class QueryInterface {
2858
3585
  })
2859
3586
  .join(',')
2860
3587
  : '';
2861
- // Keyset order AND per-field direction: a multi-field cursor's predicate
2862
- // is one OR-branch per field in orderBy precedence, and `<` / `>` are SQL
2863
- // text, so `asc` and `desc` (or two precedences) can never share a template.
2864
- const cursorFp = args?.cursor
2865
- ? this.cursorSeekEntries(args.cursor, args.orderBy)
2866
- .map((e) => `${e.column}:${e.desc ? 'd' : 'a'}`)
2867
- .join(',')
2868
- : '';
3588
+ // Mode, keyset order, directions, NULL placement and which values are
3589
+ // NULL all change the predicate's SQL text (see planCursor).
3590
+ const cursorFp = cursorPlan?.fingerprint ?? '';
2869
3591
  // distinct must fingerprint in USER order: the SQL emits `DISTINCT ON` in
2870
3592
  // the caller's column order, so a permuted array rebuilds different SQL and
2871
3593
  // must not collapse onto the same cache entry (would trip the cross-check).
@@ -2906,7 +3628,9 @@ export class QueryInterface {
2906
3628
  // non-default one, so the key never depends on what the client default
2907
3629
  // happens to be.
2908
3630
  const encodingFp = `|je=${jsonEncoding === 'positional' ? 'p' : 'o'}`;
2909
- const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${flattenFp}${encodingFp}${this.globalFilterCacheSegment()}`;
3631
+ // Empty without a lock, so every unlocked key is unchanged.
3632
+ const lockFp = rowLock ? `|lk=${rowLock.fp}` : '';
3633
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${flattenFp}${encodingFp}${lockFp}${this.globalFilterCacheSegment()}`;
2910
3634
  const params = [];
2911
3635
  const buildSql = (freshParams) => {
2912
3636
  // Fresh build: generates SQL and populates freshParams
@@ -2972,18 +3696,9 @@ export class QueryInterface {
2972
3696
  // WHERE + cursor conditions accumulate into `tail`, pushing params in
2973
3697
  // where → cursor order (the collect path mirrors this exactly).
2974
3698
  let tail = freshWhereSql;
2975
- if (args?.cursor) {
2976
- // Keyset order (orderBy precedence), MUST match cursorFp and the
2977
- // cache-hit collect below: both go through cursorSeekEntries.
2978
- const seek = this.cursorSeekEntries(args.cursor, args.orderBy);
2979
- if (seek.length > 0) {
2980
- const terms = seek.map((e) => {
2981
- freshParams.push(e.value);
2982
- return { column: `${qt}.${this.q(e.column)}`, placeholder: this.p(freshParams.length), desc: e.desc };
2983
- });
2984
- const predicate = this.keysetPredicate(terms);
2985
- tail += freshWhereSql ? ` AND ${predicate}` : ` WHERE ${predicate}`;
2986
- }
3699
+ if (cursorPlan) {
3700
+ const predicate = this.renderCursorPredicate(cursorPlan, freshParams);
3701
+ tail += freshWhereSql ? ` AND ${predicate}` : ` WHERE ${predicate}`;
2987
3702
  }
2988
3703
  // ORDER BY is built AFTER the cursor pushes (param order
2989
3704
  // where → with → cursor → orderBy → limit → offset) and BEFORE final
@@ -3057,6 +3772,9 @@ export class QueryInterface {
3057
3772
  offsetPh = this.paginationRef(args.offset, freshParams, 'skip/offset');
3058
3773
  }
3059
3774
  sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
3775
+ // The locking clause follows pagination and binds nothing.
3776
+ if (rowLock)
3777
+ sql += rowLock.sql;
3060
3778
  return sql;
3061
3779
  };
3062
3780
  const entry = this.acquireSql(ck, buildSql);
@@ -3069,13 +3787,9 @@ export class QueryInterface {
3069
3787
  if (args?.with) {
3070
3788
  this.collectWithParams(args.with, params, undefined, flattenPlan);
3071
3789
  }
3072
- // 3. Cursor params, keyset order, matching cursorFp and the build path
3073
- // (each value binds ONCE; the expanded predicate re-references its `$n`).
3074
- if (args?.cursor) {
3075
- for (const e of this.cursorSeekEntries(args.cursor, args.orderBy)) {
3076
- params.push(e.value);
3077
- }
3078
- }
3790
+ // 3. Cursor params: the build's own renderer, SQL discarded.
3791
+ if (cursorPlan)
3792
+ this.renderCursorPredicate(cursorPlan, params);
3079
3793
  // 4. ORDER BY params (vector KNN ordering binds a `$n::vector` query vector).
3080
3794
  // Mirrors buildOrderBy's push order, between cursor and LIMIT.
3081
3795
  if (args?.orderBy) {
@@ -3102,6 +3816,7 @@ export class QueryInterface {
3102
3816
  transform: (result) => result.rows.map((row) => (parseWith ? parseWith(row) : this.parseRow(row, this.table))),
3103
3817
  tag: `${this.table}.findMany`,
3104
3818
  preparedName: entry.name,
3819
+ ...rowLockMarker(rowLock),
3105
3820
  };
3106
3821
  }
3107
3822
  // -------------------------------------------------------------------------
@@ -3162,20 +3877,21 @@ export class QueryInterface {
3162
3877
  * the result set is empty. Callers may therefore treat a yielded batch as
3163
3878
  * non-empty.
3164
3879
  */
3165
- async *streamRaw(args, action) {
3166
- // Pagination aliases folded BEFORE the speculative build below, which
3167
- // spreads `args` and overrides `limit`. A surviving `take` would reach that
3168
- // spread alongside the override and be read as two different values for one
3169
- // bound. Relation names are left to `buildFindMany`, which both branches go
3170
- // through.
3171
- args = normalizePagination(args);
3880
+ async *streamRaw(args, action, idle, unit) {
3881
+ // Canonical args BEFORE the speculative build below, which spreads `args`
3882
+ // and sets its own `limit`: a surviving `take` would reach that spread
3883
+ // beside it and be read as a second value for one bound.
3884
+ args = this.normalizeArgs(args);
3172
3885
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
3886
+ // Validated on every path, so a bad value is refused whether or not this
3887
+ // stream turns out to need a cursor.
3888
+ const idleTimeoutMs = resolveStreamIdleTimeout(args?.idleTimeoutMs, 'idleTimeoutMs') ?? this.streamIdleTimeoutMs;
3173
3889
  this.currentAction = action;
3174
3890
  // The two streaming methods do NOT go through `executeWithMiddleware`, so
3175
3891
  // the unknown-key diagnostic has to be hung here as well or a stream would
3176
3892
  // be the one read that silently drops an `include`. Both public methods
3177
3893
  // reach this, and each passes its own name.
3178
- warnUnknownQueryOptions(this.table, action, args);
3894
+ warnUnknownQueryOptions(this.table, action, args, this.advice ?? null);
3179
3895
  // Streaming is ALREADY immune to the generic-plan cliff: the speculative
3180
3896
  // fetch has never passed a prepared name, and the cursor path runs through
3181
3897
  // DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
@@ -3212,9 +3928,18 @@ export class QueryInterface {
3212
3928
  // four round trips on a drain that would have fit, never any transferred
3213
3929
  // rows.
3214
3930
  if (batchSize <= STREAM_SPECULATION_MAX_BATCH) {
3931
+ // The caller's bound (or the client's `defaultLimit`, exactly as findMany
3932
+ // applies it) caps the speculative fetch. When the bound fits in a batch
3933
+ // the speculative fetch IS the whole answer; otherwise `rows <= batchSize`
3934
+ // still means fewer rows exist than either bound, and an overflow goes to
3935
+ // the cursor, whose statement carries the caller's own LIMIT.
3936
+ const callerLimit = args?.limit ?? this.defaultLimit;
3937
+ const bound = callerLimit === undefined ? undefined : this.paginationValue(callerLimit, 'limit');
3215
3938
  const speculativeDeferred = this.buildFindMany({
3216
3939
  ...args,
3217
- limit: batchSize + 1,
3940
+ limit: bound !== undefined && bound <= batchSize ? bound : batchSize + 1,
3941
+ // Only the caller's own bound makes this a page (completePageOrder).
3942
+ ...firstRowOnly(args),
3218
3943
  });
3219
3944
  const speculativeResult = await this.readWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout, streamPreparedName);
3220
3945
  if (speculativeResult.rows.length <= batchSize) {
@@ -3238,22 +3963,100 @@ export class QueryInterface {
3238
3963
  // transaction control of its own (`ambientTransaction`).
3239
3964
  const held = this.txScoped ? null : await this.acquireConnection();
3240
3965
  const client = held?.client ?? null;
3241
- const conn = client ?? {
3966
+ const base = client ?? {
3242
3967
  query: async (text, values) => (await this.pool.query(text, values)),
3243
3968
  };
3969
+ // The idle guard watches only a stream that holds a connection of its own
3970
+ // between pulls. Inside a caller's transaction the connection is theirs and
3971
+ // their transaction's timeout bounds it. On PostgreSQL the stream's BEGIN
3972
+ // also carries the server-side limit (see streamBackstopStatement), and the
3973
+ // guard keeps a live consumer's transaction from looking idle to it.
3974
+ const watched = held !== null && idleTimeoutMs > 0;
3975
+ const backstop = watched && this.dialect.capabilities.idleTransactionTimeout;
3976
+ let guard;
3977
+ const conn = watched
3978
+ ? watchStreamConnection(base, () => guard, backstop
3979
+ ? { begin: this.dialect.beginStatement(), statement: streamBackstopStatement(idleTimeoutMs) }
3980
+ : undefined)
3981
+ : base;
3982
+ // The cursor's statement carries the `sqlComment` like any other (the
3983
+ // speculative fetch above got it at the execute seam). Never named: a
3984
+ // cursor is declared by text.
3985
+ const streamSql = this.outgoing(deferred.sql, undefined, action).text;
3986
+ const stream = this.dialect.openStream(conn, streamSql, deferred.params, batchSize, {
3987
+ ambientTransaction: this.txScoped,
3988
+ });
3989
+ if (watched) {
3990
+ guard = new StreamIdleGuard(idle, idleTimeoutMs, () => streamIdleExpiredError(action, this.table, idleTimeoutMs, unit), () => this.expireIdleStream(stream, held, action, idleTimeoutMs, idle, unit), backstop ? () => base.query('SELECT 1') : undefined);
3991
+ }
3244
3992
  try {
3245
- yield* this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
3246
- ambientTransaction: this.txScoped,
3247
- });
3993
+ // Driven by hand rather than `yield*`, so an expired stream's cleanup can
3994
+ // run from the timer while this generator is suspended.
3995
+ while (true) {
3996
+ const step = await stream.next();
3997
+ if (step.done)
3998
+ break;
3999
+ yield step.value;
4000
+ if (idle.expired)
4001
+ throw idle.expired;
4002
+ }
3248
4003
  }
3249
4004
  catch (err) {
4005
+ if (err === idle.expired)
4006
+ throw err;
3250
4007
  // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
3251
4008
  throw explainConnectionLoss(wrapPgError(err), held?.checkout.lostWith);
3252
4009
  }
3253
4010
  finally {
3254
- held?.checkout.release();
4011
+ guard?.stop();
4012
+ if (guard?.expiry) {
4013
+ // The timer already closed the stream and released the connection;
4014
+ // wait for that so the connection is never handed back mid-cleanup.
4015
+ await guard.expiry;
4016
+ }
4017
+ else {
4018
+ try {
4019
+ // An early exit (`break`) closes the cursor here; after a full drain
4020
+ // or an error the dialect's generator has finished and this is a no-op.
4021
+ await stream.return(undefined);
4022
+ }
4023
+ catch (err) {
4024
+ // Only an early exit can reach this throw: after an error or a full
4025
+ // drain the dialect's generator has finished and return() resolves.
4026
+ // A failed CLOSE / COMMIT on an early exit must reach the caller, as
4027
+ // it did when this loop was a `yield*`.
4028
+ // biome-ignore lint/correctness/noUnsafeFinally: it replaces only an early-exit return, see above
4029
+ throw explainConnectionLoss(wrapPgError(err), held?.checkout.lostWith);
4030
+ }
4031
+ finally {
4032
+ held?.checkout.release();
4033
+ }
4034
+ }
3255
4035
  }
3256
4036
  }
4037
+ /**
4038
+ * Close a stream whose consumer asked for nothing within its idle timeout:
4039
+ * run the dialect's own cleanup (PostgreSQL: CLOSE, COMMIT), release the
4040
+ * connection (with the cleanup's error, if any, so the pool discards it), and
4041
+ * log `stream-idle-timeout`. The log line matters: an abandoned consumer
4042
+ * never pulls again, so it never sees the E002 waiting for it.
4043
+ */
4044
+ async expireIdleStream(stream, held, action, timeoutMs, idle, unit) {
4045
+ let failure;
4046
+ try {
4047
+ await stream.return(undefined);
4048
+ }
4049
+ catch (err) {
4050
+ failure = err;
4051
+ }
4052
+ finally {
4053
+ held.checkout.release(failure === undefined ? undefined : failure instanceof Error ? failure : true);
4054
+ }
4055
+ logTo(this.logger, 'warn', `${action} on "${this.table}" had no ${unit} requested for ${timeoutMs} ms, so Turbine closed its cursor ` +
4056
+ `and released its connection (${idle.rows} ${idle.rows === 1 ? 'row' : 'rows'} handed out before that). ` +
4057
+ 'An iterator that is kept and never finished, or a response stream torn down without calling return(), ' +
4058
+ 'leaves a stream like this. The next request on it throws TimeoutError (E002).', { code: 'stream-idle-timeout', table: this.table, operation: action, timeoutMs, rows: idle.rows });
4059
+ }
3257
4060
  /**
3258
4061
  * Stream rows from a findMany query using PostgreSQL cursors, one BATCH of
3259
4062
  * rows at a time.
@@ -3289,13 +4092,24 @@ export class QueryInterface {
3289
4092
  * ```
3290
4093
  */
3291
4094
  async *findManyStreamBatches(args) {
4095
+ args = this.normalizeArgs(args);
4096
+ this.assertNoStreamLock(args, 'findManyStreamBatches');
3292
4097
  const parse = this.makeStreamRowParser(args);
3293
- for await (const batch of this.streamRaw(args, 'findManyStreamBatches')) {
4098
+ const idle = newStreamIdleState();
4099
+ for await (const batch of this.streamRaw(args, 'findManyStreamBatches', idle, 'batch')) {
3294
4100
  const parsed = new Array(batch.length);
3295
4101
  for (let i = 0; i < batch.length; i++) {
3296
4102
  parsed[i] = parse(batch[i]);
3297
4103
  }
4104
+ idle.rows += parsed.length;
4105
+ // `suspended` and `pulls` are the idle guard's whole view of the
4106
+ // consumer: two writes per yield, no timer work.
4107
+ idle.suspended = true;
3298
4108
  yield parsed;
4109
+ idle.suspended = false;
4110
+ idle.pulls++;
4111
+ if (idle.expired)
4112
+ throw idle.expired;
3299
4113
  }
3300
4114
  }
3301
4115
  /**
@@ -3334,14 +4148,25 @@ export class QueryInterface {
3334
4148
  * ```
3335
4149
  */
3336
4150
  async *findManyStream(args) {
4151
+ args = this.normalizeArgs(args);
4152
+ this.assertNoStreamLock(args, 'findManyStream');
3337
4153
  const parse = this.makeStreamRowParser(args);
3338
4154
  // Parsed one row at a time, INSIDE the yield loop rather than a batch ahead
3339
4155
  // of it, so a consumer that breaks early still pays only for the rows it
3340
4156
  // took. That laziness is the reason this loop is not written over
3341
4157
  // `findManyStreamBatches`.
3342
- for await (const batch of this.streamRaw(args, 'findManyStream')) {
4158
+ const idle = newStreamIdleState();
4159
+ for await (const batch of this.streamRaw(args, 'findManyStream', idle, 'row')) {
3343
4160
  for (let i = 0; i < batch.length; i++) {
3344
- yield parse(batch[i]);
4161
+ const row = parse(batch[i]);
4162
+ idle.rows++;
4163
+ // See findManyStreamBatches: the idle guard's view of the consumer.
4164
+ idle.suspended = true;
4165
+ yield row;
4166
+ idle.suspended = false;
4167
+ idle.pulls++;
4168
+ if (idle.expired)
4169
+ throw idle.expired;
3345
4170
  }
3346
4171
  }
3347
4172
  }
@@ -3349,28 +4174,28 @@ export class QueryInterface {
3349
4174
  // findFirst, like findMany but returns a single row or null
3350
4175
  // -------------------------------------------------------------------------
3351
4176
  async findFirst(args) {
3352
- // BEFORE the strategy branch, not only inside the builders, so the
3353
- // join-vs-batched decision and its dev warning see the same declared name
3354
- // every other stage does. `planAuto` splits `with` on the caller's keys and
3355
- // `runAutoSplit` carries them onward; each downstream consumer normalizes
3356
- // too, so this is not load-bearing for correctness (measured: removing it
3357
- // changes the warning text from the declared name back to the caller's and
3358
- // nothing else). It is here so the invariant holds at the seam rather than
3359
- // depending on every consumer to re-establish it.
3360
- args = this.normalizeArgs(args);
4177
+ // BEFORE the strategy branch, so the batched and auto paths below read the
4178
+ // same canonical args the builders do, and so an all-undefined `where` is
4179
+ // refused before anything runs (see normalizeArgs).
4180
+ args = this.normalizeArgs(args, 'findFirst');
4181
+ this.assertLockInTransaction(args, 'findFirst');
3361
4182
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
3362
4183
  if (args?.with) {
3363
4184
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
3364
4185
  // findFirst is findMany + LIMIT 1: batch the single base row, then load.
3365
4186
  if (strategy === 'batched') {
3366
- const rows = await this.runFindManyBatched({ ...args, limit: 1 });
4187
+ const rows = await this.runFindManyBatched({
4188
+ ...args,
4189
+ limit: 1,
4190
+ ...firstRowOnly(args),
4191
+ });
3367
4192
  return (rows[0] ?? null);
3368
4193
  }
3369
4194
  if (strategy === 'auto') {
3370
4195
  // findFirst is findMany + LIMIT 1: a one-row parent set.
3371
4196
  const split = this.planAuto(args.with, args.stableRelationOrder, false, 1);
3372
4197
  if (split) {
3373
- const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
4198
+ const rows = (await this.runAutoSplit({ ...args, limit: 1, ...firstRowOnly(args) }, split, false));
3374
4199
  return (rows[0] ?? null);
3375
4200
  }
3376
4201
  }
@@ -3381,8 +4206,16 @@ export class QueryInterface {
3381
4206
  });
3382
4207
  }
3383
4208
  buildFindFirst(args) {
4209
+ return this.buildFindFirstAs(args, 'findFirst');
4210
+ }
4211
+ /**
4212
+ * findFirst / findFirstOrThrow over findMany's builder with `LIMIT 1`.
4213
+ * `operation` names the method in the refusal of an all-undefined `where`.
4214
+ */
4215
+ buildFindFirstAs(args, operation) {
4216
+ args = this.normalizeArgs(args, operation);
3384
4217
  // Reuse findMany's SQL builder but force LIMIT 1
3385
- const findManyArgs = { ...args, limit: 1 };
4218
+ const findManyArgs = { ...args, limit: 1, ...firstRowOnly(args) };
3386
4219
  const deferred = this.buildFindMany(findManyArgs);
3387
4220
  return {
3388
4221
  sql: deferred.sql,
@@ -3392,12 +4225,15 @@ export class QueryInterface {
3392
4225
  return rows.length > 0 ? rows[0] : null;
3393
4226
  },
3394
4227
  tag: `${this.table}.findFirst`,
4228
+ ...(deferred.rowLock ? { rowLock: true } : {}),
3395
4229
  };
3396
4230
  }
3397
4231
  // -------------------------------------------------------------------------
3398
4232
  // findFirstOrThrow, like findFirst but throws if no record found
3399
4233
  // -------------------------------------------------------------------------
3400
4234
  async findFirstOrThrow(args) {
4235
+ args = this.normalizeArgs(args, 'findFirstOrThrow');
4236
+ this.assertLockInTransaction(args, 'findFirstOrThrow');
3401
4237
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
3402
4238
  const deferred = this.buildFindFirstOrThrow(args);
3403
4239
  const result = await this.readWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
@@ -3405,7 +4241,8 @@ export class QueryInterface {
3405
4241
  });
3406
4242
  }
3407
4243
  buildFindFirstOrThrow(args) {
3408
- const inner = this.buildFindFirst(args);
4244
+ args = this.normalizeArgs(args, 'findFirstOrThrow');
4245
+ const inner = this.buildFindFirstAs(args, 'findFirstOrThrow');
3409
4246
  return {
3410
4247
  sql: inner.sql,
3411
4248
  params: inner.params,
@@ -3421,12 +4258,15 @@ export class QueryInterface {
3421
4258
  return row;
3422
4259
  },
3423
4260
  tag: `${this.table}.findFirstOrThrow`,
4261
+ ...(inner.rowLock ? { rowLock: true } : {}),
3424
4262
  };
3425
4263
  }
3426
4264
  // -------------------------------------------------------------------------
3427
4265
  // findUniqueOrThrow, like findUnique but throws if no record found
3428
4266
  // -------------------------------------------------------------------------
3429
4267
  async findUniqueOrThrow(args) {
4268
+ args = this.normalizeArgs(args);
4269
+ this.assertLockInTransaction(args, 'findUniqueOrThrow');
3430
4270
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
3431
4271
  const deferred = this.buildFindUniqueOrThrow(args);
3432
4272
  const result = await this.readWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
@@ -3434,6 +4274,7 @@ export class QueryInterface {
3434
4274
  });
3435
4275
  }
3436
4276
  buildFindUniqueOrThrow(args) {
4277
+ args = this.normalizeArgs(args);
3437
4278
  const inner = this.buildFindUnique(args);
3438
4279
  return {
3439
4280
  sql: inner.sql,
@@ -3450,12 +4291,14 @@ export class QueryInterface {
3450
4291
  return row;
3451
4292
  },
3452
4293
  tag: `${this.table}.findUniqueOrThrow`,
4294
+ ...(inner.rowLock ? { rowLock: true } : {}),
3453
4295
  };
3454
4296
  }
3455
4297
  // -------------------------------------------------------------------------
3456
4298
  // create
3457
4299
  // -------------------------------------------------------------------------
3458
4300
  async create(args) {
4301
+ args = this.normalizeArgs(args);
3459
4302
  return this.executeWithMiddleware('create', args, async () => {
3460
4303
  if (hasRelationFields(args.data, this.tableMeta)) {
3461
4304
  return this.nestedCreate(args);
@@ -3468,16 +4311,55 @@ export class QueryInterface {
3468
4311
  // createMany, uses UNNEST for performance
3469
4312
  // -------------------------------------------------------------------------
3470
4313
  async createMany(args) {
4314
+ args = this.normalizeArgs(args);
3471
4315
  return this.executeWithMiddleware('createMany', args, async () => {
3472
- const deferred = this.buildCreateMany(args);
3473
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
3474
- return deferred.transform(result);
4316
+ const projection = this.resolveWriteProjection(args);
4317
+ const base = args;
4318
+ // One statement when the rows fit the engine's per-statement limits,
4319
+ // else chunks that do, all in one transaction so the rows land together
4320
+ // or not at all. (`buildCreateMany`, one statement for a pipeline,
4321
+ // refuses instead.)
4322
+ const { chunks, rowsPerChunk } = writesMod.planCreateMany(this.ctx, base, projection);
4323
+ const run = async () => {
4324
+ // An engine that applies two rows whose onConflict keys are equal only
4325
+ // under the column's collation (SQLite's NOCASE) as an insert and an
4326
+ // update of one row, in one statement, is caught by the transform (the
4327
+ // row comes back twice), so the statement runs in a transaction that
4328
+ // refusal rolls back. A reselect write already runs in one.
4329
+ const appliesRepeatedKeys = base.onConflict !== undefined &&
4330
+ this.dialect.capabilities.bulkUpsertAppliesRepeatedKeys &&
4331
+ this.dialect.resultStrategy !== 'reselect';
4332
+ if (chunks.length === 1 && !appliesRepeatedKeys) {
4333
+ return this.executeMutation(chunks[0], args.timeout);
4334
+ }
4335
+ const reselects = this.dialect.resultStrategy === 'reselect';
4336
+ return this.inOneTransaction(async (exec) => {
4337
+ const rows = [];
4338
+ for (const chunk of chunks) {
4339
+ const result = reselects && chunk.reselect
4340
+ ? await chunk.reselect(exec)
4341
+ : await exec(chunk.sql, chunk.params, chunk.preparedName);
4342
+ rows.push(...chunk.transform(result));
4343
+ }
4344
+ return rows;
4345
+ }, args.timeout, `createMany of ${base.data.length} rows`, `Insert at most ${rowsPerChunk} rows per call, which fits one statement, or pass a pool that can check ` +
4346
+ 'out a connection.');
4347
+ };
4348
+ if (base.onConflict === undefined)
4349
+ return run();
4350
+ try {
4351
+ return await run();
4352
+ }
4353
+ catch (err) {
4354
+ throw writesMod.explainConflictKeyCollision(this.ctx, base, err);
4355
+ }
3475
4356
  });
3476
4357
  }
3477
4358
  // -------------------------------------------------------------------------
3478
4359
  // update
3479
4360
  // -------------------------------------------------------------------------
3480
4361
  async update(args) {
4362
+ args = this.normalizeArgs(args);
3481
4363
  return this.executeWithMiddleware('update', args, async () => {
3482
4364
  if (hasRelationFields(args.data, this.tableMeta)) {
3483
4365
  return this.nestedUpdate(args);
@@ -3590,6 +4472,7 @@ export class QueryInterface {
3590
4472
  // delete
3591
4473
  // -------------------------------------------------------------------------
3592
4474
  async delete(args) {
4475
+ args = this.normalizeArgs(args);
3593
4476
  return this.executeWithMiddleware('delete', args, async () => {
3594
4477
  const deferred = this.buildDelete(args);
3595
4478
  return this.executeMutation(deferred, args.timeout);
@@ -3599,7 +4482,11 @@ export class QueryInterface {
3599
4482
  // upsert, INSERT ... ON CONFLICT ... DO UPDATE
3600
4483
  // -------------------------------------------------------------------------
3601
4484
  async upsert(args) {
4485
+ args = this.normalizeArgs(args);
3602
4486
  return this.executeWithMiddleware('upsert', args, async () => {
4487
+ // First: the nested-write branch below decides before upsertLookupReason
4488
+ // would have refused it.
4489
+ writesMod.assertNoOptimisticLock(this.ctx, args, 'upsert');
3603
4490
  if (this.upsertNeedsLookup(args)) {
3604
4491
  return this.upsertLookupFirst(args);
3605
4492
  }
@@ -3614,6 +4501,7 @@ export class QueryInterface {
3614
4501
  * an upsert or has to run the array inside a transaction.
3615
4502
  */
3616
4503
  upsertNeedsLookup(args) {
4504
+ args = this.normalizeArgs(args);
3617
4505
  return (hasRelationFields((args.create ?? {}), this.tableMeta) ||
3618
4506
  hasRelationFields((args.update ?? {}), this.tableMeta) ||
3619
4507
  writesMod.upsertLookupReason(this.ctx, args) !== null);
@@ -3642,7 +4530,11 @@ export class QueryInterface {
3642
4530
  async upsertLookupFirst(args) {
3643
4531
  const where = (args.where ?? {});
3644
4532
  const create = (args.create ?? {});
3645
- const update = (args.update ?? {});
4533
+ // `expr.excluded(field)` in `update` reads the incoming row, which here is
4534
+ // `create`: resolved to create's value before the update compiles, as the
4535
+ // single-statement path does.
4536
+ const update = (resolveExcludedFromCreate(this.ctx, (args.update ?? {}), create) ??
4537
+ {});
3646
4538
  assertMutationWhereIdentifiesOneRow(this.tableMeta, this.table, expandCompoundUniqueWhere(this.tableMeta, where), 'upsert');
3647
4539
  this.resolveWriteProjection(args);
3648
4540
  // An undefined option is an absent one on every call below.
@@ -3676,6 +4568,7 @@ export class QueryInterface {
3676
4568
  // updateMany, UPDATE ... WHERE ... returning count
3677
4569
  // -------------------------------------------------------------------------
3678
4570
  async updateMany(args) {
4571
+ args = this.normalizeArgs(args);
3679
4572
  return this.executeWithMiddleware('updateMany', args, async () => {
3680
4573
  const deferred = this.buildUpdateMany(args);
3681
4574
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
@@ -3686,6 +4579,7 @@ export class QueryInterface {
3686
4579
  // deleteMany, DELETE ... WHERE ... returning count
3687
4580
  // -------------------------------------------------------------------------
3688
4581
  async deleteMany(args) {
4582
+ args = this.normalizeArgs(args);
3689
4583
  return this.executeWithMiddleware('deleteMany', args, async () => {
3690
4584
  const deferred = this.buildDeleteMany(args);
3691
4585
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
@@ -3696,6 +4590,7 @@ export class QueryInterface {
3696
4590
  // count
3697
4591
  // -------------------------------------------------------------------------
3698
4592
  async count(args) {
4593
+ args = this.normalizeArgs(args);
3699
4594
  return this.executeWithMiddleware('count', (args ?? {}), async () => {
3700
4595
  const deferred = this.buildCount(args);
3701
4596
  const result = await this.readWithTimeout(deferred.sql, deferred.params, args?.timeout, this.preparedNameFor(args, deferred.preparedName));
@@ -3703,6 +4598,7 @@ export class QueryInterface {
3703
4598
  });
3704
4599
  }
3705
4600
  buildCount(args) {
4601
+ args = this.normalizeArgs(args);
3706
4602
  this.currentSkip = resolveSkipGlobalFilters(args?.skipGlobalFilters);
3707
4603
  const effWhere = this.mergeGlobalFilter(args?.where);
3708
4604
  const hasWhere = effWhere !== undefined;
@@ -3740,6 +4636,7 @@ export class QueryInterface {
3740
4636
  * that cannot be typed, so those columns are not projected onto the row type.
3741
4637
  */
3742
4638
  async groupBy(args) {
4639
+ args = this.normalizeArgs(args);
3743
4640
  return this.executeWithMiddleware('groupBy', args, async () => {
3744
4641
  const deferred = this.buildGroupBy(args);
3745
4642
  const result = await this.readWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
@@ -3750,15 +4647,16 @@ export class QueryInterface {
3750
4647
  // Aggregate / groupBy compilation (extracted to aggregates.ts).
3751
4648
  // ---------------------------------------------------------------------------
3752
4649
  buildGroupBy(args) {
3753
- return aggMod.buildGroupBy(this.ctx, args);
4650
+ return aggMod.buildGroupBy(this.ctx, this.normalizeArgs(args));
3754
4651
  }
3755
4652
  buildAggregate(args) {
3756
- return aggMod.buildAggregate(this.ctx, args);
4653
+ return aggMod.buildAggregate(this.ctx, this.normalizeArgs(args));
3757
4654
  }
3758
4655
  // -------------------------------------------------------------------------
3759
4656
  // aggregate, standalone aggregation without groupBy
3760
4657
  // -------------------------------------------------------------------------
3761
4658
  async aggregate(args) {
4659
+ args = this.normalizeArgs(args);
3762
4660
  return this.executeWithMiddleware('aggregate', args, async () => {
3763
4661
  const deferred = this.buildAggregate(args);
3764
4662
  const result = await this.readWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
@@ -3876,14 +4774,15 @@ export class QueryInterface {
3876
4774
  }
3877
4775
  return relationsMod.planFlattenWith(this.ctx, this.table, withClause, includePii);
3878
4776
  }
3879
- /** Dev-only once-only note that `'flatten'` was refused for the whole query. */
4777
+ /** Advice, once per reason: `'flatten'` was refused for the whole query. */
3880
4778
  warnFlattenBlocked(reason) {
3881
- if (process.env.NODE_ENV === 'production')
4779
+ const log = this.advice;
4780
+ if (!log)
3882
4781
  return;
3883
4782
  if (!shouldWarnOnce(WARN_NS.flattenFallback, `${this.table}|query|${reason}`))
3884
4783
  return;
3885
- console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage on "${this.table}": ${reason}. ` +
3886
- 'Every relation loads via the correlated subquery instead (same rows, same values, different plan).');
4784
+ logTo(log, 'warn', `relationLoadStrategy: 'flatten' did not engage on "${this.table}": ${reason}. ` +
4785
+ 'Every relation loads via the correlated subquery instead (same rows, same values, different plan).', { code: 'flatten-blocked', table: this.table, reason });
3887
4786
  }
3888
4787
  /**
3889
4788
  * Convert a field name to its snake_case column name (unquoted, for non-SQL
@@ -4018,8 +4917,8 @@ export class QueryInterface {
4018
4917
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
4019
4918
  // subquery as `_count`: mirror its global-filter params. Pick-row
4020
4919
  // ordering mirrors its full param chain (by-path / global filter /
4021
- // pick.where / pick.orderBy paths). To-one relation orderBy carries the
4022
- // target's global filter once per ordered column.
4920
+ // pick.where / pick.orderBy paths). To-one relation orderBy is mirrored
4921
+ // by running its build (see collectToOneRelationOrderParams).
4023
4922
  if (this.isRelationOrderByValue(dir)) {
4024
4923
  // Mirrors the build path's resolution, so a cache HIT binds params for
4025
4924
  // the same relation the cached SQL was built from.
@@ -4032,9 +4931,7 @@ export class QueryInterface {
4032
4931
  this.collectRelationCountParams(relDef, params);
4033
4932
  }
4034
4933
  else if (relDef) {
4035
- for (const _col of Object.keys(dir)) {
4036
- this.collectTargetGlobalFilterAlias(relDef.to, params);
4037
- }
4934
+ relationsMod.collectToOneRelationOrderParams(this.ctx, key, dir, params);
4038
4935
  }
4039
4936
  }
4040
4937
  }
@@ -4106,10 +5003,10 @@ export class QueryInterface {
4106
5003
  * one, is the acknowledgement, and the warning exists to surface an
4107
5004
  * unacknowledged trade rather than to nag.
4108
5005
  *
4109
- * NOT DEV-ONLY, unlike every other warning in this codebase, and deliberately
4110
- * so. Production is exactly where a destructive write commits and where the
4111
- * row cannot be recovered afterwards, so a warning that goes quiet under
4112
- * `NODE_ENV=production` is silent in the only place it matters. The cost is
5006
+ * NOT ADVICE: it fires whatever `diagnostics` says, deliberately. Production
5007
+ * is exactly where a destructive write commits and where the row cannot be
5008
+ * recovered afterwards, so a warning that goes quiet in production mode is
5009
+ * silent in the only place it matters. The cost is
4113
5010
  * bounded to the point of irrelevance: once per process per field, and only
4114
5011
  * on a row that actually held an infinity.
4115
5012
  *
@@ -4122,7 +5019,7 @@ export class QueryInterface {
4122
5019
  return;
4123
5020
  if (!shouldWarnOnce(WARN_NS.temporalInfinity, `${table}.${field}`))
4124
5021
  return;
4125
- console.warn(`[turbine] ${table}.${field} holds the Postgres value \`infinity\` (or \`-infinity\`). JavaScript has ` +
5022
+ logTo(this.logger, 'warn', `${table}.${field} holds the Postgres value \`infinity\` (or \`-infinity\`). JavaScript has ` +
4126
5023
  'no Date for it, so it reads as the JS number `Infinity` / `-Infinity` on a field the generated ' +
4127
5024
  'types declare as `Date`: `.toISOString()` and `.getTime()` throw a TypeError on these rows, and ' +
4128
5025
  '`JSON.stringify` still renders them null because JSON has no infinity literal. The number is the ' +
@@ -4132,7 +5029,7 @@ export class QueryInterface {
4132
5029
  "cannot tell infinity, -infinity and NULL apart. Set `temporalInfinity: 'preserve'` to confirm this " +
4133
5030
  "reading and silence the warning, or `'null'` to read null instead, accepting that a stored " +
4134
5031
  'infinity then looks exactly like a stored NULL and a read-modify-write over a nullable column ' +
4135
- 'stores SQL NULL, destroying the value with no error.');
5032
+ 'stores SQL NULL, destroying the value with no error.', { code: 'temporal-infinity', table, field });
4136
5033
  }
4137
5034
  /**
4138
5035
  * Apply the configured reading to the infinity elements of a temporal ARRAY
@@ -4212,6 +5109,64 @@ export class QueryInterface {
4212
5109
  // UTC so results don't depend on the server's time zone.
4213
5110
  return this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
4214
5111
  }
5112
+ /** The non-temporal decode kinds of `table`'s columns, by column and by field name. */
5113
+ getCellKinds(table, meta) {
5114
+ let kinds = this.cellKindCache.get(table);
5115
+ if (!kinds) {
5116
+ kinds = new Map();
5117
+ for (const column of meta.columns) {
5118
+ // Metadata may carry the type on the column or only in the table map.
5119
+ const pgType = column.pgType ?? meta.pgTypes?.[column.name];
5120
+ if (typeof pgType !== 'string')
5121
+ continue;
5122
+ let kind = cellKindOf(pgType, this.schema.enums);
5123
+ const decode = kind === CELL_PLAIN ? this.dialect.decodeColumnValue?.(pgType) : undefined;
5124
+ if (decode !== undefined) {
5125
+ kind = CELL_DIALECT;
5126
+ let decoders = this.dialectDecodeCache.get(table);
5127
+ if (!decoders) {
5128
+ decoders = new Map();
5129
+ this.dialectDecodeCache.set(table, decoders);
5130
+ }
5131
+ decoders.set(column.name, decode);
5132
+ decoders.set(column.field, decode);
5133
+ }
5134
+ if (kind === CELL_PLAIN)
5135
+ continue;
5136
+ kinds.set(column.name, kind);
5137
+ kinds.set(column.field, kind);
5138
+ }
5139
+ this.cellKindCache.set(table, kinds);
5140
+ }
5141
+ return kinds;
5142
+ }
5143
+ /** One cell's decode, for every kind but {@link CELL_PLAIN}. */
5144
+ decodeCell(kind, value, table, field) {
5145
+ switch (kind) {
5146
+ case CELL_TEMPORAL:
5147
+ return this.decodeTemporalCell(value, table, field);
5148
+ case CELL_FLOAT:
5149
+ return readJsonFloat(value);
5150
+ case CELL_FLOAT_ARRAY:
5151
+ return readJsonFloatArray(value);
5152
+ case CELL_TEXT_ARRAY:
5153
+ return readTextArray(value);
5154
+ case CELL_DIALECT:
5155
+ return this.decodeDialectCell(value, table, field);
5156
+ default:
5157
+ return value;
5158
+ }
5159
+ }
5160
+ /**
5161
+ * A {@link CELL_DIALECT} cell: the dialect's decode for the column's declared
5162
+ * type, recorded by {@link getCellKinds} when it classified the column.
5163
+ */
5164
+ decodeDialectCell(value, table, field) {
5165
+ if (value === null || value === undefined)
5166
+ return value;
5167
+ const decode = this.dialectDecodeCache.get(table)?.get(field);
5168
+ return decode ? decode(value) : value;
5169
+ }
4215
5170
  /**
4216
5171
  * Build the decode plan for one exact column list, and remember it as this
4217
5172
  * table's most recent shape so the next row of the same result set hits the
@@ -4235,16 +5190,19 @@ export class QueryInterface {
4235
5190
  // camelCase-keyed date fields, so nested json_build_object rows (whose
4236
5191
  // keys are already camelCase) get the same Date coercion as top-level rows.
4237
5192
  const camelDateFields = this.getCamelDateFields(table, meta);
5193
+ const cellKinds = this.getCellKinds(table, meta);
4238
5194
  const cols = keys.slice();
4239
5195
  const fields = [];
4240
- const dates = [];
5196
+ const kinds = [];
4241
5197
  for (const col of cols) {
4242
5198
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
4243
5199
  fields.push(field);
4244
5200
  // Top-level rows are snake_case (dateCols); nested rows are camelCase.
4245
- dates.push(dateCols.has(col) || camelDateFields.has(field));
5201
+ kinds.push(dateCols.has(col) || camelDateFields.has(field)
5202
+ ? CELL_TEMPORAL
5203
+ : (cellKinds.get(col) ?? cellKinds.get(field) ?? CELL_PLAIN));
4246
5204
  }
4247
- plan = { cols, fields, dates };
5205
+ plan = { cols, fields, kinds };
4248
5206
  this.rowPlanCache.set(shapeKey, plan);
4249
5207
  }
4250
5208
  this.rowPlanLast.set(table, plan);
@@ -4288,11 +5246,16 @@ export class QueryInterface {
4288
5246
  const plan = last !== undefined && QueryInterface.rowPlanMatches(last, keys)
4289
5247
  ? last
4290
5248
  : this.buildRowDecodePlan(table, meta, keys);
4291
- const { cols, fields, dates } = plan;
5249
+ const { cols, fields, kinds } = plan;
4292
5250
  for (let i = 0; i < cols.length; i++) {
4293
5251
  const field = fields[i];
4294
5252
  const value = row[cols[i]];
4295
- parsed[field] = dates[i] ? this.decodeTemporalCell(value, table, field) : value;
5253
+ const kind = kinds[i];
5254
+ // A float from the driver is already a number; only JSON's strings need reading.
5255
+ parsed[field] =
5256
+ kind === CELL_PLAIN || (kind === CELL_FLOAT && typeof value !== 'string')
5257
+ ? value
5258
+ : this.decodeCell(kind, value, table, field);
4296
5259
  }
4297
5260
  }
4298
5261
  else {