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
@@ -158,3 +158,33 @@ export interface ConnectionOptionsConfig {
158
158
  * a value that can carry a space can carry a second `-c`.
159
159
  */
160
160
  export declare function withStatementTimeoutOption(config: ConnectionOptionsConfig, statementTimeoutMs: number): ConnectionOptionsConfig;
161
+ /**
162
+ * `connectionString` with `setting` appended to an existing `options` query
163
+ * parameter, or `null` when it carries none (in which case the caller uses the
164
+ * `options` config field, which the connection string does not override).
165
+ *
166
+ * Only the query string is rewritten, never the userinfo or host, so a
167
+ * percent-encoded password cannot be mangled by a round trip through `URL`. The
168
+ * split is on the first `?`, which is also where pg's own parser puts the
169
+ * query-string boundary: a connection string with an unencoded `?` inside the
170
+ * password is not parseable by pg either, so there is no shape this handles
171
+ * differently from the driver.
172
+ *
173
+ * Shared with `client.ts`, which needs the identical merge for
174
+ * `plan_cache_mode`. It was duplicated there until 2026-08-16, and the reason
175
+ * is worth keeping because it was wrong in an instructive way: importing it
176
+ * from `client.ts` adds an import edge from a module ~100 test processes load,
177
+ * and this file's merged coverage read 40% while measuring 100% in isolation,
178
+ * which moved the aggregate gate 75.48% -> 75.03% against a 75% floor. The
179
+ * conclusion drawn was that the refactor cost the gate's headroom.
180
+ *
181
+ * The 40% was a c8 merge defect, not an import cost: c8's V8-level merge is not
182
+ * monotonic, and once the report merges at the istanbul level instead this file
183
+ * reads 100% with the edge in place (see `//merge-bug` in .c8rc.json). So the
184
+ * duplication was paying for an artifact, and the two copies are now one.
185
+ *
186
+ * Keep this module import-free regardless. That part was never about coverage:
187
+ * `query/` and `cli/` both reach it, and an edge back to `client.ts` would be
188
+ * the cycle `scripts/check-import-cycles.mjs` exists to refuse.
189
+ */
190
+ export declare function mergeConnectionStringOptions(connectionString: string, setting: string): string | null;
@@ -256,27 +256,24 @@ export function withStatementTimeoutOption(config, statementTimeoutMs) {
256
256
  * password is not parseable by pg either, so there is no shape this handles
257
257
  * differently from the driver.
258
258
  *
259
- * DELIBERATELY DUPLICATED with `TurbineClient.mergeConnectionStringOptions`,
260
- * which needs the identical merge for `plan_cache_mode`. Collapsing the two
261
- * onto this copy is the obviously correct refactor and it was tried; it is
262
- * reverted, and the reason is worth writing down because the next person will
263
- * try it too.
259
+ * Shared with `client.ts`, which needs the identical merge for
260
+ * `plan_cache_mode`. It was duplicated there until 2026-08-16, and the reason
261
+ * is worth keeping because it was wrong in an instructive way: importing it
262
+ * from `client.ts` adds an import edge from a module ~100 test processes load,
263
+ * and this file's merged coverage read 40% while measuring 100% in isolation,
264
+ * which moved the aggregate gate 75.48% -> 75.03% against a 75% floor. The
265
+ * conclusion drawn was that the refactor cost the gate's headroom.
264
266
  *
265
- * Exporting it and importing it from `client.ts` adds an import edge from a
266
- * module that ~100 test processes load. In each of those processes this file's
267
- * top level runs and its functions do not, so c8 merges ~100 top-level-only
268
- * entries against the one full entry from `pooler-guard.test.ts`, and the file
269
- * reports 40% in the merged report while measuring 100% in isolation. Real
270
- * coverage is unchanged either way, but the aggregate gate moved 75.48% ->
271
- * 75.03% against a 75% floor, i.e. the refactor spent almost all the headroom
272
- * on a reporting artifact.
267
+ * The 40% was a c8 merge defect, not an import cost: c8's V8-level merge is not
268
+ * monotonic, and once the report merges at the istanbul level instead this file
269
+ * reads 100% with the edge in place (see `//merge-bug` in .c8rc.json). So the
270
+ * duplication was paying for an artifact, and the two copies are now one.
273
271
  *
274
- * So: two copies, both small, both pure, neither reachable from the other. If
275
- * you unify them, re-measure `npm run test:coverage` as a whole and not just
276
- * this file, and raise the floor's headroom first. Keep this module
277
- * import-free regardless.
272
+ * Keep this module import-free regardless. That part was never about coverage:
273
+ * `query/` and `cli/` both reach it, and an edge back to `client.ts` would be
274
+ * the cycle `scripts/check-import-cycles.mjs` exists to refuse.
278
275
  */
279
- function mergeConnectionStringOptions(connectionString, setting) {
276
+ export function mergeConnectionStringOptions(connectionString, setting) {
280
277
  const q = connectionString.indexOf('?');
281
278
  if (q === -1)
282
279
  return null;
package/dist/powql.d.ts CHANGED
@@ -67,8 +67,34 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
67
67
  private readonly onQuery?;
68
68
  private warnedUnlimited;
69
69
  constructor(pool: PowdbPool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
70
- /** Resolve a camelCase field name (or raw snake) to its column metadata. */
70
+ /**
71
+ * Resolve a camelCase field name (or raw snake) to its column metadata.
72
+ *
73
+ * THE single key→column decision on this engine: every PowQL surface
74
+ * reaches a column through here, which is why PowDB never grew the
75
+ * per-argument asymmetry the SQL builders did. It resolves through the
76
+ * shared {@link resolveColumnName} so both engines answer alike; the direct
77
+ * name/field match stays as a fallback, since a PowDB schema may carry names
78
+ * outside the snake_case↔camelCase round trip and narrowing that would be a
79
+ * silent break rather than a fix.
80
+ */
71
81
  private column;
82
+ /**
83
+ * {@link lookupGroupKey} for the `${aggKey}:${field}` composite the aggregate
84
+ * ordering registry is keyed by: only the FIELD half needs canonicalizing.
85
+ */
86
+ private aggOrderExpr;
87
+ /**
88
+ * Read a caller-supplied groupBy result key out of a registry keyed by the
89
+ * canonical FIELD name.
90
+ *
91
+ * `by`, `orderBy` and `having` are three arguments of one call, each free to
92
+ * spell a column either way, so `by`'s choice must not decide what the others
93
+ * may name. Try the key as written (which is what carries a JSON group-key
94
+ * ALIAS, not a column), then its canonical field. Mirrors `lookupGroupKey` in
95
+ * query/aggregates.ts.
96
+ */
97
+ private lookupGroupKey;
72
98
  /**
73
99
  * PowQL column reference for a field: a dotted field reference
74
100
  * (`.snake_name`), or `alias.snake_name` when an `alias` is supplied (the F2
@@ -149,6 +175,17 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
149
175
  * which is precisely the "reports a guarantee that was never made" failure the
150
176
  * option's refusal exists to prevent.
151
177
  */
178
+ /**
179
+ * `args` with every `with` relation key replaced by the relation's DECLARED
180
+ * spelling, so PowDB accepts a snake_case relation name exactly as the SQL
181
+ * engines do. Returns `args` by reference when nothing needed rewriting.
182
+ *
183
+ * PowqlInterface is a parallel implementation rather than a subclass, so
184
+ * nothing makes this happen here automatically: a rule adopted only on the
185
+ * SQL side becomes an engine that disagrees about which queries are valid,
186
+ * which is the divergence class the projection resolver already cost.
187
+ */
188
+ private withDeclaredRelationNames;
152
189
  private assertNoForceCustomPlan;
153
190
  private assertPagination;
154
191
  /** A predicate that is always false, the empty-`in` / contradiction sentinel. */
package/dist/powql.js CHANGED
@@ -41,11 +41,12 @@ import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isPowdbDatetim
41
41
  import { assertAggregatePiiOptIn } from './query/aggregates.js';
42
42
  import { expandCompoundUniqueWhere } from './query/compound-unique.js';
43
43
  import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
44
+ import { normalizeWithClause } from './query/relation-names.js';
44
45
  // The privilege sentinel and its resolver: `includePii` / `allowFullTableScan`
45
46
  // are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
46
47
  // engines, so a spread request body cannot turn either on here either.
47
48
  import { assertDirectionToken, resolveUnsafeFlag, UNSAFE } from './query/types.js';
48
- import { escapeLike, ownLookup, relationInProjectionMessage, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
49
+ import { escapeLike, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
49
50
  import { assertJsonFilterKeys, jsonStringEntries } from './query/where.js';
50
51
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
51
52
  /**
@@ -195,9 +196,20 @@ export class PowqlInterface {
195
196
  // -------------------------------------------------------------------------
196
197
  // Column / value helpers
197
198
  // -------------------------------------------------------------------------
198
- /** Resolve a camelCase field name (or raw snake) to its column metadata. */
199
+ /**
200
+ * Resolve a camelCase field name (or raw snake) to its column metadata.
201
+ *
202
+ * THE single key→column decision on this engine: every PowQL surface
203
+ * reaches a column through here, which is why PowDB never grew the
204
+ * per-argument asymmetry the SQL builders did. It resolves through the
205
+ * shared {@link resolveColumnName} so both engines answer alike; the direct
206
+ * name/field match stays as a fallback, since a PowDB schema may carry names
207
+ * outside the snake_case↔camelCase round trip and narrowing that would be a
208
+ * silent break rather than a fix.
209
+ */
199
210
  column(field) {
200
- const snake = this.meta.columnMap[field] ?? field;
211
+ const resolved = resolveColumnName(this.meta, field);
212
+ const snake = resolved ?? ownLookup(this.meta.columnMap, field) ?? field;
201
213
  const col = this.meta.columns.find((c) => c.name === snake || c.field === field);
202
214
  if (!col) {
203
215
  throw new ValidationError(`[turbine] Unknown column "${field}" on table "${this.table}". Known: ${this.meta.columns
@@ -206,6 +218,41 @@ export class PowqlInterface {
206
218
  }
207
219
  return col;
208
220
  }
221
+ /**
222
+ * {@link lookupGroupKey} for the `${aggKey}:${field}` composite the aggregate
223
+ * ordering registry is keyed by: only the FIELD half needs canonicalizing.
224
+ */
225
+ aggOrderExpr(registry, aggKey, field) {
226
+ try {
227
+ return registry.get(`${aggKey}:${this.column(field).field}`);
228
+ }
229
+ catch {
230
+ return undefined; // not a column: the caller's own E003 is the right error
231
+ }
232
+ }
233
+ /**
234
+ * Read a caller-supplied groupBy result key out of a registry keyed by the
235
+ * canonical FIELD name.
236
+ *
237
+ * `by`, `orderBy` and `having` are three arguments of one call, each free to
238
+ * spell a column either way, so `by`'s choice must not decide what the others
239
+ * may name. Try the key as written (which is what carries a JSON group-key
240
+ * ALIAS, not a column), then its canonical field. Mirrors `lookupGroupKey` in
241
+ * query/aggregates.ts.
242
+ */
243
+ lookupGroupKey(registry, key) {
244
+ const direct = registry.get(key);
245
+ if (direct !== undefined)
246
+ return direct;
247
+ let field;
248
+ try {
249
+ field = this.column(key).field;
250
+ }
251
+ catch {
252
+ return undefined; // not a column: the caller's own E003 below is the right error
253
+ }
254
+ return registry.get(field);
255
+ }
209
256
  /**
210
257
  * PowQL column reference for a field: a dotted field reference
211
258
  * (`.snake_name`), or `alias.snake_name` when an `alias` is supplied (the F2
@@ -320,6 +367,22 @@ export class PowqlInterface {
320
367
  * which is precisely the "reports a guarantee that was never made" failure the
321
368
  * option's refusal exists to prevent.
322
369
  */
370
+ /**
371
+ * `args` with every `with` relation key replaced by the relation's DECLARED
372
+ * spelling, so PowDB accepts a snake_case relation name exactly as the SQL
373
+ * engines do. Returns `args` by reference when nothing needed rewriting.
374
+ *
375
+ * PowqlInterface is a parallel implementation rather than a subclass, so
376
+ * nothing makes this happen here automatically: a rule adopted only on the
377
+ * SQL side becomes an engine that disagrees about which queries are valid,
378
+ * which is the divergence class the projection resolver already cost.
379
+ */
380
+ withDeclaredRelationNames(args) {
381
+ if (!args?.with)
382
+ return args;
383
+ const normalized = normalizeWithClause(this.schema, this.table, args.with);
384
+ return normalized === args.with ? args : { ...args, with: normalized };
385
+ }
323
386
  assertNoForceCustomPlan(args) {
324
387
  if (args?.forceCustomPlan !== true)
325
388
  return;
@@ -760,8 +823,8 @@ export class PowqlInterface {
760
823
  else if (key === 'NOT') {
761
824
  scalar[key] = await this.resolveRelationFilters(value, timeout);
762
825
  }
763
- else if (this.meta.relations[key]) {
764
- relConds.push(await this.resolveRelationCondition(this.meta.relations[key], value, timeout));
826
+ else if (resolveRelationDef(this.meta.relations, key)) {
827
+ relConds.push(await this.resolveRelationCondition(resolveRelationDef(this.meta.relations, key), value, timeout));
765
828
  }
766
829
  else {
767
830
  scalar[key] = value;
@@ -896,7 +959,7 @@ export class PowqlInterface {
896
959
  * for a misspelling that is not there. Same message as the SQL engines.
897
960
  */
898
961
  projectionColumn(field, clause) {
899
- if (ownLookup(this.meta.relations, field)) {
962
+ if (resolveRelationDef(this.meta.relations, field)) {
900
963
  throw new ValidationError(relationInProjectionMessage(this.table, field, clause));
901
964
  }
902
965
  return this.column(field).name;
@@ -1264,6 +1327,7 @@ export class PowqlInterface {
1264
1327
  // -------------------------------------------------------------------------
1265
1328
  async findMany(args = {}) {
1266
1329
  this.assertNoForceCustomPlan(args);
1330
+ args = this.withDeclaredRelationNames(args);
1267
1331
  return this.withMiddleware('findMany', args, async () => {
1268
1332
  // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
1269
1333
  // is correct on every engine version: PowDB's projection fast path returned
@@ -1345,6 +1409,12 @@ export class PowqlInterface {
1345
1409
  const nest = nestedPlans.length > 0 || linkPlans.length > 0;
1346
1410
  const alias = nest ? 't0' : undefined;
1347
1411
  const where = this.buildWhere(resolvedWhere, params, alias);
1412
+ // PowQL's `distinct` is row-wide, so these names never reach the emitted
1413
+ // statement. They are still caller-supplied names, and a name resolves or
1414
+ // throws: reading the array for its LENGTH alone let `distinct: ['nope']`
1415
+ // succeed here while every SQL engine refuses it. Validation only.
1416
+ for (const key of args.distinct ?? [])
1417
+ this.column(key);
1348
1418
  const distinct = args.distinct?.length ? ' distinct' : '';
1349
1419
  const filter = where ? ` filter ${where}` : '';
1350
1420
  const order = this.buildOrder(args.orderBy, params, alias);
@@ -1409,6 +1479,7 @@ export class PowqlInterface {
1409
1479
  }
1410
1480
  async findUnique(args) {
1411
1481
  this.assertNoForceCustomPlan(args);
1482
+ args = this.withDeclaredRelationNames(args);
1412
1483
  // Prisma compound-unique selector → column conjunction (engine parity with
1413
1484
  // the SQL findUnique family; pure metadata, so this is a one-line adoption).
1414
1485
  if (args.where) {
@@ -1433,6 +1504,7 @@ export class PowqlInterface {
1433
1504
  });
1434
1505
  }
1435
1506
  async findFirst(args = {}) {
1507
+ args = this.withDeclaredRelationNames(args);
1436
1508
  this.assertNoForceCustomPlan(args);
1437
1509
  return this.withMiddleware('findFirst', args, async () => {
1438
1510
  const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
@@ -2364,7 +2436,7 @@ export class PowqlInterface {
2364
2436
  for (const [field, value] of Object.entries(data)) {
2365
2437
  if (value === undefined)
2366
2438
  continue;
2367
- if (this.meta.relations[field]) {
2439
+ if (resolveRelationDef(this.meta.relations, field)) {
2368
2440
  throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update(), not createMany()/upsert()`);
2369
2441
  }
2370
2442
  out.push({ col: this.column(field), value });
@@ -2382,7 +2454,11 @@ export class PowqlInterface {
2382
2454
  for (const pk of this.meta.primaryKey) {
2383
2455
  const field = this.meta.reverseColumnMap[pk] ?? pk;
2384
2456
  const col = this.meta.columns.find((c) => c.name === pk);
2457
+ // "Supplied" means under EITHER spelling: `create` resolves both, so
2458
+ // testing the camelCase field alone made `{ user_id: 'x' }` look absent,
2459
+ // generated a UUID under `userId`, and wrote the column twice.
2385
2460
  if (out[field] == null &&
2461
+ (field === pk || out[pk] == null) &&
2386
2462
  col?.hasDefault &&
2387
2463
  !col.isGenerated &&
2388
2464
  col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
@@ -2521,7 +2597,7 @@ export class PowqlInterface {
2521
2597
  for (const [field, value] of Object.entries(data)) {
2522
2598
  if (value === undefined)
2523
2599
  continue;
2524
- if (this.meta.relations[field]) {
2600
+ if (resolveRelationDef(this.meta.relations, field)) {
2525
2601
  throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update(), not updateMany()/upsert()`);
2526
2602
  }
2527
2603
  const colMeta = this.column(field);
@@ -2684,7 +2760,10 @@ export class PowqlInterface {
2684
2760
  // reselect-by-PK fetch; create/update/delete all use `returning`.
2685
2761
  await this.exec(`upsert ${this.qt} on ${this.colRefName(pkCol)} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
2686
2762
  const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
2687
- const row = await this.reselectByPk(createData[pkField], args.timeout);
2763
+ // Either spelling, as `upsertComposite` already does below: reading the
2764
+ // camelCase field alone reselected `undefined` for a snake-spelled PK and
2765
+ // reported a write that had SUCCEEDED as a NotFoundError.
2766
+ const row = await this.reselectByPk(createData[pkField] ?? createData[pkCol], args.timeout);
2688
2767
  if (!row)
2689
2768
  throw new NotFoundError({ table: this.table, where: createData });
2690
2769
  return row;
@@ -2852,13 +2931,18 @@ export class PowqlInterface {
2852
2931
  if (typeof entry === 'string') {
2853
2932
  const col = this.column(entry);
2854
2933
  assertAggregatePiiOptIn(this.table, this.meta, entry, col.name, 'groupBy `by` key', resolveUnsafeFlag(args.includePii, 'includePii'));
2855
- claim(entry, `column "${col.name}"`);
2856
- if (col.name !== entry)
2934
+ // Keyed by the canonical FIELD, never by whichever of the column's
2935
+ // two legal spellings the caller wrote (`column` accepts both), so
2936
+ // neither the result shape nor the keys `orderBy` accepts depends on
2937
+ // that choice; `lookupGroupKey` reconciles the other spelling. Same
2938
+ // rule and same result shape as the SQL groupBy (query/aggregates.ts).
2939
+ claim(col.field, `column "${col.name}"`);
2940
+ if (col.name !== col.field)
2857
2941
  claim(col.name, `column "${col.name}"`);
2858
2942
  groupExprs.push(this.colRefName(col.name));
2859
2943
  proj.push(this.colRefName(col.name));
2860
- byOrderExprs.set(entry, this.colRefName(col.name));
2861
- byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
2944
+ byOrderExprs.set(col.field, this.colRefName(col.name));
2945
+ byReaders.push({ kind: 'plain', resultKey: col.field, rowKey: col.name, col });
2862
2946
  }
2863
2947
  else {
2864
2948
  const col = this.column(entry.field);
@@ -2921,9 +3005,11 @@ export class PowqlInterface {
2921
3005
  claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
2922
3006
  const inner = this.colRefName(col.name);
2923
3007
  proj.push(`${alias}: ${powfn}(${inner})`);
2924
- aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
2925
- aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
2926
- aggInner.set(key, inner);
3008
+ // Canonical field, so the result bucket and the keys `orderBy` /
3009
+ // `having` accept are the same whichever spelling was requested.
3010
+ aggReaders.push({ alias, outKey: `${fn}:${col.field}`, numeric: true });
3011
+ aggOrderExprs.set(`${fn}:${col.field}`, `.${alias}`);
3012
+ aggInner.set(col.field, inner);
2927
3013
  }
2928
3014
  else {
2929
3015
  const col = this.column(target.field);
@@ -3059,7 +3145,7 @@ export class PowqlInterface {
3059
3145
  throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
3060
3146
  `Supported: ${[...POWQL_HAVING_AGG_FNS.keys()].join(', ')}.`);
3061
3147
  }
3062
- const inner = aggInner.get(key) ?? this.ref(key);
3148
+ const inner = this.lookupGroupKey(aggInner, key) ?? this.ref(key);
3063
3149
  conds.push(cmp(`${fn}(${inner})`, filter));
3064
3150
  }
3065
3151
  }
@@ -3107,7 +3193,9 @@ export class PowqlInterface {
3107
3193
  for (const [field, dirSpec] of Object.entries(value)) {
3108
3194
  if (dirSpec === undefined)
3109
3195
  continue;
3110
- const expr = aggOrderExprs.get(`${key}:${field}`);
3196
+ // `field` is the caller's spelling of the aggregate's target column;
3197
+ // the registry is keyed by the canonical one.
3198
+ const expr = aggOrderExprs.get(`${key}:${field}`) ?? this.aggOrderExpr(aggOrderExprs, key, field);
3111
3199
  if (!expr) {
3112
3200
  throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
3113
3201
  `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
@@ -3116,7 +3204,7 @@ export class PowqlInterface {
3116
3204
  }
3117
3205
  continue;
3118
3206
  }
3119
- const expr = byOrderExprs.get(key);
3207
+ const expr = this.lookupGroupKey(byOrderExprs, key);
3120
3208
  if (!expr) {
3121
3209
  throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
3122
3210
  }
@@ -30,19 +30,6 @@ import type { BuilderCtx } from './where.js';
30
30
  */
31
31
  export declare function assertAggregatePiiOptIn(table: string, meta: TableMetadata | undefined, field: string, column: string, usage: string, includePii: boolean | undefined): void;
32
32
  export declare function buildGroupBy<T extends object>(qi: BuilderCtx, args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
33
- /**
34
- * Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
35
- * ({@link buildOrderBy}, which validates keys against the table's physical
36
- * columns), groupBy ordering targets the columns the RESULT actually
37
- * contains: plain by-fields, JSON group-key aliases, and requested aggregates
38
- * (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
39
- * SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
40
- * mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
41
- * to accept a SELECT-alias reference in ORDER BY, and any already-bound
42
- * JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
43
- * no `$n` renumbering). An aggregate key that was not requested, or an unknown
44
- * by-key, throws {@link ValidationError} E003 listing the valid keys.
45
- */
46
33
  export declare function buildGroupByOrderBy(qi: BuilderCtx, orderBy: GroupByOrderBy | GroupByOrderBy[], byOrderExprs: Map<string, string>, aggOrderExprs: Map<string, string>): string;
47
34
  /**
48
35
  * Validate a JSON-path target (group key or aggregate target) in groupBy:
@@ -12,7 +12,7 @@ import { UnsupportedFeatureError, ValidationError } from '../errors.js';
12
12
  import { snakeToCamel } from '../schema.js';
13
13
  import { dedupeColumnList, isJsonPathOrderBy, isUnmatchedPlainObject, isVectorOrderBy, isWhereOperator, normalizeOrderBy, orderByEntries, } from './filters.js';
14
14
  import { assertOrderDirection, resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
15
- import { isTemporalInfinity, ownLookup, parseDbDate, unknownFieldMessage, warnRedundantSortTerm } from './utils.js';
15
+ import { isTemporalInfinity, ownLookup, parseDbDate, resolveColumnName, warnRedundantSortTerm } from './utils.js';
16
16
  import * as whereMod from './where.js';
17
17
  import { assertWhereDepth } from './where-compile.js';
18
18
  /**
@@ -46,12 +46,14 @@ export function assertAggregatePiiOptIn(table, meta, field, column, usage, inclu
46
46
  }
47
47
  export function buildGroupBy(qi, args) {
48
48
  const meta = qi.schema.tables[qi.table];
49
- if (meta) {
50
- for (const key of args.by) {
51
- if (typeof key === 'string' && !(key in meta.columnMap)) {
52
- throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
53
- }
54
- }
49
+ // Up-front, so a bad `by` key is reported before anything else in the call,
50
+ // and through `qi.toColumn` (the ONE `resolveColumnName` rule) rather than
51
+ // `key in meta.columnMap`, which knows only the FIELD spelling and so
52
+ // rejected the snake_case COLUMN name that `where` / `select` / `distinct`
53
+ // accept and that an introspected schema's DDL declares.
54
+ for (const key of args.by) {
55
+ if (typeof key === 'string')
56
+ qi.toColumn(key);
55
57
  }
56
58
  qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
57
59
  // Resolve the PII opt-in ONCE, here, so the sentinel check runs on every
@@ -107,18 +109,30 @@ export function buildGroupBy(qi, args) {
107
109
  for (const entry of args.by) {
108
110
  if (typeof entry === 'string') {
109
111
  const col = qi.toColumn(entry);
112
+ // The group key's identity is the COLUMN, so everything keyed off it
113
+ // uses the canonical FIELD name rather than whichever spelling the caller
114
+ // wrote. The result key above all: rows are read through `parseRow`,
115
+ // whose keys are field names, so `by: ['created_at']` keyed by the
116
+ // caller's spelling read `parsed['created_at']` and returned `undefined`
117
+ // for every group. `_count` / `_sum` / `_min` in the same transform
118
+ // already map their alias back through `reverseColumnMap`.
119
+ const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
110
120
  assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', includePii);
111
- claimResultKey(entry, `column "${col}"`);
121
+ claimResultKey(field, `column "${col}"`);
112
122
  // The emitted output column is the snake_case name; claim it too (when
113
123
  // it differs from the result key) so a JSON alias like 'created_at'
114
124
  // cannot silently shadow the 'createdAt' group key on the wire.
115
- if (col !== entry)
125
+ if (col !== field)
116
126
  claimResultKey(col, `column "${col}"`);
117
127
  groupExprs.push(qi.q(col));
118
128
  selectExprs.push(qi.q(col));
119
- byReaders.push({ resultKey: entry, rowKey: col, raw: false });
120
- byOrderExprs.set(entry, qi.q(col));
121
- havingGroupKeys.set(entry, { kind: 'column', field: entry });
129
+ byReaders.push({ resultKey: field, rowKey: col, raw: false });
130
+ // Registered ONCE, under the canonical field: `orderBy` and `having`
131
+ // may spell the same group key the other way, and {@link lookupGroupKey}
132
+ // reconciles that at lookup time rather than doubling every
133
+ // "orderable keys" list.
134
+ byOrderExprs.set(field, qi.q(col));
135
+ havingGroupKeys.set(field, { kind: 'column', field });
122
136
  }
123
137
  else {
124
138
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
@@ -199,7 +213,9 @@ export function buildGroupBy(qi, args) {
199
213
  const inner = `${sqlFn}(${qi.q(col)})`;
200
214
  const expr = aggKey === '_avg' ? qi.castAgg(inner, 'float') : inner;
201
215
  selectExprs.push(`${expr} AS ${qi.q(`${aggKey}_${col}`)}`);
202
- aggOrderExprs.set(`${aggKey}:${key}`, expr);
216
+ // Canonical field, matching the result bucket the transform fills;
217
+ // `orderBy` may spell it either way (see {@link lookupGroupKey}).
218
+ aggOrderExprs.set(`${aggKey}:${qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col)}`, expr);
203
219
  continue;
204
220
  }
205
221
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
@@ -354,6 +370,37 @@ export function buildGroupBy(qi, args) {
354
370
  * no `$n` renumbering). An aggregate key that was not requested, or an unknown
355
371
  * by-key, throws {@link ValidationError} E003 listing the valid keys.
356
372
  */
373
+ /**
374
+ * Read a caller-supplied groupBy result key out of a registry keyed by the
375
+ * CANONICAL name (the field for a `by` column / aggregate target, the alias
376
+ * for a JSON group key).
377
+ *
378
+ * `by`, `orderBy` and `having` are three arguments of one call, each free to
379
+ * spell a column either way, so `by`'s choice must not decide what the other
380
+ * two may name. Reconciled here rather than by registering both spellings,
381
+ * which would list every group key twice in the "orderable keys" text: try the
382
+ * key as written (which is what carries a JSON alias, not a column), then its
383
+ * canonical field.
384
+ */
385
+ function lookupGroupKey(qi, registry, key) {
386
+ const direct = registry.get(key);
387
+ if (direct !== undefined)
388
+ return direct;
389
+ const canonical = canonicalFieldName(qi, key);
390
+ return canonical === undefined ? undefined : registry.get(canonical);
391
+ }
392
+ /**
393
+ * The canonical FIELD name for a caller-supplied column name, or `undefined`
394
+ * when it names no column (a JSON group-key alias, a typo). One hop through
395
+ * {@link resolveColumnName} and back via `reverseColumnMap`, so both spellings
396
+ * land on one string.
397
+ */
398
+ function canonicalFieldName(qi, key) {
399
+ const column = resolveColumnName(qi.tableMeta, key);
400
+ if (column === undefined)
401
+ return undefined;
402
+ return qi.tableMeta.reverseColumnMap[column] ?? snakeToCamel(column);
403
+ }
357
404
  export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
358
405
  const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
359
406
  /** Human-readable list of every key this call can order by (for E003). */
@@ -416,7 +463,11 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
416
463
  for (const [field, dirSpec] of Object.entries(value)) {
417
464
  if (dirSpec === undefined)
418
465
  continue;
419
- const expr = aggOrderExprs.get(`${key}:${field}`);
466
+ // `field` is the caller's spelling of the aggregate's target column;
467
+ // the registry is keyed by the canonical one.
468
+ const canonical = canonicalFieldName(qi, field);
469
+ const expr = aggOrderExprs.get(`${key}:${field}`) ??
470
+ (canonical === undefined ? undefined : aggOrderExprs.get(`${key}:${canonical}`));
420
471
  if (!expr) {
421
472
  throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${qi.table}": ` +
422
473
  `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
@@ -427,7 +478,7 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
427
478
  continue;
428
479
  }
429
480
  // Plain by-field name or JSON group-key alias.
430
- const expr = byOrderExprs.get(key);
481
+ const expr = lookupGroupKey(qi, byOrderExprs, key);
431
482
  if (!expr) {
432
483
  throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${qi.table}". ` +
433
484
  `Orderable keys: ${validKeys()}.`);
@@ -691,7 +742,7 @@ function buildHavingCombinator(qi, key, value, params, jsonAggExprs, groupKeys,
691
742
  * its re-emitted extract expression.
692
743
  */
693
744
  function buildHavingScalarClauses(qi, field, value, params, groupKeys) {
694
- const ref = groupKeys?.get(field);
745
+ const ref = groupKeys ? lookupGroupKey(qi, groupKeys, field) : undefined;
695
746
  if (!ref) {
696
747
  const known = groupKeys ? [...groupKeys.keys()] : [];
697
748
  throw new ValidationError(`[turbine] having on "${field}" (table "${qi.table}") filters the grouped value itself, but ` +
@@ -842,25 +893,22 @@ export function buildAggregate(qi, args) {
842
893
  ? whereMod.buildWhere(qi, aggWhere)
843
894
  : { sql: '', params: [] };
844
895
  const meta = qi.schema.tables[qi.table];
845
- if (meta) {
846
- for (const group of [args._sum, args._avg, args._min, args._max]) {
847
- if (group && typeof group === 'object') {
848
- for (const key of Object.keys(group)) {
849
- if (!(key in meta.columnMap)) {
850
- throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
851
- }
852
- }
853
- }
896
+ // Every target is validated up front, including one whose falsy value the
897
+ // builders below skip, and through `qi.toColumn` (the ONE
898
+ // `resolveColumnName` rule). `key in meta.columnMap` knows only the FIELD
899
+ // spelling, so the snake_case COLUMN name that `where` / `select` and even
900
+ // `groupBy`'s own `_min` accepted was rejected here.
901
+ for (const group of [args._sum, args._avg, args._min, args._max]) {
902
+ if (group && typeof group === 'object') {
903
+ for (const key of Object.keys(group))
904
+ qi.toColumn(key);
854
905
  }
855
- if (args._count && typeof args._count === 'object') {
856
- for (const key of Object.keys(args._count)) {
857
- // `_all` is the reserved COUNT(*) selector, not a column.
858
- if (key === '_all')
859
- continue;
860
- if (!(key in meta.columnMap)) {
861
- throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
862
- }
863
- }
906
+ }
907
+ if (args._count && typeof args._count === 'object') {
908
+ for (const key of Object.keys(args._count)) {
909
+ // `_all` is the reserved COUNT(*) selector, not a column.
910
+ if (key !== '_all')
911
+ qi.toColumn(key);
864
912
  }
865
913
  }
866
914
  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