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
package/README.md CHANGED
@@ -43,8 +43,8 @@ Measured **2026-08-15 against turbine-orm 0.71.0**, versus **Prisma 7.9.1** (`@p
43
43
  What that run says:
44
44
 
45
45
  - **Turbine ran at 1.08x hand-written `pg`**, where Drizzle ran at 1.47x and Prisma at 1.81x (geometric mean over the eight scenarios with a raw control). That 1.08x is the conservative reading: the raw L2 control statement still uses the `json_build_object` encoding Turbine has moved off, which is worth 1.83x on its own, so the figure the harness records unadjusted is 1.00x. The adjusted one is published instead.
46
- - Across all ten scenarios Turbine was **2.02x faster than Prisma 7.9** and **1.46x faster than Drizzle 0.45** by geometric mean. Turbine takes nine scenarios, Drizzle one, Prisma none.
47
- - **Drizzle wins streaming, by 22%** (39.49 ms vs 50.39 ms). Turbine's cursor carries about 19% overhead over hand-written keyset pagination on a full-table drain; it buys arbitrary `orderBy`, early break, and nested `with` per batch, but on this shape it is a loss. `findManyStreamBatches`, which yields a batch at a time instead of a row, cuts that to 6% above the control and narrows the loss to 1.16x without closing it.
46
+ - Across all ten scenarios Turbine was **2.02x faster than Prisma 7.9** and **1.46x faster than Drizzle 0.45** by geometric mean. Turbine took nine scenarios, Drizzle one, Prisma none. The one Drizzle took was streaming, and 0.72.0 takes it back (below).
47
+ - **Streaming was Drizzle's one win, by 22%** (39.49 ms vs 50.39 ms) on 0.71.0. **0.72.0 closes it**: the remaining gap was temporal decoding rather than the cursor, and a direct scan for the canonical ISO wire shape (declining anything else back to `pg`'s own parser) takes `findManyStream` from 52.44 ms to **37.06 ms** against Drizzle's 42.09 ms re-measured in the same rotation, and `findManyStreamBatches` to **31.65 ms**. Scope, because it matters: that is a targeted re-measurement of one scenario on a paired one-process harness, not a re-run of the ten above, so the table's stream row still shows 0.71.0 and the absolute values are not comparable. Method and controls in [`benchmarks/RESULTS-0.72.0.md`](benchmarks/RESULTS-0.72.0.md).
48
48
  - **Pipelining is Turbine's clearest win**: one TCP flush for 5 queries runs 2.04x faster than Prisma and 2.07x faster than Drizzle, level with raw `pg`.
49
49
  - Three scenarios (L2, L3, atomic increment) are marked *contested*: the contiguous cross-check harness disagrees with itself across runs of the same configuration there, so neither side claims them. Both nested reads were losses in the previous run and the improvement behind them reproduces on both harnesses; whether it is enough to pass Drizzle is what is unsettled.
50
50
  - The sub-0.15 ms scenarios carry roughly one third uncertainty in their absolute values. Orderings held across runs; margins should not be quoted, and that includes L2's 2.5%.
@@ -800,24 +800,6 @@ export declare class TurbineClient {
800
800
  * (`ALTER ROLE ... SET plan_cache_mode = ...`).
801
801
  */
802
802
  private static withPlanCacheMode;
803
- /**
804
- * `connectionString` with `setting` appended to its existing `options` query
805
- * parameter, or `null` when it carries no `options` (in which case the caller
806
- * should use the `options` pool field, which is not overridden).
807
- *
808
- * Only the query string is rewritten, never the userinfo or host, so a
809
- * percent-encoded password cannot be mangled by a round trip through `URL`.
810
- * The split is on the first `?`, which is also where pg's own parser puts the
811
- * query-string boundary: a connection string with an unencoded `?` inside the
812
- * password is not parseable by pg either, so there is no shape this handles
813
- * differently from the driver.
814
- *
815
- * A twin of this lives in `src/connection-url.ts`, which `turbine doctor`
816
- * uses for `statement_timeout`. Unifying them is the obvious refactor and it
817
- * is deliberately NOT done; the reason (a c8 merge artifact that costs almost
818
- * all of the coverage gate's headroom) is written up over there.
819
- */
820
- private static mergeConnectionStringOptions;
821
803
  /**
822
804
  * Refuse a `utcTimestamps` value that contradicts the one an earlier client
823
805
  * in this process settled the zone-less temporal read parsers (OIDs 1114,
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.TurbineClient = exports.TransactionClient = exports.READ_OPERATIONS = void 0;
30
30
  exports.withRetry = withRetry;
31
31
  const pg_1 = __importDefault(require("pg"));
32
+ const connection_url_js_1 = require("./connection-url.js");
32
33
  const dialect_js_1 = require("./dialect.js");
33
34
  const errors_js_1 = require("./errors.js");
34
35
  const observe_js_1 = require("./observe.js");
@@ -1146,7 +1147,7 @@ class TurbineClient {
1146
1147
  // cannot be a bind parameter.
1147
1148
  const setting = `-c plan_cache_mode=${mode}`;
1148
1149
  const merged = poolConfig.connectionString
1149
- ? TurbineClient.mergeConnectionStringOptions(poolConfig.connectionString, setting)
1150
+ ? (0, connection_url_js_1.mergeConnectionStringOptions)(poolConfig.connectionString, setting)
1150
1151
  : null;
1151
1152
  if (merged)
1152
1153
  return { ...poolConfig, connectionString: merged };
@@ -1156,34 +1157,6 @@ class TurbineClient {
1156
1157
  const existing = poolConfig.options || (typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined);
1157
1158
  return { ...poolConfig, options: existing ? `${existing} ${setting}` : setting };
1158
1159
  }
1159
- /**
1160
- * `connectionString` with `setting` appended to its existing `options` query
1161
- * parameter, or `null` when it carries no `options` (in which case the caller
1162
- * should use the `options` pool field, which is not overridden).
1163
- *
1164
- * Only the query string is rewritten, never the userinfo or host, so a
1165
- * percent-encoded password cannot be mangled by a round trip through `URL`.
1166
- * The split is on the first `?`, which is also where pg's own parser puts the
1167
- * query-string boundary: a connection string with an unencoded `?` inside the
1168
- * password is not parseable by pg either, so there is no shape this handles
1169
- * differently from the driver.
1170
- *
1171
- * A twin of this lives in `src/connection-url.ts`, which `turbine doctor`
1172
- * uses for `statement_timeout`. Unifying them is the obvious refactor and it
1173
- * is deliberately NOT done; the reason (a c8 merge artifact that costs almost
1174
- * all of the coverage gate's headroom) is written up over there.
1175
- */
1176
- static mergeConnectionStringOptions(connectionString, setting) {
1177
- const q = connectionString.indexOf('?');
1178
- if (q === -1)
1179
- return null;
1180
- const params = new URLSearchParams(connectionString.slice(q + 1));
1181
- const existing = params.get('options');
1182
- if (existing === null)
1183
- return null;
1184
- params.set('options', `${existing} ${setting}`);
1185
- return connectionString.slice(0, q + 1) + params.toString();
1186
- }
1187
1160
  /**
1188
1161
  * Refuse a `utcTimestamps` value that contradicts the one an earlier client
1189
1162
  * in this process settled the zone-less temporal read parsers (OIDs 1114,
@@ -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;
@@ -39,6 +39,7 @@ exports.parseConnectionTarget = parseConnectionTarget;
39
39
  exports.detectPooler = detectPooler;
40
40
  exports.poolerRefusalMessage = poolerRefusalMessage;
41
41
  exports.withStatementTimeoutOption = withStatementTimeoutOption;
42
+ exports.mergeConnectionStringOptions = mergeConnectionStringOptions;
42
43
  /**
43
44
  * Host and port from a connection string, in either shape libpq accepts.
44
45
  *
@@ -263,25 +264,22 @@ function withStatementTimeoutOption(config, statementTimeoutMs) {
263
264
  * password is not parseable by pg either, so there is no shape this handles
264
265
  * differently from the driver.
265
266
  *
266
- * DELIBERATELY DUPLICATED with `TurbineClient.mergeConnectionStringOptions`,
267
- * which needs the identical merge for `plan_cache_mode`. Collapsing the two
268
- * onto this copy is the obviously correct refactor and it was tried; it is
269
- * reverted, and the reason is worth writing down because the next person will
270
- * try it too.
267
+ * Shared with `client.ts`, which needs the identical merge for
268
+ * `plan_cache_mode`. It was duplicated there until 2026-08-16, and the reason
269
+ * is worth keeping because it was wrong in an instructive way: importing it
270
+ * from `client.ts` adds an import edge from a module ~100 test processes load,
271
+ * and this file's merged coverage read 40% while measuring 100% in isolation,
272
+ * which moved the aggregate gate 75.48% -> 75.03% against a 75% floor. The
273
+ * conclusion drawn was that the refactor cost the gate's headroom.
271
274
  *
272
- * Exporting it and importing it from `client.ts` adds an import edge from a
273
- * module that ~100 test processes load. In each of those processes this file's
274
- * top level runs and its functions do not, so c8 merges ~100 top-level-only
275
- * entries against the one full entry from `pooler-guard.test.ts`, and the file
276
- * reports 40% in the merged report while measuring 100% in isolation. Real
277
- * coverage is unchanged either way, but the aggregate gate moved 75.48% ->
278
- * 75.03% against a 75% floor, i.e. the refactor spent almost all the headroom
279
- * on a reporting artifact.
275
+ * The 40% was a c8 merge defect, not an import cost: c8's V8-level merge is not
276
+ * monotonic, and once the report merges at the istanbul level instead this file
277
+ * reads 100% with the edge in place (see `//merge-bug` in .c8rc.json). So the
278
+ * duplication was paying for an artifact, and the two copies are now one.
280
279
  *
281
- * So: two copies, both small, both pure, neither reachable from the other. If
282
- * you unify them, re-measure `npm run test:coverage` as a whole and not just
283
- * this file, and raise the floor's headroom first. Keep this module
284
- * import-free regardless.
280
+ * Keep this module import-free regardless. That part was never about coverage:
281
+ * `query/` and `cli/` both reach it, and an edge back to `client.ts` would be
282
+ * the cycle `scripts/check-import-cycles.mjs` exists to refuse.
285
283
  */
286
284
  function mergeConnectionStringOptions(connectionString, setting) {
287
285
  const q = connectionString.indexOf('?');
@@ -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/cjs/powql.js CHANGED
@@ -77,6 +77,7 @@ const powdb_js_1 = require("./powdb.js");
77
77
  const aggregates_js_1 = require("./query/aggregates.js");
78
78
  const compound_unique_js_1 = require("./query/compound-unique.js");
79
79
  const filters_js_1 = require("./query/filters.js");
80
+ const relation_names_js_1 = require("./query/relation-names.js");
80
81
  // The privilege sentinel and its resolver: `includePii` / `allowFullTableScan`
81
82
  // are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
82
83
  // engines, so a spread request body cannot turn either on here either.
@@ -231,9 +232,20 @@ class PowqlInterface {
231
232
  // -------------------------------------------------------------------------
232
233
  // Column / value helpers
233
234
  // -------------------------------------------------------------------------
234
- /** Resolve a camelCase field name (or raw snake) to its column metadata. */
235
+ /**
236
+ * Resolve a camelCase field name (or raw snake) to its column metadata.
237
+ *
238
+ * THE single key→column decision on this engine: every PowQL surface
239
+ * reaches a column through here, which is why PowDB never grew the
240
+ * per-argument asymmetry the SQL builders did. It resolves through the
241
+ * shared {@link resolveColumnName} so both engines answer alike; the direct
242
+ * name/field match stays as a fallback, since a PowDB schema may carry names
243
+ * outside the snake_case↔camelCase round trip and narrowing that would be a
244
+ * silent break rather than a fix.
245
+ */
235
246
  column(field) {
236
- const snake = this.meta.columnMap[field] ?? field;
247
+ const resolved = (0, utils_js_1.resolveColumnName)(this.meta, field);
248
+ const snake = resolved ?? (0, utils_js_1.ownLookup)(this.meta.columnMap, field) ?? field;
237
249
  const col = this.meta.columns.find((c) => c.name === snake || c.field === field);
238
250
  if (!col) {
239
251
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${field}" on table "${this.table}". Known: ${this.meta.columns
@@ -242,6 +254,41 @@ class PowqlInterface {
242
254
  }
243
255
  return col;
244
256
  }
257
+ /**
258
+ * {@link lookupGroupKey} for the `${aggKey}:${field}` composite the aggregate
259
+ * ordering registry is keyed by: only the FIELD half needs canonicalizing.
260
+ */
261
+ aggOrderExpr(registry, aggKey, field) {
262
+ try {
263
+ return registry.get(`${aggKey}:${this.column(field).field}`);
264
+ }
265
+ catch {
266
+ return undefined; // not a column: the caller's own E003 is the right error
267
+ }
268
+ }
269
+ /**
270
+ * Read a caller-supplied groupBy result key out of a registry keyed by the
271
+ * canonical FIELD name.
272
+ *
273
+ * `by`, `orderBy` and `having` are three arguments of one call, each free to
274
+ * spell a column either way, so `by`'s choice must not decide what the others
275
+ * may name. Try the key as written (which is what carries a JSON group-key
276
+ * ALIAS, not a column), then its canonical field. Mirrors `lookupGroupKey` in
277
+ * query/aggregates.ts.
278
+ */
279
+ lookupGroupKey(registry, key) {
280
+ const direct = registry.get(key);
281
+ if (direct !== undefined)
282
+ return direct;
283
+ let field;
284
+ try {
285
+ field = this.column(key).field;
286
+ }
287
+ catch {
288
+ return undefined; // not a column: the caller's own E003 below is the right error
289
+ }
290
+ return registry.get(field);
291
+ }
245
292
  /**
246
293
  * PowQL column reference for a field: a dotted field reference
247
294
  * (`.snake_name`), or `alias.snake_name` when an `alias` is supplied (the F2
@@ -356,6 +403,22 @@ class PowqlInterface {
356
403
  * which is precisely the "reports a guarantee that was never made" failure the
357
404
  * option's refusal exists to prevent.
358
405
  */
406
+ /**
407
+ * `args` with every `with` relation key replaced by the relation's DECLARED
408
+ * spelling, so PowDB accepts a snake_case relation name exactly as the SQL
409
+ * engines do. Returns `args` by reference when nothing needed rewriting.
410
+ *
411
+ * PowqlInterface is a parallel implementation rather than a subclass, so
412
+ * nothing makes this happen here automatically: a rule adopted only on the
413
+ * SQL side becomes an engine that disagrees about which queries are valid,
414
+ * which is the divergence class the projection resolver already cost.
415
+ */
416
+ withDeclaredRelationNames(args) {
417
+ if (!args?.with)
418
+ return args;
419
+ const normalized = (0, relation_names_js_1.normalizeWithClause)(this.schema, this.table, args.with);
420
+ return normalized === args.with ? args : { ...args, with: normalized };
421
+ }
359
422
  assertNoForceCustomPlan(args) {
360
423
  if (args?.forceCustomPlan !== true)
361
424
  return;
@@ -796,8 +859,8 @@ class PowqlInterface {
796
859
  else if (key === 'NOT') {
797
860
  scalar[key] = await this.resolveRelationFilters(value, timeout);
798
861
  }
799
- else if (this.meta.relations[key]) {
800
- relConds.push(await this.resolveRelationCondition(this.meta.relations[key], value, timeout));
862
+ else if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, key)) {
863
+ relConds.push(await this.resolveRelationCondition((0, utils_js_1.resolveRelationDef)(this.meta.relations, key), value, timeout));
801
864
  }
802
865
  else {
803
866
  scalar[key] = value;
@@ -932,7 +995,7 @@ class PowqlInterface {
932
995
  * for a misspelling that is not there. Same message as the SQL engines.
933
996
  */
934
997
  projectionColumn(field, clause) {
935
- if ((0, utils_js_1.ownLookup)(this.meta.relations, field)) {
998
+ if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, field)) {
936
999
  throw new errors_js_1.ValidationError((0, utils_js_1.relationInProjectionMessage)(this.table, field, clause));
937
1000
  }
938
1001
  return this.column(field).name;
@@ -1300,6 +1363,7 @@ class PowqlInterface {
1300
1363
  // -------------------------------------------------------------------------
1301
1364
  async findMany(args = {}) {
1302
1365
  this.assertNoForceCustomPlan(args);
1366
+ args = this.withDeclaredRelationNames(args);
1303
1367
  return this.withMiddleware('findMany', args, async () => {
1304
1368
  // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
1305
1369
  // is correct on every engine version: PowDB's projection fast path returned
@@ -1381,6 +1445,12 @@ class PowqlInterface {
1381
1445
  const nest = nestedPlans.length > 0 || linkPlans.length > 0;
1382
1446
  const alias = nest ? 't0' : undefined;
1383
1447
  const where = this.buildWhere(resolvedWhere, params, alias);
1448
+ // PowQL's `distinct` is row-wide, so these names never reach the emitted
1449
+ // statement. They are still caller-supplied names, and a name resolves or
1450
+ // throws: reading the array for its LENGTH alone let `distinct: ['nope']`
1451
+ // succeed here while every SQL engine refuses it. Validation only.
1452
+ for (const key of args.distinct ?? [])
1453
+ this.column(key);
1384
1454
  const distinct = args.distinct?.length ? ' distinct' : '';
1385
1455
  const filter = where ? ` filter ${where}` : '';
1386
1456
  const order = this.buildOrder(args.orderBy, params, alias);
@@ -1445,6 +1515,7 @@ class PowqlInterface {
1445
1515
  }
1446
1516
  async findUnique(args) {
1447
1517
  this.assertNoForceCustomPlan(args);
1518
+ args = this.withDeclaredRelationNames(args);
1448
1519
  // Prisma compound-unique selector → column conjunction (engine parity with
1449
1520
  // the SQL findUnique family; pure metadata, so this is a one-line adoption).
1450
1521
  if (args.where) {
@@ -1469,6 +1540,7 @@ class PowqlInterface {
1469
1540
  });
1470
1541
  }
1471
1542
  async findFirst(args = {}) {
1543
+ args = this.withDeclaredRelationNames(args);
1472
1544
  this.assertNoForceCustomPlan(args);
1473
1545
  return this.withMiddleware('findFirst', args, async () => {
1474
1546
  const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
@@ -2400,7 +2472,7 @@ class PowqlInterface {
2400
2472
  for (const [field, value] of Object.entries(data)) {
2401
2473
  if (value === undefined)
2402
2474
  continue;
2403
- if (this.meta.relations[field]) {
2475
+ if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, field)) {
2404
2476
  throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update(), not createMany()/upsert()`);
2405
2477
  }
2406
2478
  out.push({ col: this.column(field), value });
@@ -2418,7 +2490,11 @@ class PowqlInterface {
2418
2490
  for (const pk of this.meta.primaryKey) {
2419
2491
  const field = this.meta.reverseColumnMap[pk] ?? pk;
2420
2492
  const col = this.meta.columns.find((c) => c.name === pk);
2493
+ // "Supplied" means under EITHER spelling: `create` resolves both, so
2494
+ // testing the camelCase field alone made `{ user_id: 'x' }` look absent,
2495
+ // generated a UUID under `userId`, and wrote the column twice.
2421
2496
  if (out[field] == null &&
2497
+ (field === pk || out[pk] == null) &&
2422
2498
  col?.hasDefault &&
2423
2499
  !col.isGenerated &&
2424
2500
  col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
@@ -2557,7 +2633,7 @@ class PowqlInterface {
2557
2633
  for (const [field, value] of Object.entries(data)) {
2558
2634
  if (value === undefined)
2559
2635
  continue;
2560
- if (this.meta.relations[field]) {
2636
+ if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, field)) {
2561
2637
  throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update(), not updateMany()/upsert()`);
2562
2638
  }
2563
2639
  const colMeta = this.column(field);
@@ -2720,7 +2796,10 @@ class PowqlInterface {
2720
2796
  // reselect-by-PK fetch; create/update/delete all use `returning`.
2721
2797
  await this.exec(`upsert ${this.qt} on ${this.colRefName(pkCol)} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
2722
2798
  const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
2723
- const row = await this.reselectByPk(createData[pkField], args.timeout);
2799
+ // Either spelling, as `upsertComposite` already does below: reading the
2800
+ // camelCase field alone reselected `undefined` for a snake-spelled PK and
2801
+ // reported a write that had SUCCEEDED as a NotFoundError.
2802
+ const row = await this.reselectByPk(createData[pkField] ?? createData[pkCol], args.timeout);
2724
2803
  if (!row)
2725
2804
  throw new errors_js_1.NotFoundError({ table: this.table, where: createData });
2726
2805
  return row;
@@ -2888,13 +2967,18 @@ class PowqlInterface {
2888
2967
  if (typeof entry === 'string') {
2889
2968
  const col = this.column(entry);
2890
2969
  (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, entry, col.name, 'groupBy `by` key', (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
2891
- claim(entry, `column "${col.name}"`);
2892
- if (col.name !== entry)
2970
+ // Keyed by the canonical FIELD, never by whichever of the column's
2971
+ // two legal spellings the caller wrote (`column` accepts both), so
2972
+ // neither the result shape nor the keys `orderBy` accepts depends on
2973
+ // that choice; `lookupGroupKey` reconciles the other spelling. Same
2974
+ // rule and same result shape as the SQL groupBy (query/aggregates.ts).
2975
+ claim(col.field, `column "${col.name}"`);
2976
+ if (col.name !== col.field)
2893
2977
  claim(col.name, `column "${col.name}"`);
2894
2978
  groupExprs.push(this.colRefName(col.name));
2895
2979
  proj.push(this.colRefName(col.name));
2896
- byOrderExprs.set(entry, this.colRefName(col.name));
2897
- byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
2980
+ byOrderExprs.set(col.field, this.colRefName(col.name));
2981
+ byReaders.push({ kind: 'plain', resultKey: col.field, rowKey: col.name, col });
2898
2982
  }
2899
2983
  else {
2900
2984
  const col = this.column(entry.field);
@@ -2957,9 +3041,11 @@ class PowqlInterface {
2957
3041
  claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
2958
3042
  const inner = this.colRefName(col.name);
2959
3043
  proj.push(`${alias}: ${powfn}(${inner})`);
2960
- aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
2961
- aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
2962
- aggInner.set(key, inner);
3044
+ // Canonical field, so the result bucket and the keys `orderBy` /
3045
+ // `having` accept are the same whichever spelling was requested.
3046
+ aggReaders.push({ alias, outKey: `${fn}:${col.field}`, numeric: true });
3047
+ aggOrderExprs.set(`${fn}:${col.field}`, `.${alias}`);
3048
+ aggInner.set(col.field, inner);
2963
3049
  }
2964
3050
  else {
2965
3051
  const col = this.column(target.field);
@@ -3095,7 +3181,7 @@ class PowqlInterface {
3095
3181
  throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
3096
3182
  `Supported: ${[...POWQL_HAVING_AGG_FNS.keys()].join(', ')}.`);
3097
3183
  }
3098
- const inner = aggInner.get(key) ?? this.ref(key);
3184
+ const inner = this.lookupGroupKey(aggInner, key) ?? this.ref(key);
3099
3185
  conds.push(cmp(`${fn}(${inner})`, filter));
3100
3186
  }
3101
3187
  }
@@ -3143,7 +3229,9 @@ class PowqlInterface {
3143
3229
  for (const [field, dirSpec] of Object.entries(value)) {
3144
3230
  if (dirSpec === undefined)
3145
3231
  continue;
3146
- const expr = aggOrderExprs.get(`${key}:${field}`);
3232
+ // `field` is the caller's spelling of the aggregate's target column;
3233
+ // the registry is keyed by the canonical one.
3234
+ const expr = aggOrderExprs.get(`${key}:${field}`) ?? this.aggOrderExpr(aggOrderExprs, key, field);
3147
3235
  if (!expr) {
3148
3236
  throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
3149
3237
  `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
@@ -3152,7 +3240,7 @@ class PowqlInterface {
3152
3240
  }
3153
3241
  continue;
3154
3242
  }
3155
- const expr = byOrderExprs.get(key);
3243
+ const expr = this.lookupGroupKey(byOrderExprs, key);
3156
3244
  if (!expr) {
3157
3245
  throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
3158
3246
  }
@@ -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: