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
@@ -47,9 +47,11 @@ var __importStar = (this && this.__importStar) || (function () {
47
47
  };
48
48
  })();
49
49
  Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.piiSelectRefusedMessage = piiSelectRefusedMessage;
50
51
  exports.resolveProjection = resolveProjection;
51
52
  exports.resolveColumns = resolveColumns;
52
53
  exports.relationOptions = relationOptions;
54
+ exports.relationOffset = relationOffset;
53
55
  exports.withFingerprint = withFingerprint;
54
56
  exports.collectWithParams = collectWithParams;
55
57
  exports.collectRelationSubqueryParams = collectRelationSubqueryParams;
@@ -61,6 +63,7 @@ exports.resolveOrderByColumn = resolveOrderByColumn;
61
63
  exports.validateJsonPathOrderBy = validateJsonPathOrderBy;
62
64
  exports.buildJsonPathOrderEntry = buildJsonPathOrderEntry;
63
65
  exports.buildRelationOrderBy = buildRelationOrderBy;
66
+ exports.collectToOneRelationOrderParams = collectToOneRelationOrderParams;
64
67
  exports.pickOrderNestedError = pickOrderNestedError;
65
68
  exports.validatePickOrderBy = validatePickOrderBy;
66
69
  exports.buildRelationPickOrderBy = buildRelationPickOrderBy;
@@ -94,6 +97,7 @@ exports.buildRelationSubquery = buildRelationSubquery;
94
97
  exports.buildManyToManySubquery = buildManyToManySubquery;
95
98
  const errors_js_1 = require("../errors.js");
96
99
  const index_advisor_js_1 = require("../index-advisor.js");
100
+ const logger_js_1 = require("../logger.js");
97
101
  const schema_js_1 = require("../schema.js");
98
102
  const batched_loader_js_1 = require("./batched-loader.js");
99
103
  const filters_js_1 = require("./filters.js");
@@ -125,6 +129,19 @@ function projectionColumn(table, meta, field, clause) {
125
129
  throw new errors_js_1.ValidationError((0, utils_js_1.relationInProjectionMessage)(table, field, clause));
126
130
  throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(table, field, meta));
127
131
  }
132
+ /**
133
+ * The E003 text for a PII-tagged column named in `select` without
134
+ * `includePii: UNSAFE`. Shared by every projection path through
135
+ * {@link resolveProjection}, so a top-level read, a `with` relation (join,
136
+ * batched, flatten and positional alike) and a single-row write refuse in the
137
+ * same words. The write path is named because it cannot pass the opt-in at all.
138
+ */
139
+ function piiSelectRefusedMessage(table, field) {
140
+ return (`\`select\` on "${table}" names "${field}", which is tagged PII. A PII column is returned only by a read that ` +
141
+ 'passes `includePii: UNSAFE` (import { UNSAFE } from "turbine-orm"); naming it in `select` does not unlock it, ' +
142
+ 'because `select` can arrive in request data and must not be able to. A write never returns a PII column, so ' +
143
+ `read the row back with \`includePii: UNSAFE\`, or remove "${field}" from \`select\`.`);
144
+ }
128
145
  /**
129
146
  * Resolve `select` / `omit` into a list of snake_case column names, for the
130
147
  * query's own table and for a relation target alike. `null` means "no
@@ -194,8 +211,15 @@ function resolveProjection(qi, table, meta, select, omit, includePii) {
194
211
  if (Array.isArray(select)) {
195
212
  throw new errors_js_1.ValidationError(`"select" must be an object mapping field names to true ` + `(e.g. { id: true, name: true }), not an array.`);
196
213
  }
197
- // Only include columns where value is true. An explicit `select` naming a
198
- // PII column IS the opt-in: it comes back regardless of `includePii`.
214
+ // Only include columns where value is true.
215
+ //
216
+ // A PII-tagged column named here needs `includePii: UNSAFE`, exactly like
217
+ // the default-projection path below. `select` is ordinary request data (a
218
+ // parsed query string is a `select` object), and the `UNSAFE` symbol is the
219
+ // one thing a request cannot produce, so letting a bare `select: { ssn: true }`
220
+ // unlock the column would make the privilege option decorative. The one
221
+ // exemption is a key the batched loader added to stitch rows together
222
+ // (STITCH_ONLY_FIELDS), which it strips before the caller sees any entity.
199
223
  //
200
224
  // Emitted in the TABLE's column order, not the caller's key order, which is
201
225
  // the same order the `omit` and default branches below produce. Two reasons,
@@ -223,9 +247,17 @@ function resolveProjection(qi, table, meta, select, omit, includePii) {
223
247
  // keys are all derived from it, so they move together. The one observable
224
248
  // consequence is that a projected row's KEY INSERTION order now follows the
225
249
  // table rather than the `select` literal.
250
+ const piiCols = includePii ? undefined : writesMod.piiColumns(qi, meta);
251
+ const stitchOnly = piiCols && piiCols.size > 0 ? (0, batched_loader_js_1.stitchOnlyFields)(select) : undefined;
226
252
  return (0, utils_js_1.canonicalColumnOrder)(meta, Object.entries(select)
227
253
  .filter(([, v]) => v)
228
- .map(([k]) => projectionColumn(table, meta, k, 'select')));
254
+ .map(([k]) => {
255
+ const column = projectionColumn(table, meta, k, 'select');
256
+ if (piiCols?.has(column) && !stitchOnly?.has(k)) {
257
+ throw new errors_js_1.ValidationError(piiSelectRefusedMessage(table, meta.reverseColumnMap[column] ?? column));
258
+ }
259
+ return column;
260
+ }));
229
261
  }
230
262
  // Default / omit-only projection: PII-tagged columns are excluded unless the
231
263
  // caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
@@ -294,6 +326,88 @@ const EMPTY_WITH_OPTIONS = Object.freeze({});
294
326
  function relationOptions(spec) {
295
327
  return spec === true ? EMPTY_WITH_OPTIONS : spec;
296
328
  }
329
+ /** The options a relation in `with` reads; any other key is ignored by every relation strategy. */
330
+ const WITH_OPTION_KEYS = new Set([
331
+ 'with',
332
+ 'where',
333
+ 'orderBy',
334
+ 'limit',
335
+ 'offset',
336
+ 'take',
337
+ 'skip',
338
+ 'select',
339
+ 'omit',
340
+ ]);
341
+ /**
342
+ * Check one relation's `with` options. The fingerprint walk reads every level
343
+ * of every `with` on every call, whichever strategy then loads it, so this is
344
+ * the one place that sees them all. `lock` is refused: the row lock compiles to
345
+ * `FOR <mode> OF "<table>"` on the query's own table, so a lock written on a
346
+ * relation locked nothing and said nothing. Any other key no relation reads
347
+ * (`wehre`, `cursor`, `distinct`) is named once in the advice log.
348
+ */
349
+ function checkWithOptions(qi, table, relName, opts) {
350
+ for (const key in opts) {
351
+ if (WITH_OPTION_KEYS.has(key))
352
+ continue;
353
+ const value = opts[key];
354
+ if (value === undefined)
355
+ continue;
356
+ if (key === 'lock') {
357
+ throw new errors_js_1.ValidationError(`\`lock\` is not an option of a relation in \`with\` (\`${relName}\` on "${table}"): a row lock applies to ` +
358
+ "the rows of the query's own table only, so the related rows would not be locked. Lock them with a " +
359
+ 'locking read on their own table, in the same transaction.');
360
+ }
361
+ const advice = whereMod.adviceLogger(qi);
362
+ if (!advice || !(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.unknownQueryOption, `${table}.with.${relName}.${key}`))
363
+ continue;
364
+ const suggestion = (0, utils_js_1.suggestKey)(key, WITH_OPTION_KEYS);
365
+ (0, logger_js_1.logTo)(advice, 'warn', `unknown option "${key}" on relation "${relName}" in \`with\` on ${table}, it is ignored.` +
366
+ (suggestion ? ` Did you mean "${suggestion}"?` : ''), { code: 'unknown-query-option', table, operation: 'with', key });
367
+ }
368
+ }
369
+ /**
370
+ * `_count` in `with` takes `true` (every relation) or an object naming
371
+ * relations with `true`. Anything else under a relation, `{ where }` in
372
+ * particular, was read as `true` and counted every related row.
373
+ */
374
+ function assertWithCount(table, count) {
375
+ if (count === true)
376
+ return;
377
+ if (!(0, filters_js_1.isPlainRecord)(count)) {
378
+ throw new errors_js_1.ValidationError(`\`_count\` in \`with\` on "${table}" takes true or an object naming relations, for example ` +
379
+ `\`_count: { posts: true }\`; received ${(0, filters_js_1.describeOperand)(count)}.`);
380
+ }
381
+ for (const rel of Object.keys(count)) {
382
+ const value = count[rel];
383
+ if (value === undefined || typeof value === 'boolean')
384
+ continue;
385
+ throw new errors_js_1.ValidationError(`\`_count.${rel}\` in \`with\` on "${table}" takes true; received ${(0, filters_js_1.describeOperand)(value)}. ` +
386
+ '`_count` counts every related row. To count only the rows that match a filter, use ' +
387
+ `\`count({ where })\` on the related table.`);
388
+ }
389
+ }
390
+ /**
391
+ * A to-many relation's `offset` (`skip` is folded into it at the args seam by
392
+ * `normalizeWithPagination`, which also refuses it on a to-one relation).
393
+ */
394
+ function relationOffset(opts) {
395
+ return opts.offset;
396
+ }
397
+ /**
398
+ * The LIMIT / OFFSET tail of a to-many relation's inner row query. Binds the
399
+ * limit, then the offset, the order {@link collectRelationSubqueryParams}
400
+ * mirrors. Without an offset the tail is the plain ` LIMIT $n` it has always
401
+ * been; with one it goes through the dialect's pagination hook, which knows
402
+ * how to write an offset with no limit.
403
+ */
404
+ function relationPagination(qi, limit, offset, params, hasOrder) {
405
+ const limitRef = limit === undefined ? undefined : qi.paginationRef(limit, params, 'relation limit');
406
+ if (offset === undefined)
407
+ return limitRef === undefined ? '' : ` LIMIT ${limitRef}`;
408
+ const offsetRef = qi.paginationRef(offset, params, 'relation offset');
409
+ return qi.buildPagination(limitRef, offsetRef, hasOrder);
410
+ }
297
411
  /**
298
412
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
299
413
  * buildSelectWithRelations / buildRelationSubquery.
@@ -315,6 +429,7 @@ function withFingerprint(qi, withClause, table, depth = 0) {
315
429
  // Reserved `_count` key, fingerprint by the selected relation set so
316
430
  // `_count: true` and `_count: { posts: true }` never share a cache entry.
317
431
  if (relName === '_count') {
432
+ assertWithCount(meta.name, spec);
318
433
  const c = spec;
319
434
  parts.push(c === true
320
435
  ? '_count(*)'
@@ -331,6 +446,8 @@ function withFingerprint(qi, withClause, table, depth = 0) {
331
446
  continue;
332
447
  }
333
448
  const opts = relationOptions(spec);
449
+ if (opts !== EMPTY_WITH_OPTIONS)
450
+ checkWithOptions(qi, meta.name, relName, opts);
334
451
  const subParts = [];
335
452
  // select/omit shape
336
453
  if (opts.select) {
@@ -365,6 +482,10 @@ function withFingerprint(qi, withClause, table, depth = 0) {
365
482
  if (opts.limit !== undefined) {
366
483
  subParts.push(qi.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
367
484
  }
485
+ const offset = relationOffset(opts);
486
+ if (offset !== undefined) {
487
+ subParts.push(qi.dialect.inlineLimitOffset ? `off=${offset}` : 'off=1');
488
+ }
368
489
  // nested with (recurse)
369
490
  if (opts.with) {
370
491
  const nested = withFingerprint(qi, opts.with, relDef.to, depth + 1);
@@ -437,6 +558,10 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
437
558
  if (opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
438
559
  params.push(qi.paginationValue(opts.limit, 'relation limit'));
439
560
  }
561
+ const m2mOffset = relationOffset(opts);
562
+ if (m2mOffset !== undefined && !qi.dialect.inlineLimitOffset) {
563
+ params.push(qi.paginationValue(m2mOffset, 'relation offset'));
564
+ }
440
565
  if (opts.with) {
441
566
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(opts.with)) {
442
567
  const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
@@ -450,7 +575,8 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
450
575
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
451
576
  const relOrderEntries = opts.orderBy ? (0, filters_js_1.orderByEntries)(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
452
577
  const hasOrder = relOrderEntries.length > 0;
453
- const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || hasOrder);
578
+ const offset = relationOffset(opts);
579
+ const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || offset !== undefined || hasOrder);
454
580
  // Non-wrapped path: nested relations BEFORE where/limit
455
581
  if (!willWrap && opts.with) {
456
582
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(opts.with)) {
@@ -480,6 +606,9 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
480
606
  if (relDef.type === 'hasMany' && opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
481
607
  params.push(qi.paginationValue(opts.limit, 'relation limit'));
482
608
  }
609
+ if (relDef.type === 'hasMany' && offset !== undefined && !qi.dialect.inlineLimitOffset) {
610
+ params.push(qi.paginationValue(offset, 'relation offset'));
611
+ }
483
612
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
484
613
  if (willWrap && opts.with) {
485
614
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(opts.with)) {
@@ -637,14 +766,14 @@ function isRelationOrderByValue(_qi, value) {
637
766
  }
638
767
  /**
639
768
  * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
640
- * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar, on any
641
- * other engine a caller asking for explicit nulls placement gets a clear
642
- * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
769
+ * On an engine without the `NULLS FIRST/LAST` grammar (`nullsOrderingClause`:
770
+ * PostgreSQL and SQLite have it) a caller asking for explicit nulls placement
771
+ * gets a clear {@link UnsupportedFeatureError} (E017) instead of broken SQL.
643
772
  */
