turbine-orm 0.71.0 → 0.72.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 (44) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/client.d.ts +0 -18
  3. package/dist/cjs/client.js +2 -29
  4. package/dist/cjs/connection-url.d.ts +30 -0
  5. package/dist/cjs/connection-url.js +15 -17
  6. package/dist/cjs/powql.d.ts +38 -1
  7. package/dist/cjs/powql.js +106 -18
  8. package/dist/cjs/query/aggregates.d.ts +0 -13
  9. package/dist/cjs/query/aggregates.js +81 -33
  10. package/dist/cjs/query/batched-loader.d.ts +13 -1
  11. package/dist/cjs/query/batched-loader.js +46 -11
  12. package/dist/cjs/query/builder.d.ts +13 -0
  13. package/dist/cjs/query/builder.js +104 -14
  14. package/dist/cjs/query/compound-unique.js +29 -5
  15. package/dist/cjs/query/relation-names.d.ts +52 -0
  16. package/dist/cjs/query/relation-names.js +120 -0
  17. package/dist/cjs/query/relations.d.ts +11 -6
  18. package/dist/cjs/query/relations.js +45 -27
  19. package/dist/cjs/query/utils.d.ts +107 -3
  20. package/dist/cjs/query/utils.js +408 -7
  21. package/dist/cjs/query/where-compile.js +9 -4
  22. package/dist/cjs/query/where.js +9 -5
  23. package/dist/client.d.ts +0 -18
  24. package/dist/client.js +2 -29
  25. package/dist/connection-url.d.ts +30 -0
  26. package/dist/connection-url.js +15 -18
  27. package/dist/powql.d.ts +38 -1
  28. package/dist/powql.js +107 -19
  29. package/dist/query/aggregates.d.ts +0 -13
  30. package/dist/query/aggregates.js +82 -34
  31. package/dist/query/batched-loader.d.ts +13 -1
  32. package/dist/query/batched-loader.js +47 -12
  33. package/dist/query/builder.d.ts +13 -0
  34. package/dist/query/builder.js +105 -15
  35. package/dist/query/compound-unique.js +30 -6
  36. package/dist/query/relation-names.d.ts +52 -0
  37. package/dist/query/relation-names.js +117 -0
  38. package/dist/query/relations.d.ts +11 -6
  39. package/dist/query/relations.js +47 -29
  40. package/dist/query/utils.d.ts +107 -3
  41. package/dist/query/utils.js +404 -8
  42. package/dist/query/where-compile.js +10 -5
  43. package/dist/query/where.js +10 -6
  44. package/package.json +5 -3
@@ -89,12 +89,14 @@ function assertAggregatePiiOptIn(table, meta, field, column, usage, includePii)
89
89
  }
