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,13 +12,14 @@
12
12
  * reached through the ctx. See builder.ts for the thin delegating methods and
13
13
  * the findMany/findUnique execute assembly.
14
14
  */
15
- import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
15
+ import { CircularRelationError, InvariantError, RelationError, UnsupportedFeatureError, ValidationError, } from '../errors.js';
16
16
  import { missingIndexForRelation } from '../index-advisor.js';
17
+ import { logTo } from '../logger.js';
17
18
  import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
- import { resolveCountRelations } from './batched-loader.js';
19
- import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, MAX_NAMED_ORDER_KEYS, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
19
+ import { resolveCountRelations, stitchOnlyFields } from './batched-loader.js';
20
+ import { describeOperand, isJsonPathOrderBy, isOrderBySpec, isPlainRecord, isRelationPickOrderBy, isVectorOrderBy, MAX_NAMED_ORDER_KEYS, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
20
21
  import { assertDirectionToken, assertOrderDirection } from './types.js';
21
- import { availableClause, canonicalColumnOrder, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelation, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
22
+ import { availableClause, canonicalColumnOrder, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelation, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, suggestKey, unknownFieldMessage, } from './utils.js';
22
23
  import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
23
24
  import * as whereMod from './where.js';
24
25
  import * as writesMod from './writes.js';
@@ -45,6 +46,19 @@ function projectionColumn(table, meta, field, clause) {
45
46
  throw new ValidationError(relationInProjectionMessage(table, field, clause));
46
47
  throw new ValidationError(unknownFieldMessage(table, field, meta));
47
48
  }
49
+ /**
50
+ * The E003 text for a PII-tagged column named in `select` without
51
+ * `includePii: UNSAFE`. Shared by every projection path through
52
+ * {@link resolveProjection}, so a top-level read, a `with` relation (join,
53
+ * batched, flatten and positional alike) and a single-row write refuse in the
54
+ * same words. The write path is named because it cannot pass the opt-in at all.
55
+ */
56
+ export function piiSelectRefusedMessage(table, field) {
57
+ return (`\`select\` on "${table}" names "${field}", which is tagged PII. A PII column is returned only by a read that ` +
58
+ 'passes `includePii: UNSAFE` (import { UNSAFE } from "turbine-orm"); naming it in `select` does not unlock it, ' +
59
+ 'because `select` can arrive in request data and must not be able to. A write never returns a PII column, so ' +
60
+ `read the row back with \`includePii: UNSAFE\`, or remove "${field}" from \`select\`.`);
61
+ }
48
62
  /**
49
63
  * Resolve `select` / `omit` into a list of snake_case column names, for the
50
64
  * query's own table and for a relation target alike. `null` means "no
@@ -114,8 +128,15 @@ export function resolveProjection(qi, table, meta, select, omit, includePii) {
114
128
  if (Array.isArray(select)) {
115
129
  throw new ValidationError(`"select" must be an object mapping field names to true ` + `(e.g. { id: true, name: true }), not an array.`);
116
130
  }
117
- // Only include columns where value is true. An explicit `select` naming a
118
- // PII column IS the opt-in: it comes back regardless of `includePii`.
131
+ // Only include columns where value is true.
132
+ //
133
+ // A PII-tagged column named here needs `includePii: UNSAFE`, exactly like
134
+ // the default-projection path below. `select` is ordinary request data (a
135
+ // parsed query string is a `select` object), and the `UNSAFE` symbol is the
136
+ // one thing a request cannot produce, so letting a bare `select: { ssn: true }`
137
+ // unlock the column would make the privilege option decorative. The one
138
+ // exemption is a key the batched loader added to stitch rows together
139
+ // (STITCH_ONLY_FIELDS), which it strips before the caller sees any entity.
119
140
  //
120
141
  // Emitted in the TABLE's column order, not the caller's key order, which is
121
142
  // the same order the `omit` and default branches below produce. Two reasons,
@@ -143,9 +164,17 @@ export function resolveProjection(qi, table, meta, select, omit, includePii) {
143
164
  // keys are all derived from it, so they move together. The one observable
144
165
  // consequence is that a projected row's KEY INSERTION order now follows the
145
166
  // table rather than the `select` literal.
167
+ const piiCols = includePii ? undefined : writesMod.piiColumns(qi, meta);
168
+ const stitchOnly = piiCols && piiCols.size > 0 ? stitchOnlyFields(select) : undefined;
146
169
  return canonicalColumnOrder(meta, Object.entries(select)
147
170
  .filter(([, v]) => v)
148
- .map(([k]) => projectionColumn(table, meta, k, 'select')));
171
+ .map(([k]) => {
172
+ const column = projectionColumn(table, meta, k, 'select');
173
+ if (piiCols?.has(column) && !stitchOnly?.has(k)) {
174
+ throw new ValidationError(piiSelectRefusedMessage(table, meta.reverseColumnMap[column] ?? column));
175
+ }
176
+ return column;
177
+ }));
149
178
  }
150
179
  // Default / omit-only projection: PII-tagged columns are excluded unless the
151
180
  // caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
@@ -214,6 +243,88 @@ const EMPTY_WITH_OPTIONS = Object.freeze({});
214
243
  export function relationOptions(spec) {
215
244
  return spec === true ? EMPTY_WITH_OPTIONS : spec;
216
245
  }
246
+ /** The options a relation in `with` reads; any other key is ignored by every relation strategy. */
247
+ const WITH_OPTION_KEYS = new Set([
248
+ 'with',
249
+ 'where',
250
+ 'orderBy',
251
+ 'limit',
252
+ 'offset',
253
+ 'take',
254
+ 'skip',
255
+ 'select',
256
+ 'omit',
257
+ ]);
258
+ /**
259
+ * Check one relation's `with` options. The fingerprint walk reads every level
260
+ * of every `with` on every call, whichever strategy then loads it, so this is
261
+ * the one place that sees them all. `lock` is refused: the row lock compiles to
262
+ * `FOR <mode> OF "<table>"` on the query's own table, so a lock written on a
263
+ * relation locked nothing and said nothing. Any other key no relation reads
264
+ * (`wehre`, `cursor`, `distinct`) is named once in the advice log.
265
+ */
266
+ function checkWithOptions(qi, table, relName, opts) {
267
+ for (const key in opts) {
268
+ if (WITH_OPTION_KEYS.has(key))
269
+ continue;
270
+ const value = opts[key];
271
+ if (value === undefined)
272
+ continue;
273
+ if (key === 'lock') {
274
+ throw new ValidationError(`\`lock\` is not an option of a relation in \`with\` (\`${relName}\` on "${table}"): a row lock applies to ` +
275
+ "the rows of the query's own table only, so the related rows would not be locked. Lock them with a " +
276
+ 'locking read on their own table, in the same transaction.');
277
+ }
278
+ const advice = whereMod.adviceLogger(qi);
279
+ if (!advice || !shouldWarnOnce(WARN_NS.unknownQueryOption, `${table}.with.${relName}.${key}`))
280
+ continue;
281
+ const suggestion = suggestKey(key, WITH_OPTION_KEYS);
282
+ logTo(advice, 'warn', `unknown option "${key}" on relation "${relName}" in \`with\` on ${table}, it is ignored.` +
283
+ (suggestion ? ` Did you mean "${suggestion}"?` : ''), { code: 'unknown-query-option', table, operation: 'with', key });
284
+ }
285
+ }
286
+ /**
287
+ * `_count` in `with` takes `true` (every relation) or an object naming
288
+ * relations with `true`. Anything else under a relation, `{ where }` in
289
+ * particular, was read as `true` and counted every related row.
290
+ */
291
+ function assertWithCount(table, count) {
292
+ if (count === true)
293
+ return;
294
+ if (!isPlainRecord(count)) {
295
+ throw new ValidationError(`\`_count\` in \`with\` on "${table}" takes true or an object naming relations, for example ` +
296
+ `\`_count: { posts: true }\`; received ${describeOperand(count)}.`);
297
+ }
298
+ for (const rel of Object.keys(count)) {
299
+ const value = count[rel];
300
+ if (value === undefined || typeof value === 'boolean')
301
+ continue;
302
+ throw new ValidationError(`\`_count.${rel}\` in \`with\` on "${table}" takes true; received ${describeOperand(value)}. ` +
303
+ '`_count` counts every related row. To count only the rows that match a filter, use ' +
304
+ `\`count({ where })\` on the related table.`);
305
+ }
306
+ }
307
+ /**
308
+ * A to-many relation's `offset` (`skip` is folded into it at the args seam by
309
+ * `normalizeWithPagination`, which also refuses it on a to-one relation).
310
+ */
311
+ export function relationOffset(opts) {
312
+ return opts.offset;
313
+ }
314
+ /**
315
+ * The LIMIT / OFFSET tail of a to-many relation's inner row query. Binds the
316
+ * limit, then the offset, the order {@link collectRelationSubqueryParams}
317
+ * mirrors. Without an offset the tail is the plain ` LIMIT $n` it has always
318
+ * been; with one it goes through the dialect's pagination hook, which knows
319
+ * how to write an offset with no limit.
320
+ */
321
+ function relationPagination(qi, limit, offset, params, hasOrder) {
322
+ const limitRef = limit === undefined ? undefined : qi.paginationRef(limit, params, 'relation limit');
323
+ if (offset === undefined)
324
+ return limitRef === undefined ? '' : ` LIMIT ${limitRef}`;
325
+ const offsetRef = qi.paginationRef(offset, params, 'relation offset');
326
+ return qi.buildPagination(limitRef, offsetRef, hasOrder);
327
+ }
217
328
  /**
218
329
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
219
330
  * buildSelectWithRelations / buildRelationSubquery.
@@ -235,6 +346,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
235
346
  // Reserved `_count` key, fingerprint by the selected relation set so
236
347
  // `_count: true` and `_count: { posts: true }` never share a cache entry.
237
348
  if (relName === '_count') {
349
+ assertWithCount(meta.name, spec);
238
350
  const c = spec;
239
351
  parts.push(c === true
240
352
  ? '_count(*)'
@@ -251,6 +363,8 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
251
363
  continue;
252
364
  }
253
365
  const opts = relationOptions(spec);
366
+ if (opts !== EMPTY_WITH_OPTIONS)
367
+ checkWithOptions(qi, meta.name, relName, opts);
254
368
  const subParts = [];
255
369
  // select/omit shape
256
370
  if (opts.select) {
@@ -285,6 +399,10 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
285
399
  if (opts.limit !== undefined) {
286
400
  subParts.push(qi.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
287
401
  }
402
+ const offset = relationOffset(opts);
403
+ if (offset !== undefined) {
404
+ subParts.push(qi.dialect.inlineLimitOffset ? `off=${offset}` : 'off=1');
405
+ }
288
406
  // nested with (recurse)
289
407
  if (opts.with) {
290
408
  const nested = withFingerprint(qi, opts.with, relDef.to, depth + 1);
@@ -357,6 +475,10 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
357
475
  if (opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
358
476
  params.push(qi.paginationValue(opts.limit, 'relation limit'));
359
477
  }
478
+ const m2mOffset = relationOffset(opts);
479
+ if (m2mOffset !== undefined && !qi.dialect.inlineLimitOffset) {
480
+ params.push(qi.paginationValue(m2mOffset, 'relation offset'));
481
+ }
360
482
  if (opts.with) {
361
483
  for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
362
484
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
@@ -370,7 +492,8 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
370
492
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
371
493
  const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
372
494
  const hasOrder = relOrderEntries.length > 0;
373
- const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || hasOrder);
495
+ const offset = relationOffset(opts);
496
+ const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || offset !== undefined || hasOrder);
374
497
  // Non-wrapped path: nested relations BEFORE where/limit
375
498
  if (!willWrap && opts.with) {
376
499
  for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
@@ -400,6 +523,9 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
400
523
  if (relDef.type === 'hasMany' && opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
401
524
  params.push(qi.paginationValue(opts.limit, 'relation limit'));
402
525
  }
526
+ if (relDef.type === 'hasMany' && offset !== undefined && !qi.dialect.inlineLimitOffset) {
527
+ params.push(qi.paginationValue(offset, 'relation offset'));
528
+ }
403
529
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
404
530
  if (willWrap && opts.with) {
405
531
  for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
@@ -557,14 +683,14 @@ export function isRelationOrderByValue(_qi, value) {
557
683
  }
558
684
  /**
559
685
  * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
560
- * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar, on any
561
- * other engine a caller asking for explicit nulls placement gets a clear
562
- * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
686
+ * On an engine without the `NULLS FIRST/LAST` grammar (`nullsOrderingClause`:
687
+ * PostgreSQL and SQLite have it) a caller asking for explicit nulls placement
688
+ * gets a clear {@link UnsupportedFeatureError} (E017) instead of broken SQL.
563
689
  */
564
690
  export function nullsSuffix(qi, nulls) {
565
691
  if (!nulls)
566
692
  return '';
567
- if (qi.dialect.name !== 'postgresql' && qi.dialect.name !== 'sqlite') {
693
+ if (!qi.dialect.capabilities.nullsOrderingClause) {
568
694
  throw new UnsupportedFeatureError('NULLS FIRST/LAST ordering', qi.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
569
695
  }
570
696
  return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
@@ -638,7 +764,7 @@ export function buildJsonPathOrderEntry(qi, table, meta, field, spec, prefix, pa
638
764
  // the grammar gate matches nullsSuffix.
639
765
  const nullsSql = spec.nulls
640
766
  ? nullsSuffix(qi, spec.nulls)
641
- : qi.dialect.name === 'postgresql' || qi.dialect.name === 'sqlite'
767
+ : qi.dialect.capabilities.nullsOrderingClause
642
768
  ? ' NULLS LAST'
643
769
  : '';
644
770
  return `${lhs} ${dir}${nullsSql}`;
@@ -847,6 +973,18 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
847
973
  })
848
974
  .join(', ');
849
975
  }
976
+ /**
977
+ * Param-collect mirror of a TO-ONE relation orderBy entry, done by running
978
+ * {@link buildRelationOrderBy} and discarding its SQL. The build pushes the
979
+ * target's global filter once per ordered column and, for a chained
980
+ * `{ author: { org: { name } } }` ordering, once more per hop table, in hop
981
+ * order. A hand-written mirror that pushed one filter per column bound too few
982
+ * params (and in the wrong order) as soon as a hop table had a filter, so this
983
+ * mirror is the build itself and cannot drift from it.
984
+ */
985
+ export function collectToOneRelationOrderParams(qi, relName, value, params, ctx) {
986
+ buildRelationOrderBy(qi, relName, value, 'ord0', params, ctx);
987
+ }
850
988
  /**
851
989
  * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
852
990
  * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
@@ -946,7 +1084,7 @@ export function buildRelationPickOrderBy(qi, relName, relDef, spec, alias, paren
946
1084
  // the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
947
1085
  const nullsSql = spec.nulls
948
1086
  ? nullsSuffix(qi, spec.nulls)
949
- : qi.dialect.name === 'postgresql' || qi.dialect.name === 'sqlite'
1087
+ : qi.dialect.capabilities.nullsOrderingClause
950
1088
  ? ' NULLS LAST'
951
1089
  : '';
952
1090
  // Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
@@ -1109,14 +1247,18 @@ export function collectRelationOrderParams(qi, targetTable, targetMeta, orderEnt
1109
1247
  if (isRelationPickOrderBy(dirValue)) {
1110
1248
  throw pickOrderNestedError(qi, key);
1111
1249
  }
1112
- const relDef = ownLookup(targetMeta.relations, key);
1250
+ // resolveRelationDef, as buildRelationOrderBy resolves it, so a relation
1251
+ // written in its table spelling binds its params on a cache hit too.
1252
+ const relDef = resolveRelationDef(targetMeta.relations, key);
1113
1253
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
1114
1254
  collectRelationCountParams(qi, relDef, params);
1115
1255
  }
1116
1256
  else if (relDef) {
1117
- for (const _col of Object.keys(dirValue)) {
1118
- whereMod.collectTargetGlobalFilterAlias(qi, relDef.to, params);
1119
- }
1257
+ collectToOneRelationOrderParams(qi, key, dirValue, params, {
1258
+ meta: targetMeta,
1259
+ table: targetTable,
1260
+ parentRef: targetTable,
1261
+ });
1120
1262
  }
1121
1263
  continue;
1122
1264
  }
@@ -1171,26 +1313,27 @@ export function buildRelationCountExpr(qi, relDef, parentRef, alias, params) {
1171
1313
  * {@link collectManyToManyTargetGlobalFilter}.
1172
1314
  */
1173
1315
  export function manyToManyTargetGlobalFilterExists(qi, relDef, alias, jalias, params) {
1174
- const gf = whereMod.resolveGlobalFilter(qi, relDef.to);
1175
- if (!gf || !relDef.through)
1316
+ const through = relDef.through;
1317
+ if (!through)
1176
1318
  return '';
1177
1319
  const tMeta = qi.schema.tables[relDef.to];
1178
1320
  if (!tMeta || tMeta.primaryKey.length === 0)
1179
1321
  return '';
1180
- const talias = `${alias}t`;
1181
- const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
1322
+ const targetKeys = normalizeKeyColumns(through.targetKey);
1182
1323
  const pk = tMeta.primaryKey;
1183
1324
  if (targetKeys.length !== pk.length)
1184
1325
  return '';
1185
- const join = targetKeys.map((jc, i) => `${talias}.${qi.q(pk[i])} = ${jalias}.${qi.q(jc)}`).join(' AND ');
1186
- const gfClause = whereMod.buildAliasWhere(qi, relDef.to, tMeta, talias, gf, params);
1187
- const gfAnd = gfClause ? ` AND ${gfClause}` : '';
1188
- return `EXISTS (SELECT 1 FROM ${qi.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
1326
+ return whereMod.withTargetGlobalFilter(qi, relDef.to, '', (gf) => {
1327
+ const talias = `${alias}t`;
1328
+ const join = targetKeys.map((jc, i) => `${talias}.${qi.q(pk[i])} = ${jalias}.${qi.q(jc)}`).join(' AND ');
1329
+ const gfClause = whereMod.buildAliasWhere(qi, relDef.to, tMeta, talias, gf, params);
1330
+ const gfAnd = gfClause ? ` AND ${gfClause}` : '';
1331
+ return `EXISTS (SELECT 1 FROM ${qi.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
1332
+ });
1189
1333
  }
1190
1334
  /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
1191
1335
  export function collectManyToManyTargetGlobalFilter(qi, relDef, params) {
1192
- const gf = whereMod.resolveGlobalFilter(qi, relDef.to);
1193
- if (!gf || !relDef.through)
1336
+ if (!relDef.through)
1194
1337
  return;
1195
1338
  const tMeta = qi.schema.tables[relDef.to];
1196
1339
  if (!tMeta || tMeta.primaryKey.length === 0)
@@ -1198,7 +1341,7 @@ export function collectManyToManyTargetGlobalFilter(qi, relDef, params) {
1198
1341
  const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
1199
1342
  if (targetKeys.length !== tMeta.primaryKey.length)
1200
1343
  return;
1201
- whereMod.collectAliasWhereParams(qi, relDef.to, tMeta, gf, params);
1344
+ whereMod.withTargetGlobalFilter(qi, relDef.to, false, (gf) => whereMod.collectAliasWhereParams(qi, relDef.to, tMeta, gf, params));
1202
1345
  }
1203
1346
  /**
1204
1347
  * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
@@ -1333,13 +1476,15 @@ export function parseNestedRow(qi, row, table, fromJson = false) {
1333
1476
  // find it.
1334
1477
  //
1335
1478
  // The warn is gated and deduped because this sits in parseNestedRow's
1336
- // per-relation loop: one 10,000-row page was 10,000 console.warn calls.
1337
- if (process.env.NODE_ENV !== 'production' &&
1338
- shouldWarnOnce(WARN_NS.relationParseFailure, `${qi.table}.${relName}`)) {
1339
- console.warn(`[turbine] Relation "${relName}" on "${qi.table}" returned a payload that is not valid JSON. ` +
1340
- `This is a bug; please report it with the emitted SQL.`);
1479
+ // per-relation loop: one 10,000-row page was 10,000 warnings. The
1480
+ // parse error stays out of the entry: its message quotes the payload,
1481
+ // which is row data.
1482
+ const log = whereMod.adviceLogger(qi);
1483
+ if (log && shouldWarnOnce(WARN_NS.relationParseFailure, `${qi.table}.${relName}`)) {
1484
+ logTo(log, 'warn', `Relation "${relName}" on "${qi.table}" returned a payload that is not valid JSON. ` +
1485
+ `This is a bug; please report it with the emitted SQL.`, { code: 'relation-payload-json', table: qi.table, relation: relName });
1341
1486
  }
1342
- throw new ValidationError(`Relation "${relName}" on table "${qi.table}" returned a payload that could not be parsed as JSON.`, { cause: err });
1487
+ throw new InvariantError('relation-payload-json', `Relation "${relName}" on table "${qi.table}" returned a payload that could not be parsed as JSON.`, { cause: err });
1343
1488
  }
1344
1489
  }
1345
1490
  else if (Array.isArray(rawValue)) {
@@ -1834,7 +1979,7 @@ function flattenNodeSignature(node) {
1834
1979
  return `${node.relName}@${node.alias}(${node.cols.map(([col]) => col).join(',')})${slots}`;
1835
1980
  }
1836
1981
  /**
1837
- * Dev-only, once-per-(table, relation, reason) note that an explicitly
1982
+ * Per-query advice, once per (table, relation, reason): an explicitly
1838
1983
  * requested `relationLoadStrategy: 'flatten'` did NOT engage for a relation.
1839
1984
  *
1840
1985
  * Worth its own warning because the fallback is otherwise INVISIBLE: the query
@@ -1843,21 +1988,23 @@ function flattenNodeSignature(node) {
1843
1988
  * Silence there is indistinguishable from the strategy being a no-op, which is
1844
1989
  * exactly the shape of thing that gets filed as a bug.
1845
1990
  *
1846
- * Suppressed in production like the other planner notes, and deduped through
1847
- * the shared registry so a hot query logs once rather than per call. Note that
1848
- * a flatten plan is recomputed on the build, param-collect and row-assembly
1849
- * paths for the same query; the registry is what keeps that to one line.
1991
+ * Off when `diagnostics` turn advice off, like the other planner notes, and
1992
+ * deduped through the shared registry so a hot query logs once rather than per
1993
+ * call. Note that a flatten plan is recomputed on the build, param-collect and
1994
+ * row-assembly paths for the same query; the registry is what keeps that to
1995
+ * one line.
1850
1996
  */
1851
- function warnFlattenFallback(table, rejects) {
1997
+ function warnFlattenFallback(qi, table, rejects) {
1852
1998
  if (rejects.length === 0)
1853
1999
  return;
1854
- if (process.env.NODE_ENV === 'production')
2000
+ const log = whereMod.adviceLogger(qi);
2001
+ if (!log)
1855
2002
  return;
1856
2003
  for (const { relation, reason } of rejects) {
1857
2004
  if (!shouldWarnOnce(WARN_NS.flattenFallback, `${table}.${relation}|${reason}`))
1858
2005
  continue;
1859
- console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
1860
- `${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`);
2006
+ logTo(log, 'warn', `relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
2007
+ `${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`, { code: 'flatten-fallback', table, relation, reason });
1861
2008
  }
1862
2009
  }
1863
2010
  /**
@@ -1888,7 +2035,7 @@ export function planFlattenWith(qi, table, withClause, includePii) {
1888
2035
  if (node)
1889
2036
  nodes[relName] = node;
1890
2037
  }
1891
- warnFlattenFallback(table, rejects);
2038
+ warnFlattenFallback(qi, table, rejects);
1892
2039
  if (Object.keys(nodes).length === 0)
1893
2040
  return null;
1894
2041
  // Flat rows carry the root table's own columns, one column per non-flattened
@@ -1898,7 +2045,7 @@ export function planFlattenWith(qi, table, withClause, includePii) {
1898
2045
  const reserved = new Set([...meta.allColumns, ...Object.keys(withClause)]);
1899
2046
  for (const alias of aliases) {
1900
2047
  if (reserved.has(alias)) {
1901
- warnFlattenFallback(table, [
2048
+ warnFlattenFallback(qi, table, [
1902
2049
  {
1903
2050
  relation: Object.keys(nodes).join(', '),
1904
2051
  reason: `the projected alias "${alias}" collides with a column or relation name on "${table}"`,
@@ -2171,11 +2318,12 @@ export function buildSelectWithRelations(qi, table, withClause, params, columnsL
2171
2318
  const meta = qi.schema.tables[table];
2172
2319
  if (!meta)
2173
2320
  throw new ValidationError(`Unknown table "${table}"`);
2174
- // Positional JSON encoding is Postgres-only in v1. Gate here, the single
2175
- // entry point for every `with` clause, so no engine ever emits the
2176
- // json_build_array shape its dialect can't produce (and mssql's FOR JSON
2177
- // override path is never reached with positional active).
2178
- if (qi.jsonEncoding === 'positional' && qi.dialect.name !== 'postgresql') {
2321
+ // Positional JSON encoding needs `positionalJson`, the flag the default in
2322
+ // builder.ts reads too, so the default never picks an encoding refused here.
2323
+ // Gate here, the single entry point for every `with` clause, so no engine
2324
+ // ever emits the json_build_array shape its dialect can't produce (and
2325
+ // mssql's FOR JSON override path is never reached with positional active).
2326
+ if (qi.jsonEncoding === 'positional' && !qi.dialect.capabilities.positionalJson) {
2179
2327
  throw new UnsupportedFeatureError("jsonEncoding: 'positional'", qi.dialect.name, 'Positional relation encoding is only available on PostgreSQL in this version.');
2180
2328
  }
2181
2329
  const cols = columnsList ?? meta.allColumns;
@@ -2329,12 +2477,13 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2329
2477
  // The raw `spec` is still what crosses the dialect seam below, since
2330
2478
  // RelationSubqueryContext is a published contract.
2331
2479
  const opts = relationOptions(spec);
2332
- // Dev-only: correlated relation loading probes the child table once per parent
2333
- // row, so a missing FK index multiplies into per-parent full-table scans (a
2334
- // batched-loader ORM pays the same missing index only once, which is why
2335
- // schemas migrated from one often lack these). Name the exact index to create
2336
- // instead of letting the slowness look like an ORM problem.
2337
- if (process.env.NODE_ENV !== 'production') {
2480
+ // Per-query advice: correlated relation loading probes the child table once
2481
+ // per parent row, so a missing FK index multiplies into per-parent full-table
2482
+ // scans (a batched-loader ORM pays the same missing index only once, which is
2483
+ // why schemas migrated from one often lack these). Name the exact index to
2484
+ // create instead of letting the slowness look like an ORM problem.
2485
+ const log = whereMod.adviceLogger(qi);
2486
+ if (log) {
2338
2487
  const warnKey = `${relDef.from}.${relDef.name}`;
2339
2488
  // Compute the (potentially costly) probe only for a key not yet warned, and
2340
2489
  // claim the key through the process-wide registry ONLY when we actually warn,
@@ -2343,10 +2492,16 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2343
2492
  if (!hasWarnedOnce(WARN_NS.unindexedRelation, warnKey)) {
2344
2493
  const miss = missingIndexForRelation(qi.schema, relDef);
2345
2494
  if (miss && shouldWarnOnce(WARN_NS.unindexedRelation, warnKey)) {
2346
- console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
2495
+ logTo(log, 'warn', `Relation "${relDef.name}" on "${relDef.from}" probes ` +
2347
2496
  `"${miss.table}"(${miss.columns.join(', ')}) which has no covering index, ` +
2348
2497
  `each parent row scans the full table. Fix: ${miss.createSql}; ` +
2349
- 'or run `npx turbine doctor` for a full report.');
2498
+ 'or run `npx turbine doctor` for a full report.', {
2499
+ code: 'unindexed-relation',
2500
+ table: relDef.from,
2501
+ relation: relDef.name,
2502
+ probeTable: miss.table,
2503
+ columns: miss.columns,
2504
+ });
2350
2505
  }
2351
2506
  }
2352
2507
  }
@@ -2416,7 +2571,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2416
2571
  // it must neither trigger the wrap (dropping nested relations) nor render a
2417
2572
  // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
2418
2573
  const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
2419
- const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || relOrderEntries.length > 0);
2574
+ const offset = relationOffset(opts);
2575
+ const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || offset !== undefined || relOrderEntries.length > 0);
2420
2576
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
2421
2577
  // where, orderBy, and select/omit are handled there (the target alias is the
2422
2578
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -2484,8 +2640,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2484
2640
  // (and shifts every later placeholder by one). To-one relations ignore limit.
2485
2641
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
2486
2642
  let limitClause = '';
2487
- if (relDef.type === 'hasMany' && opts.limit !== undefined) {
2488
- limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
2643
+ if (relDef.type === 'hasMany') {
2644
+ limitClause = relationPagination(qi, opts.limit, offset, params, relOrderEntries.length > 0);
2489
2645
  }
2490
2646
  if (relDef.type === 'hasMany') {
2491
2647
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -2603,11 +2759,8 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2603
2759
  const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, talias, params);
2604
2760
  if (gfExtra)
2605
2761
  whereClause += ` AND ${gfExtra}`;
2606
- // LIMIT, `limit: 0` is honored (LIMIT 0 → empty array)
2607
- let limitClause = '';
2608
- if (opts.limit !== undefined) {
2609
- limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
2610
- }
2762
+ // LIMIT / OFFSET, `limit: 0` is honored (LIMIT 0 → empty array)
2763
+ const limitClause = relationPagination(qi, opts.limit, relationOffset(opts), params, relOrderEntries.length > 0);
2611
2764
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
2612
2765
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
2613
2766
  // so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
@@ -0,0 +1,49 @@
1
+ /**
2
+ * turbine-orm: the `lock` read option (`SELECT ... FOR UPDATE OF "t"`).
3
+ *
4
+ * {@link resolveRowLock} is the one place a `lock` value is validated and
5
+ * rendered. The SQL build and the cache key both read its result, so the two
6
+ * cannot disagree about which lock a statement carries.
7
+ *
8
+ * The clause always names the queried table (`OF "t"`). That keeps the lock on
9
+ * the returned rows of that table and nothing else, on every relation
10
+ * strategy: correlated `with` subqueries are never locked anyway, and without
11
+ * `OF` PostgreSQL refuses the `flatten` and lateral-pick plans (their outer
12
+ * joins would put a table on the nullable side of the lock).
13
+ */
14
+ import type { Dialect } from '../dialect.js';
15
+ /** A validated `lock`: the clause to append after pagination, and its cache-key segment. */
16
+ export interface ResolvedRowLock {
17
+ /** e.g. ` FOR UPDATE OF "jobs" SKIP LOCKED` */
18
+ readonly sql: string;
19
+ /** e.g. `u.sl`; one value per distinct clause, never empty. */
20
+ readonly fp: string;
21
+ }
22
+ /**
23
+ * Validate `lock` for a read on `table` and render its clause.
24
+ *
25
+ * `undefined` means no lock. Anything that is not a mode string or a
26
+ * `{ mode, noWait?, skipLocked? }` object is `ValidationError` (E003), and so
27
+ * is asking for both wait policies at once. A mode the dialect does not list in
28
+ * {@link Dialect.rowLockModes} is `UnsupportedFeatureError` (E017).
29
+ */
30
+ export declare function resolveRowLock(lock: unknown, dialect: Dialect, table: string): ResolvedRowLock | undefined;
31
+ /**
32
+ * The refusal for a locking read outside a transaction. In autocommit the lock
33
+ * is released when the statement ends, before the caller sees the rows, so a
34
+ * `skipLocked` queue claim outside a transaction claims nothing, silently.
35
+ */
36
+ export declare function lockOutsideTransactionMessage(table: string, operation: string): string;
37
+ /** The refusal for `lock` on a streaming read. */
38
+ export declare function lockOnStreamMessage(table: string, operation: string): string;
39
+ /** The refusal for `lock` together with `distinct`. */
40
+ export declare function lockWithDistinctMessage(table: string): string;
41
+ /**
42
+ * Refuse a locking read in a pipeline that runs each statement in autocommit
43
+ * (`pipeline(..., { transactional: false })`). A transactional pipeline, and
44
+ * `$transaction([...])`, keep the lock for the rest of the batch.
45
+ */
46
+ export declare function assertNoLockInAutocommitPipeline(queries: readonly {
47
+ rowLock?: true;
48
+ tag?: string;
49
+ }[]): void;