644
773
  function nullsSuffix(qi, nulls) {
645
774
  if (!nulls)
646
775
  return '';
647
- if (qi.dialect.name !== 'postgresql' && qi.dialect.name !== 'sqlite') {
776
+ if (!qi.dialect.capabilities.nullsOrderingClause) {
648
777
  throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST/LAST ordering', qi.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
649
778
  }
650
779
  return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
@@ -718,7 +847,7 @@ function buildJsonPathOrderEntry(qi, table, meta, field, spec, prefix, params) {
718
847
  // the grammar gate matches nullsSuffix.
719
848
  const nullsSql = spec.nulls
720
849
  ? nullsSuffix(qi, spec.nulls)
721
- : qi.dialect.name === 'postgresql' || qi.dialect.name === 'sqlite'
850
+ : qi.dialect.capabilities.nullsOrderingClause
722
851
  ? ' NULLS LAST'
723
852
  : '';
724
853
  return `${lhs} ${dir}${nullsSql}`;
@@ -927,6 +1056,18 @@ function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lateralSin
927
1056
  })
928
1057
  .join(', ');
929
1058
  }
1059
+ /**
1060
+ * Param-collect mirror of a TO-ONE relation orderBy entry, done by running
1061
+ * {@link buildRelationOrderBy} and discarding its SQL. The build pushes the
1062
+ * target's global filter once per ordered column and, for a chained
1063
+ * `{ author: { org: { name } } }` ordering, once more per hop table, in hop
1064
+ * order. A hand-written mirror that pushed one filter per column bound too few
1065
+ * params (and in the wrong order) as soon as a hop table had a filter, so this
1066
+ * mirror is the build itself and cannot drift from it.
1067
+ */
1068
+ function collectToOneRelationOrderParams(qi, relName, value, params, ctx) {
1069
+ buildRelationOrderBy(qi, relName, value, 'ord0', params, ctx);
1070
+ }
930
1071
  /**
931
1072
  * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
932
1073
  * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
@@ -1026,7 +1167,7 @@ function buildRelationPickOrderBy(qi, relName, relDef, spec, alias, parentRef, p
1026
1167
  // the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
1027
1168
  const nullsSql = spec.nulls
1028
1169
  ? nullsSuffix(qi, spec.nulls)
1029
- : qi.dialect.name === 'postgresql' || qi.dialect.name === 'sqlite'
1170
+ : qi.dialect.capabilities.nullsOrderingClause
1030
1171
  ? ' NULLS LAST'
1031
1172
  : '';
1032
1173
  // Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
@@ -1189,14 +1330,18 @@ function collectRelationOrderParams(qi, targetTable, targetMeta, orderEntries, p
1189
1330
  if ((0, filters_js_1.isRelationPickOrderBy)(dirValue)) {
1190
1331
  throw pickOrderNestedError(qi, key);
1191
1332
  }
1192
- const relDef = (0, utils_js_1.ownLookup)(targetMeta.relations, key);
1333
+ // resolveRelationDef, as buildRelationOrderBy resolves it, so a relation
1334
+ // written in its table spelling binds its params on a cache hit too.
1335
+ const relDef = (0, utils_js_1.resolveRelationDef)(targetMeta.relations, key);
1193
1336
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
1194
1337
  collectRelationCountParams(qi, relDef, params);
1195
1338
  }
1196
1339
  else if (relDef) {
1197
- for (const _col of Object.keys(dirValue)) {
1198
- whereMod.collectTargetGlobalFilterAlias(qi, relDef.to, params);
1199
- }
1340
+ collectToOneRelationOrderParams(qi, key, dirValue, params, {
1341
+ meta: targetMeta,
1342
+ table: targetTable,
1343
+ parentRef: targetTable,
1344
+ });
1200
1345
  }
1201
1346
  continue;
1202
1347
  }
@@ -1251,26 +1396,27 @@ function buildRelationCountExpr(qi, relDef, parentRef, alias, params) {
1251
1396
  * {@link collectManyToManyTargetGlobalFilter}.
1252
1397
  */