90
90
  function buildGroupBy(qi, args) {
91
91
  const meta = qi.schema.tables[qi.table];
92
- if (meta) {
93
- for (const key of args.by) {
94
- if (typeof key === 'string' && !(key in meta.columnMap)) {
95
- throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
96
- }
97
- }
92
+ // Up-front, so a bad `by` key is reported before anything else in the call,
93
+ // and through `qi.toColumn` (the ONE `resolveColumnName` rule) rather than
94
+ // `key in meta.columnMap`, which knows only the FIELD spelling and so
95
+ // rejected the snake_case COLUMN name that `where` / `select` / `distinct`
96
+ // accept and that an introspected schema's DDL declares.
97
+ for (const key of args.by) {
98
+ if (typeof key === 'string')
99
+ qi.toColumn(key);
98
100
  }
99
101
  qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
100
102
  // Resolve the PII opt-in ONCE, here, so the sentinel check runs on every
@@ -150,18 +152,30 @@ function buildGroupBy(qi, args) {
150
152
  for (const entry of args.by) {
151
153
  if (typeof entry === 'string') {
152
154
  const col = qi.toColumn(entry);
155
+ // The group key's identity is the COLUMN, so everything keyed off it
156
+ // uses the canonical FIELD name rather than whichever spelling the caller
157
+ // wrote. The result key above all: rows are read through `parseRow`,
158
+ // whose keys are field names, so `by: ['created_at']` keyed by the
159
+ // caller's spelling read `parsed['created_at']` and returned `undefined`
160
+ // for every group. `_count` / `_sum` / `_min` in the same transform
161
+ // already map their alias back through `reverseColumnMap`.
162
+ const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
153
163
  assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', includePii);
154
- claimResultKey(entry, `column "${col}"`);
164
+ claimResultKey(field, `column "${col}"`);
155
165
  // The emitted output column is the snake_case name; claim it too (when
156
166
  // it differs from the result key) so a JSON alias like 'created_at'
157
167
  // cannot silently shadow the 'createdAt' group key on the wire.
158
- if (col !== entry)
168
+ if (col !== field)
159
169
  claimResultKey(col, `column "${col}"`);
160
170
  groupExprs.push(qi.q(col));
161
171
  selectExprs.push(qi.q(col));
162
- byReaders.push({ resultKey: entry, rowKey: col, raw: false });
163
- byOrderExprs.set(entry, qi.q(col));
164
- havingGroupKeys.set(entry, { kind: 'column', field: entry });
172
+ byReaders.push({ resultKey: field, rowKey: col, raw: false });
173
+ // Registered ONCE, under the canonical field: `orderBy` and `having`
174
+ // may spell the same group key the other way, and {@link lookupGroupKey}
175
+ // reconciles that at lookup time rather than doubling every
176
+ // "orderable keys" list.
177
+ byOrderExprs.set(field, qi.q(col));
178
+ havingGroupKeys.set(field, { kind: 'column', field });
165
179
  }
166
180
  else {
167
181
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
@@ -242,7 +256,9 @@ function buildGroupBy(qi, args) {
242
256
  const inner = `${sqlFn}(${qi.q(col)})`;
243
257
  const expr = aggKey === '_avg' ? qi.castAgg(inner, 'float') : inner;
244
258
  selectExprs.push(`${expr} AS ${qi.q(`${aggKey}_${col}`)}`);
245
- aggOrderExprs.set(`${aggKey}:${key}`, expr);
259
+ // Canonical field, matching the result bucket the transform fills;
260
+ // `orderBy` may spell it either way (see {@link lookupGroupKey}).
261
+ aggOrderExprs.set(`${aggKey}:${qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col)}`, expr);
246
262
  continue;
247
263
  }
248
264
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
@@ -397,6 +413,37 @@ function buildGroupBy(qi, args) {
397
413
  * no `$n` renumbering). An aggregate key that was not requested, or an unknown
398
414
  * by-key, throws {@link ValidationError} E003 listing the valid keys.
399
415
  */
416
+ /**
417
+ * Read a caller-supplied groupBy result key out of a registry keyed by the
418
+ * CANONICAL name (the field for a `by` column / aggregate target, the alias
419
+ * for a JSON group key).
420
+ *
421
+ * `by`, `orderBy` and `having` are three arguments of one call, each free to
422
+ * spell a column either way, so `by`'s choice must not decide what the other
423
+ * two may name. Reconciled here rather than by registering both spellings,
424
+ * which would list every group key twice in the "orderable keys" text: try the
425
+ * key as written (which is what carries a JSON alias, not a column), then its
426
+ * canonical field.
427
+ */
428
+ function lookupGroupKey(qi, registry, key) {
429
+ const direct = registry.get(key);
430
+ if (direct !== undefined)
431
+ return direct;
432
+ const canonical = canonicalFieldName(qi, key);
433
+ return canonical === undefined ? undefined : registry.get(canonical);
434
+ }
435
+ /**
436
+ * The canonical FIELD name for a caller-supplied column name, or `undefined`
437
+ * when it names no column (a JSON group-key alias, a typo). One hop through
438
+ * {@link resolveColumnName} and back via `reverseColumnMap`, so both spellings
439
+ * land on one string.
440
+ */
441
+ function canonicalFieldName(qi, key) {
442
+ const column = (0, utils_js_1.resolveColumnName)(qi.tableMeta, key);
443
+ if (column === undefined)
444
+ return undefined;
445
+ return qi.tableMeta.reverseColumnMap[column] ?? (0, schema_js_1.snakeToCamel)(column);
446
+ }
400
447
  function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
401
448
  const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
402
449
  /** Human-readable list of every key this call can order by (for E003). */
@@ -459,7 +506,11 @@ function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
459
506
  for (const [field, dirSpec] of Object.entries(value)) {
460
507
  if (dirSpec === undefined)
461
508
  continue;
462
- const expr = aggOrderExprs.get(`${key}:${field}`);
509
+ // `field` is the caller's spelling of the aggregate's target column;
510
+ // the registry is keyed by the canonical one.
511
+ const canonical = canonicalFieldName(qi, field);
512
+ const expr = aggOrderExprs.get(`${key}:${field}`) ??
513
+ (canonical === undefined ? undefined : aggOrderExprs.get(`${key}:${canonical}`));
463
514
  if (!expr) {
464
515
  throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${qi.table}": ` +
465
516
  `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
@@ -470,7 +521,7 @@ function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
470
521
  continue;
471
522
  }
472
523
  // Plain by-field name or JSON group-key alias.
473
- const expr = byOrderExprs.get(key);
524
+ const expr = lookupGroupKey(qi, byOrderExprs, key);
474
525
  if (!expr) {
475
526
  throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${qi.table}". ` +
476
527
  `Orderable keys: ${validKeys()}.`);
@@ -734,7 +785,7 @@ function buildHavingCombinator(qi, key, value, params, jsonAggExprs, groupKeys,
734
785
  * its re-emitted extract expression.
735
786
  */
736
787
  function buildHavingScalarClauses(qi, field, value, params, groupKeys) {
737
- const ref = groupKeys?.get(field);
788
+ const ref = groupKeys ? lookupGroupKey(qi, groupKeys, field) : undefined;
738
789
  if (!ref) {
739
790
  const known = groupKeys ? [...groupKeys.keys()] : [];
740
791
  throw new errors_js_1.ValidationError(`[turbine] having on "${field}" (table "${qi.table}") filters the grouped value itself, but ` +
@@ -885,25 +936,22 @@ function buildAggregate(qi, args) {
885
936
  ? whereMod.buildWhere(qi, aggWhere)
886
937
  : { sql: '', params: [] };
887
938
  const meta = qi.schema.tables[qi.table];
888
- if (meta) {
889
- for (const group of [args._sum, args._avg, args._min, args._max]) {
890
- if (group && typeof group === 'object') {
891
- for (const key of Object.keys(group)) {
892
- if (!(key in meta.columnMap)) {
893
- throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
894
- }
895
- }
896
- }
939
+ // Every target is validated up front, including one whose falsy value the
940
+ // builders below skip, and through `qi.toColumn` (the ONE
941
+ // `resolveColumnName` rule). `key in meta.columnMap` knows only the FIELD
942
+ // spelling, so the snake_case COLUMN name that `where` / `select` and even
943
+ // `groupBy`'s own `_min` accepted was rejected here.
944
+ for (const group of [args._sum, args._avg, args._min, args._max]) {
945
+ if (group && typeof group === 'object') {
946
+ for (const key of Object.keys(group))
947
+ qi.toColumn(key);
897
948
  }
898
- if (args._count && typeof args._count === 'object') {
899
- for (const key of Object.keys(args._count)) {
900
- // `_all` is the reserved COUNT(*) selector, not a column.
901
- if (key === '_all')
902
- continue;
903
- if (!(key in meta.columnMap)) {
904
- throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
905
- }
906
- }
949
+ }
950
+ if (args._count && typeof args._count === 'object') {
951
+ for (const key of Object.keys(args._count)) {
952
+ // `_all` is the reserved COUNT(*) selector, not a column.
953
+ if (key !== '_all')
954
+ qi.toColumn(key);
907
955
  }
908
956
  }
909
957
  const selectExprs = [];
@@ -58,6 +58,7 @@ import type { PgCompatQueryResult } from '../pg-types.js';
58
58
  import { type RelationDef, type SchemaMetadata, type TableMetadata } from '../schema.js';
59
59
  import type { ReselectExecutor } from './builder.js';
60
60
  import type { SkipGlobalFilters, Unsafe, WithClause, WithCount } from './types.js';
61
+ import { type ColumnNameSource } from './utils.js';
61
62
  /**
62
63
  * A DeferredQuery, minimally typed for what the loader consumes. Kept local to
63
64
  * avoid a value import of builder.ts (which imports this module).
@@ -166,7 +167,18 @@ export declare function defaultProjectionFields(meta: TableMetadata, includePii:
166
167
  * resolver so the two strategies refuse identically, word for word.
167
168
  */
168
169
  export declare function assertProjectionShape(table: string, select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined): void;
169
- export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[],
170
+ export declare function includeKeysForBatching(
171
+ /**
172
+ * The table the projection is compiled against. `select` / `omit` keys are
173
+ * the CALLER's, and a column has two legal spellings there, so matching by
174
+ * raw key made "is the correlation key already projected?" depend on which
175
+ * was used: `select: { user_id: true }` with a `userId` key looked
176
+ * unprojected, so the key was force-added AND marked stitch-only and
177
+ * `stripFields` deleted the very column the caller asked for, while
178
+ * `omit: { user_id: true }` failed to un-omit and tripped
179
+ * `assertCorrelationKeyProjected`'s "bug in turbine" path on a legal query.
180
+ */
181
+ meta: ColumnNameSource, select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[],
170
182
  /**
171
183
  * The default projection for this table when it is NOT `select`/`omit`-driven:
172
184
  * `hidden` are fields the default projection leaves out (today: PII-tagged
@@ -163,8 +163,12 @@ function partitionOrderBy(meta, orderBy) {
163
163
  return null;
164
164
  const out = [];
165
165
  for (const [key, value] of entries) {
166
- const column = (0, utils_js_1.ownLookup)(meta.columnMap, key);
167
- if (!column || !meta.allColumns.includes(column))
166
+ // The one key-resolution rule. A bare `columnMap` read knows only the
167
+ // FIELD spelling, so a snake-spelled orderBy declined the partition
168
+ // pushdown and silently fell back to fetching every child row and slicing
169
+ // client-side: two strategies, different bytes over the wire, one query.
170
+ const column = (0, utils_js_1.resolveColumnName)(meta, key);
171
+ if (column === undefined)
168
172
  return null;
169
173
  let sort;
170
174
  let nulls;
@@ -275,7 +279,18 @@ function assertProjectionShape(table, select, omit) {
275
279
  throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(table));
276
280
  }
277
281
  }
278
- function includeKeysForBatching(select, omit, fields,
282
+ function includeKeysForBatching(
283
+ /**
284
+ * The table the projection is compiled against. `select` / `omit` keys are
285
+ * the CALLER's, and a column has two legal spellings there, so matching by
286
+ * raw key made "is the correlation key already projected?" depend on which
287
+ * was used: `select: { user_id: true }` with a `userId` key looked
288
+ * unprojected, so the key was force-added AND marked stitch-only and
289
+ * `stripFields` deleted the very column the caller asked for, while
290
+ * `omit: { user_id: true }` failed to un-omit and tripped
291
+ * `assertCorrelationKeyProjected`'s "bug in turbine" path on a legal query.
292
+ */
293
+ meta, select, omit, fields,
279
294
  /**
280
295
  * The default projection for this table when it is NOT `select`/`omit`-driven:
281
296
  * `hidden` are fields the default projection leaves out (today: PII-tagged
@@ -289,23 +304,43 @@ function includeKeysForBatching(select, omit, fields,
289
304
  */
290
305
  defaultProjection) {
291
306
  const unique = [...new Set(fields)];
307
+ /**
308
+ * The caller's projection keys indexed by the column each resolves to, so a
309
+ * correlation key is recognized under either spelling. A key resolving to no
310
+ * column is left out: the projection build raises E003, where it belongs.
311
+ */
312
+ const keyByColumn = (projection) => {
313
+ const byColumn = new Map();
314
+ for (const key of Object.keys(projection)) {
315
+ const column = (0, utils_js_1.resolveColumnName)(meta, key);
316
+ if (column !== undefined)
317
+ byColumn.set(column, key);
318
+ }
319
+ return byColumn;
320
+ };
321
+ /** `fields` are canonical field names, but resolve them anyway rather than assume. */
322
+ const columnOf = (field) => (0, utils_js_1.resolveColumnName)(meta, field) ?? field;
292
323
  if (select) {
293
324
  const next = { ...select };
294
325
  const strip = [];
326
+ const selected = keyByColumn(select);
295
327
  for (const f of unique) {
296
- if (!next[f]) {
297
- next[f] = true;
298
- strip.push(f); // not requested by the caller, added only to stitch
299
- }
328
+ const existing = selected.get(columnOf(f));
329
+ if (existing !== undefined && next[existing])
330
+ continue; // already projected, under either spelling
331
+ next[f] = true;
332
+ strip.push(f); // not requested by the caller, added only to stitch
300
333
  }
301
334
  return { select: next, omit, strip };
302
335
  }
303
336
  if (omit) {
304
337
  const next = { ...omit };
305
338
  const strip = [];
339
+ const omitted = keyByColumn(omit);
306
340
  for (const f of unique) {
307
- if (next[f]) {
308
- delete next[f]; // un-omit so the key is present; the caller wanted it gone
341
+ const existing = omitted.get(columnOf(f));
342
+ if (existing !== undefined && next[existing]) {
343
+ delete next[existing]; // un-omit so the key is present; the caller wanted it gone
309
344
  strip.push(f);
310
345
  }
311
346
  }
@@ -633,7 +668,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
633
668
  const orderFields = pushDownLimit
634
669
  ? (windowOrder ?? []).map((o) => targetMeta.reverseColumnMap[o.column] ?? o.column)
635
670
  : [];
636
- const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...orderFields, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
671
+ const proj = includeKeysForBatching(targetMeta, options.select, options.omit, [childKeyField, ...orderFields, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
637
672
  const child = ctx.makeChild(rel.to);
638
673
  const buildChunk = (chunk) => child.buildFindMany({
639
674
  where: mergeChildWhere(options.where, childKeyField, chunk),
@@ -836,7 +871,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
836
871
  // reason as the to-many loader above: this level's PK is not the only key the
837
872
  // recursion below will ask these rows for.
838
873
  assertProjectionShape(targetMeta.name, options.select, options.omit);
839
- const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
874
+ const proj = includeKeysForBatching(targetMeta, options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
840
875
  const child = ctx.makeChild(rel.to);
841
876
  const buildTargetChunk = (chunk) => child.buildFindMany({
842
877
  where: mergeChildWhere(options.where, targetPkField, chunk),
@@ -538,6 +538,19 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
538
538
  * so a warm template can never serve a call the cold path would refuse.
539
539
  */
540
540
  private resolveJsonEncoding;
541
+ /**
542
+ * `args` with every `with` relation key replaced by the relation's DECLARED
543
+ * spelling, so a snake_case relation name resolves the way a snake_case
544
+ * column name already does.
545
+ *
546
+ * Returns `args` by reference when nothing needed rewriting, which is every
547
+ * query that already spells its relations the declared way. Runs before the
548
+ * stable-order pass and before `withFingerprint`, so the whole pipeline, and
549
+ * the SQL cache key with it, sees one spelling. The rule and the reason it is
550
+ * applied ONCE here rather than at each of the six `with` walkers are in
551
+ * query/relation-names.ts.
552
+ */
553
+ private withDeclaredRelationNames;
541
554
  /**
542
555
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
543
556
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -57,6 +57,7 @@ const aggMod = __importStar(require("./aggregates.js"));
57
57
  const batched_loader_js_1 = require("./batched-loader.js");
58
58
  const compound_unique_js_1 = require("./compound-unique.js");
59
59
  const filters_js_1 = require("./filters.js");
60
+ const relation_names_js_1 = require("./relation-names.js");
60
61
  const relationsMod = __importStar(require("./relations.js"));
61
62
  const types_js_1 = require("./types.js");
62
63
  const utils_js_1 = require("./utils.js");
@@ -1102,6 +1103,24 @@ class QueryInterface {
1102
1103
  this.currentJsonEncoding = argEncoding;
1103
1104
  return argEncoding;
1104
1105
  }
1106
+ /**
1107
+ * `args` with every `with` relation key replaced by the relation's DECLARED
1108
+ * spelling, so a snake_case relation name resolves the way a snake_case
1109
+ * column name already does.
1110
+ *
1111
+ * Returns `args` by reference when nothing needed rewriting, which is every
1112
+ * query that already spells its relations the declared way. Runs before the
1113
+ * stable-order pass and before `withFingerprint`, so the whole pipeline, and
1114
+ * the SQL cache key with it, sees one spelling. The rule and the reason it is
1115
+ * applied ONCE here rather than at each of the six `with` walkers are in
1116
+ * query/relation-names.ts.
1117
+ */
1118
+ withDeclaredRelationNames(args) {
1119
+ if (!args?.with)
1120
+ return args;
1121
+ const normalized = (0, relation_names_js_1.normalizeWithClause)(this.schema, this.table, args.with);
1122
+ return normalized === args.with ? args : { ...args, with: normalized };
1123
+ }
1105
1124
  /**
1106
1125
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
1107
1126
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -1122,7 +1141,7 @@ class QueryInterface {
1122
1141
  for (const [relName, spec] of Object.entries(withClause)) {
1123
1142
  if (relName === '_count' || !spec)
1124
1143
  continue; // `_count` is a count, not a row load
1125
- const rel = (0, utils_js_1.ownLookup)(meta.relations, relName);
1144
+ const rel = (0, utils_js_1.resolveRelationDef)(meta.relations, relName);
1126
1145
  if (!rel)
1127
1146
  continue; // unknown relation, let the build path surface E005
1128
1147
  const options = spec === true ? {} : spec;
@@ -1509,7 +1528,7 @@ class QueryInterface {
1509
1528
  }
1510
1529
  continue;
1511
1530
  }
1512
- const rel = (0, utils_js_1.ownLookup)(this.tableMeta.relations, key);
1531
+ const rel = (0, utils_js_1.resolveRelationDef)(this.tableMeta.relations, key);
1513
1532
  if (!rel) {
1514
1533
  joinWith[key] = spec; // unknown relation, let the join path surface E005
1515
1534
  continue;
@@ -1619,7 +1638,7 @@ class QueryInterface {
1619
1638
  const includePii = (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii');
1620
1639
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, batchedWith);
1621
1640
  (0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
1622
- const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, includePii));
1641
+ const proj = (0, batched_loader_js_1.includeKeysForBatching)(this.tableMeta, args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, includePii));
1623
1642
  const hasJoin = Object.keys(joinWith).length > 0;
1624
1643
  // Force the residual `with` onto the join plan so the base query never
1625
1644
  // re-enters this auto planning.
@@ -1757,6 +1776,10 @@ class QueryInterface {
1757
1776
  * from the returned rows, so the shape matches the join strategy exactly.
1758
1777
  */
1759
1778
  async runFindManyBatched(args) {
1779
+ // Declared relation spellings first, exactly as the join path does in
1780
+ // buildFindMany: the loader reads `args.with` itself, so without this the
1781
+ // two strategies would disagree about which relation names are valid.
1782
+ args = this.withDeclaredRelationNames(args);
1760
1783
  // Stable relation order (opt-in): the batched loader forwards each relation's
1761
1784
  // orderBy into its follow-up query, so filling the synthesized PK order here
1762
1785
  // makes the batched output deterministic exactly like the join path.
@@ -1791,7 +1814,7 @@ class QueryInterface {
1791
1814
  prepareBatchedBase(args, withClause) {
1792
1815
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
1793
1816
  (0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
1794
- const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
1817
+ const proj = (0, batched_loader_js_1.includeKeysForBatching)(this.tableMeta, args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
1795
1818
  const baseArgs = {
1796
1819
  ...args,
1797
1820
  with: undefined,
@@ -2163,6 +2186,15 @@ class QueryInterface {
2163
2186
  // findUnique
2164
2187
  // -------------------------------------------------------------------------
2165
2188
  async findUnique(args) {
2189
+ // BEFORE the strategy branch, not only inside the builders, so the
2190
+ // join-vs-batched decision and its dev warning see the same declared name
2191
+ // every other stage does. `planAuto` splits `with` on the caller's keys and
2192
+ // `runAutoSplit` carries them onward; each downstream consumer normalizes
2193
+ // too, so this is not load-bearing for correctness (measured: removing it
2194
+ // changes the warning text from the declared name back to the caller's and
2195
+ // nothing else). It is here so the invariant holds at the seam rather than
2196
+ // depending on every consumer to re-establish it.
2197
+ args = this.withDeclaredRelationNames(args);
2166
2198
  return this.executeWithMiddleware('findUnique', args, async () => {
2167
2199
  if (args.with) {
2168
2200
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
@@ -2190,6 +2222,8 @@ class QueryInterface {
2190
2222
  * strategy's shape for the one row.
2191
2223
  */
2192
2224
  async runFindUniqueBatched(args) {
2225
+ // Declared relation spellings first, see runFindManyBatched.
2226
+ args = this.withDeclaredRelationNames(args);
2193
2227
  // Stable relation order (opt-in), see runFindManyBatched.
2194
2228
  const withClause = this.resolveStableOrder(args.stableRelationOrder)
2195
2229
  ? this.applyStableRelationOrder(args.with, this.table)
@@ -2198,7 +2232,7 @@ class QueryInterface {
2198
2232
  (0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
2199
2233
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
2200
2234
  (0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
2201
- const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
2235
+ const proj = (0, batched_loader_js_1.includeKeysForBatching)(this.tableMeta, args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
2202
2236
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
2203
2237
  const deferred = this.buildFindUnique(baseArgs);
2204
2238
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
@@ -2243,6 +2277,9 @@ class QueryInterface {
2243
2277
  'A key whose value is `undefined` does not count, check that the value you are looking up is defined. ' +
2244
2278
  'If you meant "any row matching an optional filter", use `findFirst`.');
2245
2279
  }
2280
+ // Declared relation spellings first, before stable-order and the
2281
+ // fingerprint (see buildFindMany).
2282
+ args = this.withDeclaredRelationNames(args);
2246
2283
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2247
2284
  // relations before fingerprinting (see buildFindMany).
2248
2285
  if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
@@ -2285,7 +2322,9 @@ class QueryInterface {
2285
2322
  !whereObj.NOT &&
2286
2323
  whereKeys.every((k) => {
2287
2324
  const v = whereObj[k];
2288
- return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !(0, utils_js_1.ownLookup)(this.tableMeta.relations, k);
2325
+ // Resolved, not looked up: a relation filter spelled the snake_case
2326
+ // way must not read as a plain equality and take the simple path.
2327
+ return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !(0, utils_js_1.resolveRelationDef)(this.tableMeta.relations, k);
2289
2328
  });
2290
2329
  // Simple path: plain equality, no operators/null/OR.
2291
2330
  //
@@ -2381,6 +2420,15 @@ class QueryInterface {
2381
2420
  // findMany
2382
2421
  // -------------------------------------------------------------------------
2383
2422
  async findMany(args) {
2423
+ // BEFORE the strategy branch, not only inside the builders, so the
2424
+ // join-vs-batched decision and its dev warning see the same declared name
2425
+ // every other stage does. `planAuto` splits `with` on the caller's keys and
2426
+ // `runAutoSplit` carries them onward; each downstream consumer normalizes
2427
+ // too, so this is not load-bearing for correctness (measured: removing it
2428
+ // changes the warning text from the declared name back to the caller's and
2429
+ // nothing else). It is here so the invariant holds at the seam rather than
2430
+ // depending on every consumer to re-establish it.
2431
+ args = this.withDeclaredRelationNames(args);
2384
2432
  this.maybeWarnUnlimited(args);
2385
2433
  this.maybeWarnUnorderedPage(args);
2386
2434
  // Dev-only: warn on deeply nested with clauses
@@ -2531,8 +2579,12 @@ class QueryInterface {
2531
2579
  const isScalarEquality = value !== null && (typeof value !== 'object' || value instanceof Date) && typeof value !== 'function';
2532
2580
  if (!isScalarEquality)
2533
2581
  return false;
2534
- const column = (0, utils_js_1.ownLookup)(this.tableMeta.columnMap, field);
2535
- if (!column)
2582
+ // The one key-resolution rule: a bare `columnMap` read knows only the
2583
+ // FIELD spelling, so `where: { user_id: 1 }` on a unique column did not
2584
+ // count as pinning it and drew a spurious unlimited-read warning the
2585
+ // camelCase spelling of the same query did not.
2586
+ const column = (0, utils_js_1.resolveColumnName)(this.tableMeta, field);
2587
+ if (column === undefined)
2536
2588
  return false;
2537
2589
  pinned.add(column);
2538
2590
  }
@@ -2559,6 +2611,11 @@ class QueryInterface {
2559
2611
  this.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args?.skipGlobalFilters);
2560
2612
  // Pinned before the flatten plan and the cache key, both of which read it.
2561
2613
  const jsonEncoding = this.resolveJsonEncoding(args?.jsonEncoding);
2614
+ // Relation names to their declared spelling FIRST, before the stable-order
2615
+ // pass and the fingerprint below and before any of the six `with` walkers,
2616
+ // so none of them needs to know a relation has two accepted spellings and
2617
+ // both spellings share one cache entry. See query/relation-names.ts.
2618
+ args = this.withDeclaredRelationNames(args);
2562
2619
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2563
2620
  // relations BEFORE fingerprinting, so the two orderings get distinct cache
2564
2621
  // entries and every downstream path (SQL build, collect, parser) inherits it.
@@ -2670,7 +2727,13 @@ class QueryInterface {
2670
2727
  // distinct key (correct, it emits a different ORDER BY).
2671
2728
  const orderFp = args?.orderBy
2672
2729
  ? (0, filters_js_1.orderByEntries)(args.orderBy)
2673
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, (0, utils_js_1.ownLookup)(this.tableMeta.relations, k)?.to)}`)
2730
+ // Keyed by the DECLARED relation name (falling back to the key
2731
+ // itself for a column), so the two spellings of one orderBy share a
2732
+ // cache entry instead of minting two templates for one query.
2733
+ .map(([k, d]) => {
2734
+ const rel = (0, utils_js_1.resolveRelation)(this.tableMeta.relations, k);
2735
+ return `${rel?.name ?? k}:${this.orderByEntryFingerprint(d, rel?.def.to)}`;
2736
+ })
2674
2737
  .join(',')
2675
2738
  : '';
2676
2739
  const cursorFp = args?.cursor
@@ -2792,12 +2855,27 @@ class QueryInterface {
2792
2855
  // Resolve the seek direction per cursor field from the flattened
2793
2856
  // orderBy entries (last wins, matching object-key semantics), so both
2794
2857
  // the object and array orderBy forms drive the cursor comparison.
2795
- const orderDirByKey = new Map((0, filters_js_1.orderByEntries)(args.orderBy));
2858
+ //
2859
+ // Indexed by the RESOLVED COLUMN, never the caller's key: both
2860
+ // `cursor` and `orderBy` take either spelling, so a cursor written
2861
+ // `{ created_at }` against `orderBy: { createdAt: 'desc' }` missed
2862
+ // this lookup, defaulted to ascending, and emitted `created_at > $n`
2863
+ // under `ORDER BY created_at DESC` — the wrong page, silently. Same
2864
+ // failure the `{ sort, nulls }` normalization below prevents, reached
2865
+ // through the spelling instead of the value shape. A relation /
2866
+ // JSON-path / vector key resolves to no column and is skipped.
2867
+ const orderDirByColumn = new Map();
2868
+ for (const [ok, od] of (0, filters_js_1.orderByEntries)(args.orderBy)) {
2869
+ const ocol = (0, utils_js_1.resolveColumnName)(this.tableMeta, ok);
2870
+ if (ocol !== undefined)
2871
+ orderDirByColumn.set(ocol, od);
2872
+ }
2796
2873
  const cursorConditions = cursorEntries.map(([k, v]) => {
2797
- const col = this.toSqlColumn(k);
2874
+ const rawCol = this.toColumn(k);
2875
+ const col = this.q(rawCol);
2798
2876
  // orderBy values can be the { sort, nulls } spec form: normalize
2799
2877
  // before comparing, or a desc spec would seek the ascending side.
2800
- const dir = orderDirByKey.get(k);
2878
+ const dir = orderDirByColumn.get(rawCol);
2801
2879
  const desc = (0, filters_js_1.isOrderBySpec)(dir) ? dir.sort === 'desc' : dir === 'desc';
2802
2880
  const op = desc ? '<' : '>';
2803
2881
  freshParams.push(v);
@@ -3128,6 +3206,15 @@ class QueryInterface {
3128
3206
  // findFirst, like findMany but returns a single row or null
3129
3207
  // -------------------------------------------------------------------------
3130
3208
  async findFirst(args) {
3209
+ // BEFORE the strategy branch, not only inside the builders, so the
3210
+ // join-vs-batched decision and its dev warning see the same declared name
3211
+ // every other stage does. `planAuto` splits `with` on the caller's keys and
3212
+ // `runAutoSplit` carries them onward; each downstream consumer normalizes
3213
+ // too, so this is not load-bearing for correctness (measured: removing it
3214
+ // changes the warning text from the declared name back to the caller's and
3215
+ // nothing else). It is here so the invariant holds at the seam rather than
3216
+ // depending on every consumer to re-establish it.
3217
+ args = this.withDeclaredRelationNames(args);
3131
3218
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
3132
3219
  if (args?.with) {
3133
3220
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
@@ -3705,9 +3792,12 @@ class QueryInterface {
3705
3792
  // pick.where / pick.orderBy paths). To-one relation orderBy carries the
3706
3793
  // target's global filter once per ordered column.
3707
3794
  if (this.isRelationOrderByValue(dir)) {
3708
- const relDef = (0, utils_js_1.ownLookup)(this.tableMeta.relations, key);
3795
+ // Mirrors the build path's resolution, so a cache HIT binds params for
3796
+ // the same relation the cached SQL was built from.
3797
+ const resolvedRel = (0, utils_js_1.resolveRelation)(this.tableMeta.relations, key);
3798
+ const relDef = resolvedRel?.def;
3709
3799
  if (relDef && (0, filters_js_1.isRelationPickOrderBy)(dir)) {
3710
- this.collectRelationPickOrderParams(key, relDef, dir, params);
3800
+ this.collectRelationPickOrderParams(resolvedRel.name, relDef, dir, params);
3711
3801
  }
3712
3802
  else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
3713
3803
  this.collectRelationCountParams(relDef, params);
@@ -113,7 +113,9 @@ function isRealKey(meta, key) {
113
113
  return ((0, utils_js_1.ownLookup)(meta.columnMap, key) !== undefined ||
114
114
  (0, utils_js_1.ownLookup)(meta.reverseColumnMap, key) !== undefined ||
115
115
  meta.allColumns.includes(key) ||
116
- (0, utils_js_1.ownLookup)(meta.relations, key) !== undefined);
116
+ // Resolved, so a relation named the snake_case way still reads as a real
117
+ // key here and is not mistaken for a compound-unique selector.
118
+ (0, utils_js_1.resolveRelationDef)(meta.relations, key) !== undefined);
117
119
  }
118
120
  /**
119
121
  * A candidate compound-unique selector value: a plain object that is not a
@@ -155,16 +157,38 @@ function expandCompoundUniqueWhere(meta, where) {
155
157
  continue; // unknown key, falls through to the standard E003
156
158
  const selector = value;
157
159
  const provided = Object.keys(selector).filter((k) => selector[k] !== undefined);
158
- const expected = new Set(fields);
159
- const exact = provided.length === expected.size && provided.every((k) => expected.has(k));
160
+ // Matched by the COLUMN each member name resolves to, not by the literal
161
+ // key. The selector's own NAME is registered under both spellings (see
162
+ // `register` above), so `{ org_id_user_id: { org_id, user_id } }` found the
163
+ // selector and was then refused for its members. Insertion order is
164
+ // `fields` order, keeping the expansion (and the SQL) stable.
165
+ const expected = new Map();
166
+ for (const f of fields)
167
+ expected.set((0, utils_js_1.resolveColumnName)(meta, f) ?? f, f);
168
+ /** column → the key the caller actually wrote for it. */
169
+ const providedByColumn = new Map();
170
+ let ambiguous = false;
171
+ for (const k of provided) {
172
+ const column = (0, utils_js_1.resolveColumnName)(meta, k);
173
+ // Unresolvable, or two spellings of one column: fall through to the
174
+ // same refusal an incomplete member set gets.
175
+ if (column === undefined || providedByColumn.has(column)) {
176
+ ambiguous = true;
177
+ break;
178
+ }
179
+ providedByColumn.set(column, k);
180
+ }
181
+ const exact = !ambiguous &&
182
+ providedByColumn.size === expected.size &&
183
+ [...providedByColumn.keys()].every((c) => expected.has(c));
160
184
  if (!exact) {
161
185
  throw new errors_js_1.ValidationError(`[turbine] Compound unique selector "${key}" on table "${meta.name}" must supply exactly ` +
162
186
  `{ ${fields.join(', ')} }, received { ${provided.join(', ') || '(none)'} }.`);
163
187
  }
164
188
  result ??= { ...where };
165
189
  delete result[key];
166
- for (const field of fields) {
167
- const v = selector[field];
190
+ for (const [column, field] of expected) {
191
+ const v = selector[providedByColumn.get(column)];
168
192
  if (Object.hasOwn(result, field)) {
169
193
  // A member field is ALSO given directly in the outer where: wrap the
170
194
  // expansion in AND so neither value is clobbered.