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
@@ -56,7 +56,7 @@
56
56
  import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
57
57
  import { normalizeKeyColumns } from '../schema.js';
58
58
  import { dedupeOrderEntries, isOrderBySpec, isRelationPickOrderBy, orderByEntries, sortedEntries } from './filters.js';
59
- import { markInternalCombinator, ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage, sqlToPreparedName, } from './utils.js';
59
+ import { markInternalCombinator, ownLookup, resolveColumnName, selectNamesNothingMessage, selectOmitExclusiveMessage, sqlToPreparedName, } from './utils.js';
60
60
  /**
61
61
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
62
62
  * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit, it
@@ -153,8 +153,12 @@ function partitionOrderBy(meta, orderBy) {
153
153
  return null;
154
154
  const out = [];
155
155
  for (const [key, value] of entries) {
156
- const column = ownLookup(meta.columnMap, key);
157
- if (!column || !meta.allColumns.includes(column))
156
+ // The one key-resolution rule. A bare `columnMap` read knows only the
157
+ // FIELD spelling, so a snake-spelled orderBy declined the partition
158
+ // pushdown and silently fell back to fetching every child row and slicing
159
+ // client-side: two strategies, different bytes over the wire, one query.
160
+ const column = resolveColumnName(meta, key);
161
+ if (column === undefined)
158
162
  return null;
159
163
  let sort;
160
164
  let nulls;
@@ -265,7 +269,18 @@ export function assertProjectionShape(table, select, omit) {
265
269
  throw new ValidationError(selectOmitExclusiveMessage(table));
266
270
  }
267
271
  }
268
- export function includeKeysForBatching(select, omit, fields,
272
+ export function includeKeysForBatching(
273
+ /**
274
+ * The table the projection is compiled against. `select` / `omit` keys are
275
+ * the CALLER's, and a column has two legal spellings there, so matching by
276
+ * raw key made "is the correlation key already projected?" depend on which
277
+ * was used: `select: { user_id: true }` with a `userId` key looked
278
+ * unprojected, so the key was force-added AND marked stitch-only and
279
+ * `stripFields` deleted the very column the caller asked for, while
280
+ * `omit: { user_id: true }` failed to un-omit and tripped
281
+ * `assertCorrelationKeyProjected`'s "bug in turbine" path on a legal query.
282
+ */
283
+ meta, select, omit, fields,
269
284
  /**
270
285
  * The default projection for this table when it is NOT `select`/`omit`-driven:
271
286
  * `hidden` are fields the default projection leaves out (today: PII-tagged
@@ -279,23 +294,43 @@ export function includeKeysForBatching(select, omit, fields,
279
294
  */