1253
1398
  function manyToManyTargetGlobalFilterExists(qi, relDef, alias, jalias, params) {
1254
- const gf = whereMod.resolveGlobalFilter(qi, relDef.to);
1255
- if (!gf || !relDef.through)
1399
+ const through = relDef.through;
1400
+ if (!through)
1256
1401
  return '';
1257
1402
  const tMeta = qi.schema.tables[relDef.to];
1258
1403
  if (!tMeta || tMeta.primaryKey.length === 0)
1259
1404
  return '';
1260
- const talias = `${alias}t`;
1261
- const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
1405
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
1262
1406
  const pk = tMeta.primaryKey;
1263
1407
  if (targetKeys.length !== pk.length)
1264
1408
  return '';
1265
- const join = targetKeys.map((jc, i) => `${talias}.${qi.q(pk[i])} = ${jalias}.${qi.q(jc)}`).join(' AND ');
1266
- const gfClause = whereMod.buildAliasWhere(qi, relDef.to, tMeta, talias, gf, params);
1267
- const gfAnd = gfClause ? ` AND ${gfClause}` : '';
1268
- return `EXISTS (SELECT 1 FROM ${qi.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
1409
+ return whereMod.withTargetGlobalFilter(qi, relDef.to, '', (gf) => {
1410
+ const talias = `${alias}t`;
1411
+ const join = targetKeys.map((jc, i) => `${talias}.${qi.q(pk[i])} = ${jalias}.${qi.q(jc)}`).join(' AND ');
1412
+ const gfClause = whereMod.buildAliasWhere(qi, relDef.to, tMeta, talias, gf, params);
1413
+ const gfAnd = gfClause ? ` AND ${gfClause}` : '';
1414
+ return `EXISTS (SELECT 1 FROM ${qi.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
1415
+ });
1269
1416
  }
1270
1417
  /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
1271
1418
  function collectManyToManyTargetGlobalFilter(qi, relDef, params) {
1272
- const gf = whereMod.resolveGlobalFilter(qi, relDef.to);
1273
- if (!gf || !relDef.through)
1419
+ if (!relDef.through)
1274
1420
  return;
1275
1421
  const tMeta = qi.schema.tables[relDef.to];
1276
1422
  if (!tMeta || tMeta.primaryKey.length === 0)
@@ -1278,7 +1424,7 @@ function collectManyToManyTargetGlobalFilter(qi, relDef, params) {
1278
1424
  const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
1279
1425
  if (targetKeys.length !== tMeta.primaryKey.length)
1280
1426
  return;
1281
- whereMod.collectAliasWhereParams(qi, relDef.to, tMeta, gf, params);
1427
+ whereMod.withTargetGlobalFilter(qi, relDef.to, false, (gf) => whereMod.collectAliasWhereParams(qi, relDef.to, tMeta, gf, params));
1282
1428
  }
1283
1429
  /**
1284
1430
  * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
@@ -1413,13 +1559,15 @@ function parseNestedRow(qi, row, table, fromJson = false) {
1413
1559
  // find it.
1414
1560
  //
1415
1561
  // The warn is gated and deduped because this sits in parseNestedRow's
1416
- // per-relation loop: one 10,000-row page was 10,000 console.warn calls.
1417
- if (process.env.NODE_ENV !== 'production' &&
1418
- (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.relationParseFailure, `${qi.table}.${relName}`)) {
1419
- console.warn(`[turbine] Relation "${relName}" on "${qi.table}" returned a payload that is not valid JSON. ` +
1420
- `This is a bug; please report it with the emitted SQL.`);
1562
+ // per-relation loop: one 10,000-row page was 10,000 warnings. The
1563
+ // parse error stays out of the entry: its message quotes the payload,
1564
+ // which is row data.
1565
+ const log = whereMod.adviceLogger(qi);
1566
+ if (log && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.relationParseFailure, `${qi.table}.${relName}`)) {
1567
+ (0, logger_js_1.logTo)(log, 'warn', `Relation "${relName}" on "${qi.table}" returned a payload that is not valid JSON. ` +
1568
+ `This is a bug; please report it with the emitted SQL.`, { code: 'relation-payload-json', table: qi.table, relation: relName });
1421
1569
  }
1422
- throw new errors_js_1.ValidationError(`Relation "${relName}" on table "${qi.table}" returned a payload that could not be parsed as JSON.`, { cause: err });
1570
+ throw new errors_js_1.InvariantError('relation-payload-json', `Relation "${relName}" on table "${qi.table}" returned a payload that could not be parsed as JSON.`, { cause: err });
1423
1571
  }
1424
1572
  }
1425
1573
  else if (Array.isArray(rawValue)) {
@@ -1914,7 +2062,7 @@ function flattenNodeSignature(node) {
1914
2062
  return `${node.relName}@${node.alias}(${node.cols.map(([col]) => col).join(',')})${slots}`;
1915
2063
  }
1916
2064
  /**
1917
- * Dev-only, once-per-(table, relation, reason) note that an explicitly
2065
+ * Per-query advice, once per (table, relation, reason): an explicitly
1918
2066
  * requested `relationLoadStrategy: 'flatten'` did NOT engage for a relation.
1919
2067
  *
1920
2068
  * Worth its own warning because the fallback is otherwise INVISIBLE: the query
@@ -1923,21 +2071,23 @@ function flattenNodeSignature(node) {
1923
2071
  * Silence there is indistinguishable from the strategy being a no-op, which is
1924
2072
  * exactly the shape of thing that gets filed as a bug.
1925
2073
  *
1926
- * Suppressed in production like the other planner notes, and deduped through
1927
- * the shared registry so a hot query logs once rather than per call. Note that
1928
- * a flatten plan is recomputed on the build, param-collect and row-assembly
1929
- * paths for the same query; the registry is what keeps that to one line.
2074
+ * Off when `diagnostics` turn advice off, like the other planner notes, and
2075
+ * deduped through the shared registry so a hot query logs once rather than per
2076
+ * call. Note that a flatten plan is recomputed on the build, param-collect and
2077
+ * row-assembly paths for the same query; the registry is what keeps that to
2078
+ * one line.
1930
2079
  */
1931
- function warnFlattenFallback(table, rejects) {
2080
+ function warnFlattenFallback(qi, table, rejects) {
1932
2081
  if (rejects.length === 0)
1933
2082
  return;
1934
- if (process.env.NODE_ENV === 'production')
2083
+ const log = whereMod.adviceLogger(qi);
2084
+ if (!log)
1935
2085
  return;
1936
2086
  for (const { relation, reason } of rejects) {
1937
2087
  if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.flattenFallback, `${table}.${relation}|${reason}`))
1938
2088
  continue;
1939
- console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
1940
- `${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`);
2089
+ (0, logger_js_1.logTo)(log, 'warn', `relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
2090
+ `${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`, { code: 'flatten-fallback', table, relation, reason });
1941
2091
  }
1942
2092
  }
1943
2093
  /**
@@ -1968,7 +2118,7 @@ function planFlattenWith(qi, table, withClause, includePii) {
1968
2118
  if (node)
1969
2119
  nodes[relName] = node;
1970
2120
  }
1971
- warnFlattenFallback(table, rejects);
2121
+ warnFlattenFallback(qi, table, rejects);
1972
2122
  if (Object.keys(nodes).length === 0)
1973
2123
  return null;
1974
2124
  // Flat rows carry the root table's own columns, one column per non-flattened
@@ -1978,7 +2128,7 @@ function planFlattenWith(qi, table, withClause, includePii) {
1978
2128
  const reserved = new Set([...meta.allColumns, ...Object.keys(withClause)]);
1979
2129
  for (const alias of aliases) {
1980
2130
  if (reserved.has(alias)) {
1981
- warnFlattenFallback(table, [
2131
+ warnFlattenFallback(qi, table, [
1982
2132
  {
1983
2133
  relation: Object.keys(nodes).join(', '),
1984
2134
  reason: `the projected alias "${alias}" collides with a column or relation name on "${table}"`,
@@ -2251,11 +2401,12 @@ function buildSelectWithRelations(qi, table, withClause, params, columnsList, de
2251
2401
  const meta = qi.schema.tables[table];
2252
2402
  if (!meta)
2253
2403
  throw new errors_js_1.ValidationError(`Unknown table "${table}"`);
2254
- // Positional JSON encoding is Postgres-only in v1. Gate here, the single
2255
- // entry point for every `with` clause, so no engine ever emits the
2256
- // json_build_array shape its dialect can't produce (and mssql's FOR JSON
2257
- // override path is never reached with positional active).
2258
- if (qi.jsonEncoding === 'positional' && qi.dialect.name !== 'postgresql') {
2404
+ // Positional JSON encoding needs `positionalJson`, the flag the default in
2405
+ // builder.ts reads too, so the default never picks an encoding refused here.
2406
+ // Gate here, the single entry point for every `with` clause, so no engine
2407
+ // ever emits the json_build_array shape its dialect can't produce (and
2408
+ // mssql's FOR JSON override path is never reached with positional active).
2409
+ if (qi.jsonEncoding === 'positional' && !qi.dialect.capabilities.positionalJson) {
2259
2410
  throw new errors_js_1.UnsupportedFeatureError("jsonEncoding: 'positional'", qi.dialect.name, 'Positional relation encoding is only available on PostgreSQL in this version.');
2260
2411
  }
2261
2412
  const cols = columnsList ?? meta.allColumns;
@@ -2409,12 +2560,13 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
2409
2560
  // The raw `spec` is still what crosses the dialect seam below, since
2410
2561
  // RelationSubqueryContext is a published contract.
2411
2562
  const opts = relationOptions(spec);
2412
- // Dev-only: correlated relation loading probes the child table once per parent
2413
- // row, so a missing FK index multiplies into per-parent full-table scans (a
2414
- // batched-loader ORM pays the same missing index only once, which is why
2415
- // schemas migrated from one often lack these). Name the exact index to create
2416
- // instead of letting the slowness look like an ORM problem.
2417
- if (process.env.NODE_ENV !== 'production') {
2563
+ // Per-query advice: correlated relation loading probes the child table once
2564
+ // per parent row, so a missing FK index multiplies into per-parent full-table
2565
+ // scans (a batched-loader ORM pays the same missing index only once, which is
2566
+ // why schemas migrated from one often lack these). Name the exact index to
2567
+ // create instead of letting the slowness look like an ORM problem.
2568
+ const log = whereMod.adviceLogger(qi);
2569
+ if (log) {
2418
2570
  const warnKey = `${relDef.from}.${relDef.name}`;
2419
2571
  // Compute the (potentially costly) probe only for a key not yet warned, and
2420
2572
  // claim the key through the process-wide registry ONLY when we actually warn,
@@ -2423,10 +2575,16 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
2423
2575
  if (!(0, warn_registry_js_1.hasWarnedOnce)(warn_registry_js_1.WARN_NS.unindexedRelation, warnKey)) {
2424
2576
  const miss = (0, index_advisor_js_1.missingIndexForRelation)(qi.schema, relDef);
2425
2577
  if (miss && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.unindexedRelation, warnKey)) {
2426
- console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
2578
+ (0, logger_js_1.logTo)(log, 'warn', `Relation "${relDef.name}" on "${relDef.from}" probes ` +
2427
2579
  `"${miss.table}"(${miss.columns.join(', ')}) which has no covering index, ` +
2428
2580
  `each parent row scans the full table. Fix: ${miss.createSql}; ` +
2429
- 'or run `npx turbine doctor` for a full report.');
2581
+ 'or run `npx turbine doctor` for a full report.', {
2582
+ code: 'unindexed-relation',
2583
+ table: relDef.from,
2584
+ relation: relDef.name,
2585
+ probeTable: miss.table,
2586
+ columns: miss.columns,
2587
+ });
2430
2588
  }
2431
2589
  }
2432
2590
  }
@@ -2496,7 +2654,8 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
2496
2654
  // it must neither trigger the wrap (dropping nested relations) nor render a
2497
2655
  // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
2498
2656
  const relOrderEntries = opts.orderBy ? (0, filters_js_1.orderByEntries)(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
2499
- const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || relOrderEntries.length > 0);
2657
+ const offset = relationOffset(opts);
2658
+ const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || offset !== undefined || relOrderEntries.length > 0);
2500
2659
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
2501
2660
  // where, orderBy, and select/omit are handled there (the target alias is the
2502
2661
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -2564,8 +2723,8 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
2564
2723
  // (and shifts every later placeholder by one). To-one relations ignore limit.
2565
2724
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
2566
2725
  let limitClause = '';
2567
- if (relDef.type === 'hasMany' && opts.limit !== undefined) {
2568
- limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
2726
+ if (relDef.type === 'hasMany') {
2727
+ limitClause = relationPagination(qi, opts.limit, offset, params, relOrderEntries.length > 0);
2569
2728
  }
2570
2729
  if (relDef.type === 'hasMany') {
2571
2730
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -2683,11 +2842,8 @@ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCount
2683
2842
  const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, talias, params);
2684
2843
  if (gfExtra)
2685
2844
  whereClause += ` AND ${gfExtra}`;
2686
- // LIMIT, `limit: 0` is honored (LIMIT 0 → empty array)
2687
- let limitClause = '';
2688
- if (opts.limit !== undefined) {
2689
- limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
2690
- }
2845
+ // LIMIT / OFFSET, `limit: 0` is honored (LIMIT 0 → empty array)
2846
+ const limitClause = relationPagination(qi, opts.limit, relationOffset(opts), params, relOrderEntries.length > 0);
2691
2847
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
2692
2848
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
2693
2849
  // 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;