280
295
  defaultProjection) {
281
296
  const unique = [...new Set(fields)];
297
+ /**
298
+ * The caller's projection keys indexed by the column each resolves to, so a
299
+ * correlation key is recognized under either spelling. A key resolving to no
300
+ * column is left out: the projection build raises E003, where it belongs.
301
+ */
302
+ const keyByColumn = (projection) => {
303
+ const byColumn = new Map();
304
+ for (const key of Object.keys(projection)) {
305
+ const column = resolveColumnName(meta, key);
306
+ if (column !== undefined)
307
+ byColumn.set(column, key);
308
+ }
309
+ return byColumn;
310
+ };
311
+ /** `fields` are canonical field names, but resolve them anyway rather than assume. */
312
+ const columnOf = (field) => resolveColumnName(meta, field) ?? field;
282
313
  if (select) {
283
314
  const next = { ...select };
284
315
  const strip = [];
316
+ const selected = keyByColumn(select);
285
317
  for (const f of unique) {
286
- if (!next[f]) {
287
- next[f] = true;
288
- strip.push(f); // not requested by the caller, added only to stitch
289
- }
318
+ const existing = selected.get(columnOf(f));
319
+ if (existing !== undefined && next[existing])
320
+ continue; // already projected, under either spelling
321
+ next[f] = true;
322
+ strip.push(f); // not requested by the caller, added only to stitch
290
323
  }
291
324
  return { select: next, omit, strip };
292
325
  }
293
326
  if (omit) {
294
327
  const next = { ...omit };
295
328
  const strip = [];
329
+ const omitted = keyByColumn(omit);
296
330
  for (const f of unique) {
297
- if (next[f]) {
298
- delete next[f]; // un-omit so the key is present; the caller wanted it gone
331
+ const existing = omitted.get(columnOf(f));
332
+ if (existing !== undefined && next[existing]) {
333
+ delete next[existing]; // un-omit so the key is present; the caller wanted it gone
299
334
  strip.push(f);
300
335
  }
301
336
  }
@@ -623,7 +658,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
623
658
  const orderFields = pushDownLimit
624
659
  ? (windowOrder ?? []).map((o) => targetMeta.reverseColumnMap[o.column] ?? o.column)
625
660
  : [];
626
- const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...orderFields, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
661
+ const proj = includeKeysForBatching(targetMeta, options.select, options.omit, [childKeyField, ...orderFields, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
627
662
  const child = ctx.makeChild(rel.to);
628
663
  const buildChunk = (chunk) => child.buildFindMany({
629
664
  where: mergeChildWhere(options.where, childKeyField, chunk),
@@ -826,7 +861,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
826
861
  // reason as the to-many loader above: this level's PK is not the only key the
827
862
  // recursion below will ask these rows for.
828
863
  assertProjectionShape(targetMeta.name, options.select, options.omit);
829
- const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
864
+ const proj = includeKeysForBatching(targetMeta, options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
830
865
  const child = ctx.makeChild(rel.to);
831
866
  const buildTargetChunk = (chunk) => child.buildFindMany({
832
867
  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
@@ -19,9 +19,10 @@ import * as aggMod from './aggregates.js';
19
19
  import { assertProjectionShape, defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
20
20
  import { expandCompoundUniqueWhere } from './compound-unique.js';
21
21
  import { dedupeColumnList, dedupeOrderEntries, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
22
+ import { normalizeWithClause } from './relation-names.js';
22
23
  import * as relationsMod from './relations.js';
23
24
  import { resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './types.js';
24
- import { isTemporalInfinity, LRUCache, ownLookup, parseDbDate, resolveColumnName, sqlToPreparedName, unknownFieldMessage, warnRedundantSortTerm, } from './utils.js';
25
+ import { isTemporalInfinity, LRUCache, ownLookup, parseDbDate, resolveColumnName, resolveRelation, resolveRelationDef, sqlToPreparedName, unknownFieldMessage, warnRedundantSortTerm, } from './utils.js';
25
26
  import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
26
27
  import * as whereMod from './where.js';
27
28
  import * as writesMod from './writes.js';
@@ -1064,6 +1065,24 @@ export class QueryInterface {
1064
1065
  this.currentJsonEncoding = argEncoding;
1065
1066
  return argEncoding;
1066
1067
  }
1068
+ /**
1069
+ * `args` with every `with` relation key replaced by the relation's DECLARED
1070
+ * spelling, so a snake_case relation name resolves the way a snake_case
1071
+ * column name already does.
1072
+ *
1073
+ * Returns `args` by reference when nothing needed rewriting, which is every
1074
+ * query that already spells its relations the declared way. Runs before the
1075
+ * stable-order pass and before `withFingerprint`, so the whole pipeline, and
1076
+ * the SQL cache key with it, sees one spelling. The rule and the reason it is
1077
+ * applied ONCE here rather than at each of the six `with` walkers are in
1078
+ * query/relation-names.ts.
1079
+ */
1080
+ withDeclaredRelationNames(args) {
1081
+ if (!args?.with)
1082
+ return args;
1083
+ const normalized = normalizeWithClause(this.schema, this.table, args.with);
1084
+ return normalized === args.with ? args : { ...args, with: normalized };
1085
+ }
1067
1086
  /**
1068
1087
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
1069
1088
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -1084,7 +1103,7 @@ export class QueryInterface {
1084
1103
  for (const [relName, spec] of Object.entries(withClause)) {
1085
1104
  if (relName === '_count' || !spec)
1086
1105
  continue; // `_count` is a count, not a row load
1087
- const rel = ownLookup(meta.relations, relName);
1106
+ const rel = resolveRelationDef(meta.relations, relName);
1088
1107
  if (!rel)
1089
1108
  continue; // unknown relation, let the build path surface E005
1090
1109
  const options = spec === true ? {} : spec;
@@ -1471,7 +1490,7 @@ export class QueryInterface {
1471
1490
  }
1472
1491
  continue;
1473
1492
  }
1474
- const rel = ownLookup(this.tableMeta.relations, key);
1493
+ const rel = resolveRelationDef(this.tableMeta.relations, key);
1475
1494
  if (!rel) {
1476
1495
  joinWith[key] = spec; // unknown relation, let the join path surface E005
1477
1496
  continue;
@@ -1581,7 +1600,7 @@ export class QueryInterface {
1581
1600
  const includePii = resolveUnsafeFlag(args.includePii, 'includePii');
1582
1601
  const needed = neededParentKeyFields(this.tableMeta, batchedWith);
1583
1602
  assertProjectionShape(this.table, args.select, args.omit);
1584
- const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, includePii));
1603
+ const proj = includeKeysForBatching(this.tableMeta, args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, includePii));
1585
1604
  const hasJoin = Object.keys(joinWith).length > 0;
1586
1605
  // Force the residual `with` onto the join plan so the base query never
1587
1606
  // re-enters this auto planning.
@@ -1719,6 +1738,10 @@ export class QueryInterface {
1719
1738
  * from the returned rows, so the shape matches the join strategy exactly.
1720
1739
  */
1721
1740
  async runFindManyBatched(args) {
1741
+ // Declared relation spellings first, exactly as the join path does in
1742
+ // buildFindMany: the loader reads `args.with` itself, so without this the
1743
+ // two strategies would disagree about which relation names are valid.
1744
+ args = this.withDeclaredRelationNames(args);
1722
1745
  // Stable relation order (opt-in): the batched loader forwards each relation's
1723
1746
  // orderBy into its follow-up query, so filling the synthesized PK order here
1724
1747
  // makes the batched output deterministic exactly like the join path.
@@ -1753,7 +1776,7 @@ export class QueryInterface {
1753
1776
  prepareBatchedBase(args, withClause) {
1754
1777
  const needed = neededParentKeyFields(this.tableMeta, withClause);
1755
1778
  assertProjectionShape(this.table, args.select, args.omit);
1756
- const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
1779
+ const proj = includeKeysForBatching(this.tableMeta, args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
1757
1780
  const baseArgs = {
1758
1781
  ...args,
1759
1782
  with: undefined,
@@ -2125,6 +2148,15 @@ export class QueryInterface {
2125
2148
  // findUnique
2126
2149
  // -------------------------------------------------------------------------
2127
2150
  async findUnique(args) {
2151
+ // BEFORE the strategy branch, not only inside the builders, so the
2152
+ // join-vs-batched decision and its dev warning see the same declared name
2153
+ // every other stage does. `planAuto` splits `with` on the caller's keys and
2154
+ // `runAutoSplit` carries them onward; each downstream consumer normalizes
2155
+ // too, so this is not load-bearing for correctness (measured: removing it
2156
+ // changes the warning text from the declared name back to the caller's and
2157
+ // nothing else). It is here so the invariant holds at the seam rather than
2158
+ // depending on every consumer to re-establish it.
2159
+ args = this.withDeclaredRelationNames(args);
2128
2160
  return this.executeWithMiddleware('findUnique', args, async () => {
2129
2161
  if (args.with) {
2130
2162
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
@@ -2152,6 +2184,8 @@ export class QueryInterface {
2152
2184
  * strategy's shape for the one row.
2153
2185
  */
2154
2186
  async runFindUniqueBatched(args) {
2187
+ // Declared relation spellings first, see runFindManyBatched.
2188
+ args = this.withDeclaredRelationNames(args);
2155
2189
  // Stable relation order (opt-in), see runFindManyBatched.
2156
2190
  const withClause = this.resolveStableOrder(args.stableRelationOrder)
2157
2191
  ? this.applyStableRelationOrder(args.with, this.table)
@@ -2160,7 +2194,7 @@ export class QueryInterface {
2160
2194
  rejectNestedPickOrder(withClause);
2161
2195
  const needed = neededParentKeyFields(this.tableMeta, withClause);
2162
2196
  assertProjectionShape(this.table, args.select, args.omit);
2163
- const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
2197
+ const proj = includeKeysForBatching(this.tableMeta, args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
2164
2198
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
2165
2199
  const deferred = this.buildFindUnique(baseArgs);
2166
2200
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
@@ -2205,6 +2239,9 @@ export class QueryInterface {
2205
2239
  'A key whose value is `undefined` does not count, check that the value you are looking up is defined. ' +
2206
2240
  'If you meant "any row matching an optional filter", use `findFirst`.');
2207
2241
  }
2242
+ // Declared relation spellings first, before stable-order and the
2243
+ // fingerprint (see buildFindMany).
2244
+ args = this.withDeclaredRelationNames(args);
2208
2245
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2209
2246
  // relations before fingerprinting (see buildFindMany).
2210
2247
  if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
@@ -2247,7 +2284,9 @@ export class QueryInterface {
2247
2284
  !whereObj.NOT &&
2248
2285
  whereKeys.every((k) => {
2249
2286
  const v = whereObj[k];
2250
- return v !== null && !isWhereOperator(v) && !ownLookup(this.tableMeta.relations, k);
2287
+ // Resolved, not looked up: a relation filter spelled the snake_case
2288
+ // way must not read as a plain equality and take the simple path.
2289
+ return v !== null && !isWhereOperator(v) && !resolveRelationDef(this.tableMeta.relations, k);
2251
2290
  });
2252
2291
  // Simple path: plain equality, no operators/null/OR.
2253
2292
  //
@@ -2343,6 +2382,15 @@ export class QueryInterface {
2343
2382
  // findMany
2344
2383
  // -------------------------------------------------------------------------
2345
2384
  async findMany(args) {
2385
+ // BEFORE the strategy branch, not only inside the builders, so the
2386
+ // join-vs-batched decision and its dev warning see the same declared name
2387
+ // every other stage does. `planAuto` splits `with` on the caller's keys and
2388
+ // `runAutoSplit` carries them onward; each downstream consumer normalizes
2389
+ // too, so this is not load-bearing for correctness (measured: removing it
2390
+ // changes the warning text from the declared name back to the caller's and
2391
+ // nothing else). It is here so the invariant holds at the seam rather than
2392
+ // depending on every consumer to re-establish it.
2393
+ args = this.withDeclaredRelationNames(args);
2346
2394
  this.maybeWarnUnlimited(args);
2347
2395
  this.maybeWarnUnorderedPage(args);
2348
2396
  // Dev-only: warn on deeply nested with clauses
@@ -2493,8 +2541,12 @@ export class QueryInterface {
2493
2541
  const isScalarEquality = value !== null && (typeof value !== 'object' || value instanceof Date) && typeof value !== 'function';
2494
2542
  if (!isScalarEquality)
2495
2543
  return false;
2496
- const column = ownLookup(this.tableMeta.columnMap, field);
2497
- if (!column)
2544
+ // The one key-resolution rule: a bare `columnMap` read knows only the
2545
+ // FIELD spelling, so `where: { user_id: 1 }` on a unique column did not
2546
+ // count as pinning it and drew a spurious unlimited-read warning the
2547
+ // camelCase spelling of the same query did not.
2548
+ const column = resolveColumnName(this.tableMeta, field);
2549
+ if (column === undefined)
2498
2550
  return false;
2499
2551
  pinned.add(column);
2500
2552
  }
@@ -2521,6 +2573,11 @@ export class QueryInterface {
2521
2573
  this.currentSkip = resolveSkipGlobalFilters(args?.skipGlobalFilters);
2522
2574
  // Pinned before the flatten plan and the cache key, both of which read it.
2523
2575
  const jsonEncoding = this.resolveJsonEncoding(args?.jsonEncoding);
2576
+ // Relation names to their declared spelling FIRST, before the stable-order
2577
+ // pass and the fingerprint below and before any of the six `with` walkers,
2578
+ // so none of them needs to know a relation has two accepted spellings and
2579
+ // both spellings share one cache entry. See query/relation-names.ts.
2580
+ args = this.withDeclaredRelationNames(args);
2524
2581
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2525
2582
  // relations BEFORE fingerprinting, so the two orderings get distinct cache
2526
2583
  // entries and every downstream path (SQL build, collect, parser) inherits it.
@@ -2632,7 +2689,13 @@ export class QueryInterface {
2632
2689
  // distinct key (correct, it emits a different ORDER BY).
2633
2690
  const orderFp = args?.orderBy
2634
2691
  ? orderByEntries(args.orderBy)
2635
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, ownLookup(this.tableMeta.relations, k)?.to)}`)
2692
+ // Keyed by the DECLARED relation name (falling back to the key
2693
+ // itself for a column), so the two spellings of one orderBy share a
2694
+ // cache entry instead of minting two templates for one query.
2695
+ .map(([k, d]) => {
2696
+ const rel = resolveRelation(this.tableMeta.relations, k);
2697
+ return `${rel?.name ?? k}:${this.orderByEntryFingerprint(d, rel?.def.to)}`;
2698
+ })
2636
2699
  .join(',')
2637
2700
  : '';
2638
2701
  const cursorFp = args?.cursor
@@ -2754,12 +2817,27 @@ export class QueryInterface {
2754
2817
  // Resolve the seek direction per cursor field from the flattened
2755
2818
  // orderBy entries (last wins, matching object-key semantics), so both
2756
2819
  // the object and array orderBy forms drive the cursor comparison.
2757
- const orderDirByKey = new Map(orderByEntries(args.orderBy));
2820
+ //
2821
+ // Indexed by the RESOLVED COLUMN, never the caller's key: both
2822
+ // `cursor` and `orderBy` take either spelling, so a cursor written
2823
+ // `{ created_at }` against `orderBy: { createdAt: 'desc' }` missed
2824
+ // this lookup, defaulted to ascending, and emitted `created_at > $n`
2825
+ // under `ORDER BY created_at DESC` — the wrong page, silently. Same
2826
+ // failure the `{ sort, nulls }` normalization below prevents, reached
2827
+ // through the spelling instead of the value shape. A relation /
2828
+ // JSON-path / vector key resolves to no column and is skipped.
2829
+ const orderDirByColumn = new Map();
2830
+ for (const [ok, od] of orderByEntries(args.orderBy)) {
2831
+ const ocol = resolveColumnName(this.tableMeta, ok);
2832
+ if (ocol !== undefined)
2833
+ orderDirByColumn.set(ocol, od);
2834
+ }
2758
2835
  const cursorConditions = cursorEntries.map(([k, v]) => {
2759
- const col = this.toSqlColumn(k);
2836
+ const rawCol = this.toColumn(k);
2837
+ const col = this.q(rawCol);
2760
2838
  // orderBy values can be the { sort, nulls } spec form: normalize
2761
2839
  // before comparing, or a desc spec would seek the ascending side.
2762
- const dir = orderDirByKey.get(k);
2840
+ const dir = orderDirByColumn.get(rawCol);
2763
2841
  const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
2764
2842
  const op = desc ? '<' : '>';
2765
2843
  freshParams.push(v);
@@ -3090,6 +3168,15 @@ export class QueryInterface {
3090
3168
  // findFirst, like findMany but returns a single row or null
3091
3169
  // -------------------------------------------------------------------------
3092
3170
  async findFirst(args) {
3171
+ // BEFORE the strategy branch, not only inside the builders, so the
3172
+ // join-vs-batched decision and its dev warning see the same declared name
3173
+ // every other stage does. `planAuto` splits `with` on the caller's keys and
3174
+ // `runAutoSplit` carries them onward; each downstream consumer normalizes
3175
+ // too, so this is not load-bearing for correctness (measured: removing it
3176
+ // changes the warning text from the declared name back to the caller's and
3177
+ // nothing else). It is here so the invariant holds at the seam rather than
3178
+ // depending on every consumer to re-establish it.
3179
+ args = this.withDeclaredRelationNames(args);
3093
3180
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
3094
3181
  if (args?.with) {
3095
3182
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
@@ -3667,9 +3754,12 @@ export class QueryInterface {
3667
3754
  // pick.where / pick.orderBy paths). To-one relation orderBy carries the
3668
3755
  // target's global filter once per ordered column.
3669
3756
  if (this.isRelationOrderByValue(dir)) {
3670
- const relDef = ownLookup(this.tableMeta.relations, key);
3757
+ // Mirrors the build path's resolution, so a cache HIT binds params for
3758
+ // the same relation the cached SQL was built from.
3759
+ const resolvedRel = resolveRelation(this.tableMeta.relations, key);
3760
+ const relDef = resolvedRel?.def;
3671
3761
  if (relDef && isRelationPickOrderBy(dir)) {
3672
- this.collectRelationPickOrderParams(key, relDef, dir, params);
3762
+ this.collectRelationPickOrderParams(resolvedRel.name, relDef, dir, params);
3673
3763
  }
3674
3764
  else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
3675
3765
  this.collectRelationCountParams(relDef, params);
@@ -40,7 +40,7 @@
40
40
  */
41
41
  import { ValidationError } from '../errors.js';
42
42
  import { isArrayFilter, isJsonFilter, isVectorFilter, isWhereOperator } from './filters.js';
43
- import { ownLookup } from './utils.js';
43
+ import { ownLookup, resolveColumnName, resolveRelationDef } from './utils.js';
44
44
  const syntheticKeyCache = new WeakMap();
45
45
  /**
46
46
  * Build (once per table) the map from a synthetic selector name to the ordered
@@ -110,7 +110,9 @@ function isRealKey(meta, key) {
110
110
  return (ownLookup(meta.columnMap, key) !== undefined ||
111
111
  ownLookup(meta.reverseColumnMap, key) !== undefined ||
112
112
  meta.allColumns.includes(key) ||
113
- ownLookup(meta.relations, key) !== undefined);
113
+ // Resolved, so a relation named the snake_case way still reads as a real
114
+ // key here and is not mistaken for a compound-unique selector.
115
+ resolveRelationDef(meta.relations, key) !== undefined);
114
116
  }
115
117
  /**
116
118
  * A candidate compound-unique selector value: a plain object that is not a
@@ -152,16 +154,38 @@ export function expandCompoundUniqueWhere(meta, where) {
152
154
  continue; // unknown key, falls through to the standard E003
153
155
  const selector = value;
154
156
  const provided = Object.keys(selector).filter((k) => selector[k] !== undefined);
155
- const expected = new Set(fields);
156
- const exact = provided.length === expected.size && provided.every((k) => expected.has(k));
157
+ // Matched by the COLUMN each member name resolves to, not by the literal
158
+ // key. The selector's own NAME is registered under both spellings (see
159
+ // `register` above), so `{ org_id_user_id: { org_id, user_id } }` found the
160
+ // selector and was then refused for its members. Insertion order is
161
+ // `fields` order, keeping the expansion (and the SQL) stable.
162
+ const expected = new Map();
163
+ for (const f of fields)
164
+ expected.set(resolveColumnName(meta, f) ?? f, f);
165
+ /** column → the key the caller actually wrote for it. */
166
+ const providedByColumn = new Map();
167
+ let ambiguous = false;
168
+ for (const k of provided) {
169
+ const column = resolveColumnName(meta, k);
170
+ // Unresolvable, or two spellings of one column: fall through to the
171
+ // same refusal an incomplete member set gets.
172
+ if (column === undefined || providedByColumn.has(column)) {
173
+ ambiguous = true;
174
+ break;
175
+ }
176
+ providedByColumn.set(column, k);
177
+ }
178
+ const exact = !ambiguous &&
179
+ providedByColumn.size === expected.size &&
180
+ [...providedByColumn.keys()].every((c) => expected.has(c));
157
181
  if (!exact) {
158
182
  throw new ValidationError(`[turbine] Compound unique selector "${key}" on table "${meta.name}" must supply exactly ` +
159
183
  `{ ${fields.join(', ')} }, received { ${provided.join(', ') || '(none)'} }.`);
160
184
  }
161
185
  result ??= { ...where };
162
186
  delete result[key];
163
- for (const field of fields) {
164
- const v = selector[field];
187
+ for (const [column, field] of expected) {
188
+ const v = selector[providedByColumn.get(column)];
165
189
  if (Object.hasOwn(result, field)) {
166
190
  // A member field is ALSO given directly in the outer where: wrap the
167
191
  // expansion in AND so neither value is clobbered.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Caller-supplied RELATION names, normalized to their declared spelling once,
3
+ * before anything reads them.
4
+ *
5
+ * ## The rule
6
+ *
7
+ * A relation has one declared name (`ripeningChecks`), while the DDL anyone
8
+ * reads has only the table name (`ripening_checks`). Writing back what the
9
+ * schema shows therefore failed: E005 in `with`, E003 in a relation filter,
10
+ * E005 in `orderBy`, on names the error text was already computing correctly
11
+ * ("Did you mean ...?"). This accepts the snake_case spelling wherever the
12
+ * declared name is accepted, by the same resolve-then-validate rule
13
+ * {@link resolveRelation} states, which is the relation-level twin of
14
+ * `resolveColumnName`. It is not a guess: `snakeToCamel(key)` is accepted ONLY
15
+ * when it names a real declared relation, so a typo is still a typo, and an
16
+ * exact declared name always wins first.
17
+ *
18
+ * ## Why this is ONE pass up front and not a fix at each lookup
19
+ *
20
+ * The `with` tree is walked by SIX independent functions, each of which decides
21
+ * for itself which keys name relations: `withFingerprint`, `collectWithParams`,
22
+ * `buildRelationShapes`, `planFlattenWith`, `buildSelectWithRelations` and the
23
+ * batched loader, plus the positional row parser. Teaching each of them that a
24
+ * key has two spellings would make seven places that must agree about it, and
25
+ * disagreement is not a clean failure: the fingerprint is the SQL-cache key, so
26
+ * a walker that resolved differently from the builder would serve one query's
27
+ * template to another, silently. That is the drift class this repo has paid for
28
+ * repeatedly (the where-clause walkers, the two projection resolvers).
29
+ *
30
+ * Normalizing before any walker runs makes all seven correct with no knowledge
31
+ * of the second spelling, and keeps ONE authority for the rule.
32
+ *
33
+ * ## Shape
34
+ *
35
+ * Returns the SAME object when nothing needed rewriting, which is every query
36
+ * that already spells its relations the declared way, so the common path
37
+ * allocates nothing and is reference-identical to its input.
38
+ *
39
+ * An UNRESOLVABLE key is left exactly as written, deliberately. Reporting it is
40
+ * the builder's job, and it already names the offending key and lists the
41
+ * available relations; rejecting it here would move that error away from its
42
+ * context and change which error type callers see.
43
+ */
44
+ import type { SchemaMetadata } from '../schema.js';
45
+ import type { WithClause } from './types.js';
46
+ /**
47
+ * `withClause` with every relation key replaced by its declared spelling,
48
+ * recursively, including the relation names inside a `_count`.
49
+ *
50
+ * Returns the input by reference when no key changed.
51
+ */
52
+ export declare function normalizeWithClause(schema: SchemaMetadata, table: string, withClause: WithClause | undefined, depth?: number): WithClause | undefined;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Caller-supplied RELATION names, normalized to their declared spelling once,
3
+ * before anything reads them.
4
+ *
5
+ * ## The rule
6
+ *
7
+ * A relation has one declared name (`ripeningChecks`), while the DDL anyone
8
+ * reads has only the table name (`ripening_checks`). Writing back what the
9
+ * schema shows therefore failed: E005 in `with`, E003 in a relation filter,
10
+ * E005 in `orderBy`, on names the error text was already computing correctly
11
+ * ("Did you mean ...?"). This accepts the snake_case spelling wherever the
12
+ * declared name is accepted, by the same resolve-then-validate rule
13
+ * {@link resolveRelation} states, which is the relation-level twin of
14
+ * `resolveColumnName`. It is not a guess: `snakeToCamel(key)` is accepted ONLY
15
+ * when it names a real declared relation, so a typo is still a typo, and an
16
+ * exact declared name always wins first.
17
+ *
18
+ * ## Why this is ONE pass up front and not a fix at each lookup
19
+ *
20
+ * The `with` tree is walked by SIX independent functions, each of which decides
21
+ * for itself which keys name relations: `withFingerprint`, `collectWithParams`,
22
+ * `buildRelationShapes`, `planFlattenWith`, `buildSelectWithRelations` and the
23
+ * batched loader, plus the positional row parser. Teaching each of them that a
24
+ * key has two spellings would make seven places that must agree about it, and
25
+ * disagreement is not a clean failure: the fingerprint is the SQL-cache key, so
26
+ * a walker that resolved differently from the builder would serve one query's
27
+ * template to another, silently. That is the drift class this repo has paid for
28
+ * repeatedly (the where-clause walkers, the two projection resolvers).
29
+ *
30
+ * Normalizing before any walker runs makes all seven correct with no knowledge
31
+ * of the second spelling, and keeps ONE authority for the rule.
32
+ *
33
+ * ## Shape
34
+ *
35
+ * Returns the SAME object when nothing needed rewriting, which is every query
36
+ * that already spells its relations the declared way, so the common path
37
+ * allocates nothing and is reference-identical to its input.
38
+ *
39
+ * An UNRESOLVABLE key is left exactly as written, deliberately. Reporting it is
40
+ * the builder's job, and it already names the offending key and lists the
41
+ * available relations; rejecting it here would move that error away from its
42
+ * context and change which error type callers see.
43
+ */
44
+ import { resolveRelation } from './utils.js';
45
+ /** Depth cap mirroring the builder's own, so a cyclic `with` cannot spin here. */
46
+ const MAX_DEPTH = 12;
47
+ /**
48
+ * `withClause` with every relation key replaced by its declared spelling,
49
+ * recursively, including the relation names inside a `_count`.
50
+ *
51
+ * Returns the input by reference when no key changed.
52
+ */
53
+ export function normalizeWithClause(schema, table, withClause, depth = 0) {
54
+ if (!withClause || typeof withClause !== 'object' || depth > MAX_DEPTH)
55
+ return withClause;
56
+ const meta = schema.tables[table];
57
+ if (!meta)
58
+ return withClause;
59
+ let changed = false;
60
+ const out = {};
61
+ for (const [key, spec] of Object.entries(withClause)) {
62
+ if (key === '_count') {
63
+ const nextCount = normalizeCount(meta, spec);
64
+ if (nextCount !== spec)
65
+ changed = true;
66
+ out[key] = nextCount;
67
+ continue;
68
+ }
69
+ const resolved = resolveRelation(meta.relations, key);
70
+ // Unknown key: keep it verbatim and let the builder raise E005 by name.
71
+ const name = resolved?.name ?? key;
72
+ if (name !== key)
73
+ changed = true;
74
+ const nextSpec = resolved ? normalizeSpec(schema, resolved.def.to, spec, depth) : spec;
75
+ if (nextSpec !== spec)
76
+ changed = true;
77
+ out[name] = nextSpec;
78
+ }
79
+ return changed ? out : withClause;
80
+ }
81
+ /** A relation's `with` options, normalizing its nested `with` against the TARGET table. */
82
+ function normalizeSpec(schema, target, spec, depth) {
83
+ if (spec === true || spec === false || spec === null || typeof spec !== 'object')
84
+ return spec;
85
+ const opts = spec;
86
+ if (!opts.with)
87
+ return spec;
88
+ const nested = normalizeWithClause(schema, target, opts.with, depth + 1);
89
+ return nested === opts.with ? spec : { ...opts, with: nested };
90
+ }
91
+ /**
92
+ * `_count` names relations too. `true` means every relation and has nothing to
93
+ * rename; the record form has one key per counted relation.
94
+ */
95
+ function normalizeCount(meta, count) {
96
+ if (!count || typeof count !== 'object')
97
+ return count;
98
+ let changed = false;
99
+ const out = {};
100
+ for (const [key, value] of Object.entries(count)) {
101
+ const name = resolveRelation(meta.relations, key)?.name ?? key;
102
+ if (name !== key)
103
+ changed = true;
104
+ out[name] = value;
105
+ }
106
+ return changed ? out : count;
107
+ }
108
+ /*
109
+ * NOTE for the next person: there are deliberately no `declaredRelationName` /
110
+ * `namesRelation` wrappers here. The argument positions where a relation key
111
+ * sits INTERLEAVED with column keys (`where`'s some/every/none, `orderBy`'s
112
+ * relation targets, the simple-where fast path) cannot be normalized up front
113
+ * the way `with` can, so they call `resolveRelation` / `resolveRelationDef`
114
+ * from query/utils.ts directly at their branch point. Each of those branches is
115
+ * already a documented single authority; wrapping them here would add a second
116
+ * name for the same rule without removing a caller.
117
+ */
@@ -117,12 +117,17 @@ export declare function isRelationOrderByValue(_qi: BuilderCtx, value: unknown):
117
117
  */
118
118
  export declare function nullsSuffix(qi: BuilderCtx, nulls: 'first' | 'last' | undefined): string;
119
119
  /**
120
- * Resolve an orderBy key to its snake_case column via the table's columnMap
121
- * (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
122
- * where path uses. Shared by top-level JSON-path ordering and every nested
123
- * relation orderBy path so nested orderBy accepts exactly what top-level
124
- * accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
125
- * camelCase-named DB columns like "sortOrder").
120
+ * Resolve an orderBy key to its snake_case column via {@link resolveColumnName},
121
+ * throwing the SAME unknown-field E003 the top-level where path uses. Shared by
122
+ * top-level JSON-path ordering and every nested relation orderBy path so nested
123
+ * orderBy accepts exactly what top-level accepts (the 0.30.x bug: nested orderBy
124
+ * skipped the columnMap and rejected camelCase-named DB columns like
125
+ * "sortOrder").
126
+ *
127
+ * The rule is REACHED here, never restated: this used to inline
128
+ * `columnMap ?? camelToSnake` + an `allColumns` check, which agreed with
129
+ * `resolveColumnName` while the top-level scalar path a few lines up disagreed
130
+ * with both.
126
131
  */
127
132
  export declare function resolveOrderByColumn(_qi: BuilderCtx, table: string, meta: TableMetadata, key: string): string;
128
133
  /**