turbine-orm 0.32.2 → 0.34.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 (43) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/dialect.js +1 -0
  3. package/dist/cjs/index-advisor.js +0 -0
  4. package/dist/cjs/index.js +2 -1
  5. package/dist/cjs/mssql.js +3 -0
  6. package/dist/cjs/mysql.js +3 -0
  7. package/dist/cjs/optional-peer-import.cjs +28 -0
  8. package/dist/cjs/powdb-introspect.js +222 -0
  9. package/dist/cjs/powdb.js +446 -55
  10. package/dist/cjs/powql.js +566 -111
  11. package/dist/cjs/query/builder.js +136 -53
  12. package/dist/cjs/query/filters.js +4 -4
  13. package/dist/cjs/schema-builder.js +16 -0
  14. package/dist/cjs/schema-metadata.js +81 -10
  15. package/dist/cjs/sqlite.js +2 -0
  16. package/dist/dialect.d.ts +7 -0
  17. package/dist/dialect.js +1 -0
  18. package/dist/index-advisor.d.ts +15 -1
  19. package/dist/index-advisor.js +0 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/mssql.js +3 -0
  23. package/dist/mysql.js +3 -0
  24. package/dist/optional-peer-import.cjs +28 -0
  25. package/dist/optional-peer-import.d.cts +19 -0
  26. package/dist/powdb-introspect.d.ts +84 -0
  27. package/dist/powdb-introspect.js +219 -0
  28. package/dist/powdb.d.ts +249 -13
  29. package/dist/powdb.js +438 -54
  30. package/dist/powql.d.ts +113 -6
  31. package/dist/powql.js +568 -113
  32. package/dist/query/builder.d.ts +11 -0
  33. package/dist/query/builder.js +136 -53
  34. package/dist/query/filters.d.ts +3 -3
  35. package/dist/query/filters.js +4 -4
  36. package/dist/query/types.d.ts +50 -6
  37. package/dist/schema-builder.d.ts +46 -1
  38. package/dist/schema-builder.js +15 -0
  39. package/dist/schema-metadata.d.ts +13 -7
  40. package/dist/schema-metadata.js +82 -11
  41. package/dist/schema.d.ts +25 -0
  42. package/dist/sqlite.js +2 -0
  43. package/package.json +3 -3
package/dist/powql.js CHANGED
@@ -37,8 +37,8 @@
37
37
  import { randomUUID } from 'node:crypto';
38
38
  import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
- import { PowdbFloatParam, powqlColumnType, quotePowqlIdent, rowToEntity } from './powdb.js';
41
- import { isRelationPickOrderBy } from './query/filters.js';
40
+ import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
+ import { isJsonFilter, isRelationPickOrderBy } from './query/filters.js';
42
42
  import { escapeLike } from './query/utils.js';
43
43
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
44
44
  /**
@@ -48,6 +48,21 @@ import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
48
48
  * before grouping. Mirrors the chunking the parity matrix documents.
49
49
  */
50
50
  const MAX_RELATION_KEYS = 1000;
51
+ /**
52
+ * Read-shaped actions whose statement may be transparently replayed once on a
53
+ * stale wire frame when `retryStaleReads` is enabled (see
54
+ * {@link PowqlInterface.execOnce}). Writes are deliberately absent: replaying a
55
+ * mutation after an ambiguous reply can double-execute, matching the client's
56
+ * own native-path policy.
57
+ */
58
+ const POWQL_READ_ACTIONS = new Set([
59
+ 'findMany',
60
+ 'findUnique',
61
+ 'findFirst',
62
+ 'count',
63
+ 'aggregate',
64
+ 'groupBy',
65
+ ]);
51
66
  /** Operator keys recognised inside a `WhereOperator` object. */
52
67
  const OPERATOR_KEYS = new Set([
53
68
  'equals',
@@ -93,7 +108,6 @@ export class PowqlInterface {
93
108
  defaultLimit;
94
109
  warnOnUnlimited;
95
110
  onQuery;
96
- currentAction = 'raw';
97
111
  warnedUnlimited = false;
98
112
  constructor(pool, table, schema, middlewares = [], options = {}) {
99
113
  this.pool = pool;
@@ -144,7 +158,17 @@ export class PowqlInterface {
144
158
  * {@link toPowdbParam}, so the wire param is unchanged.
145
159
  */
146
160
  param(value, params, col) {
147
- const tagged = col && typeof value === 'number' && this.isFloatCol(col) ? new PowdbFloatParam(value) : value;
161
+ let tagged = value;
162
+ if (col && typeof value === 'number' && this.isFloatCol(col)) {
163
+ // Float column: force a float-form literal even for an integer value.
164
+ tagged = new PowdbFloatParam(value);
165
+ }
166
+ else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && isJsonColumn(col)) {
167
+ // json document column: a JS object/array is serialized to canonical JSON
168
+ // text and stored as a json document (a JS string passes through raw, same
169
+ // contract as pg jsonb; `null` stays `null`).
170
+ tagged = new PowdbJsonParam(value);
171
+ }
148
172
  params.push(tagged);
149
173
  return `$${params.length}`;
150
174
  }
@@ -172,6 +196,15 @@ export class PowqlInterface {
172
196
  return false;
173
197
  }
174
198
  }
199
+ /**
200
+ * The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
201
+ * default (all feature gates on, `nativeRaw` off) when a directly-constructed
202
+ * pool did not carry them, matching {@link PowdbPool}'s own constructor
203
+ * default so a hand-built test pool never crashes the version gates.
204
+ */
205
+ get capabilities() {
206
+ return this.pool.capabilities ?? ALL_POWDB_CAPABILITIES;
207
+ }
175
208
  /** A predicate that is always false — the empty-`in` / contradiction sentinel. */
176
209
  alwaysFalse() {
177
210
  const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
@@ -213,20 +246,33 @@ export class PowqlInterface {
213
246
  throw new ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
214
247
  }
215
248
  else {
216
- parts.push(this.buildFieldCondition(key, value, params));
249
+ // A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
250
+ // empty results so buildWhere never emits a dangling ` and `.
251
+ const cond = this.buildFieldCondition(key, value, params);
252
+ if (cond)
253
+ parts.push(cond);
217
254
  }
218
255
  }
219
256
  return parts.join(' and ');
220
257
  }
221
258
  /** Build a single `field: value | operator` condition. */
222
259
  buildFieldCondition(field, value, params) {
260
+ const colMeta = this.column(field);
223
261
  const ref = this.ref(field);
224
262
  if (value === null)
225
263
  return `${ref} is null`;
226
264
  if (value instanceof Date || typeof value !== 'object') {
227
- return `${ref} = ${this.param(value, params)}`;
265
+ return `${ref} = ${this.param(value, params, colMeta)}`;
228
266
  }
229
267
  const op = value;
268
+ // JSON path / key filters on a json document column compile to PowQL `->`
269
+ // path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
270
+ // `hasKey`; on a NON-json column those fall through to the scalar operator
271
+ // path below (e.g. `equals` stays a plain equality), exactly like SQL.
272
+ if (isJsonColumn(colMeta) && isJsonFilter(value)) {
273
+ requireCapability(this.capabilities, 'jsonDocs', 'JSON path filters');
274
+ return this.buildJsonPathCondition(colMeta, value, params);
275
+ }
230
276
  rejectUnsupportedFilter(op, field);
231
277
  if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
232
278
  // A bare object that is not an operator set — equality by value.
@@ -278,6 +324,100 @@ export class PowqlInterface {
278
324
  }
279
325
  return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
280
326
  }
327
+ /**
328
+ * PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
329
+ * positional param (a string segment as a `str` token, an integer index as an
330
+ * `int` token). `->` binds tighter than every operator, so no parens are
331
+ * needed around the path in a comparison. Segments are bound (never inlined)
332
+ * to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
333
+ * that literally contained `$1` would otherwise be rewritten. Shared by the
334
+ * F1 where-filter path and the F2 orderBy / groupBy path emitters.
335
+ *
336
+ * A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
337
+ * the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
338
+ * can only be expressed as a digit string, and the SQL builder converts it the
339
+ * same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
340
+ * `->` treats `'0'` as a string KEY and silently matches nothing on an array
341
+ * (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
342
+ * json object whose key is literally `"0"` is addressed as an array index.
343
+ */
344
+ jsonPathExpr(col, path, params) {
345
+ let expr = `.${col.name}`;
346
+ for (const seg of path) {
347
+ const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
348
+ expr += `->${this.param(bound, params)}`;
349
+ }
350
+ return expr;
351
+ }
352
+ /**
353
+ * Compile a {@link JsonFilter} on a json document column into a PowQL filter
354
+ * (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
355
+ * (never a wrong result): containment (`contains`, and `equals` without a
356
+ * `path`) has no PowQL operator. The mapped shapes:
357
+ * - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
358
+ * integral number→int, fractional→float; NOT stringified)
359
+ * - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
360
+ * key, a deliberate divergence from the PG driver, documented on
361
+ * {@link JsonFilter})
362
+ * - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
363
+ * engine coerces int/float numerically)
364
+ * - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
365
+ * ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
366
+ * null)
367
+ * A bare `{ path }` with no operators compiles to zero clauses (byte-parity
368
+ * with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
369
+ * by the empty-where guard.
370
+ */
371
+ buildJsonPathCondition(col, filter, params) {
372
+ const conds = [];
373
+ // Bind the path segments at most once and reuse the expression string across
374
+ // equals + range comparisons (they share the same `path`).
375
+ let pathExpr = null;
376
+ const pathP = () => {
377
+ pathExpr ??= this.jsonPathExpr(col, filter.path, params);
378
+ return pathExpr;
379
+ };
380
+ if (filter.contains !== undefined) {
381
+ throw new UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
382
+ }
383
+ if (filter.equals !== undefined) {
384
+ if (filter.path === undefined || filter.path.length === 0) {
385
+ throw new UnsupportedFeatureError('JSON containment (equals without path)', 'PowDB', `column "${col.name}": pass a \`path\` to compare a specific json value; PowQL has no whole-document containment`);
386
+ }
387
+ conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
388
+ }
389
+ if (filter.hasKey !== undefined) {
390
+ // Top-level key existence, independent of `path` (mirrors PG `col ? key`).
391
+ conds.push(`json_type(.${col.name}->${this.param(filter.hasKey, params)}) is not null`);
392
+ }
393
+ // Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
394
+ // Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
395
+ // number or a string.
396
+ for (const [op, powOp] of [
397
+ ['gt', '>'],
398
+ ['gte', '>='],
399
+ ['lt', '<'],
400
+ ['lte', '<='],
401
+ ]) {
402
+ const v = filter[op];
403
+ if (v === undefined)
404
+ continue;
405
+ if (filter.path === undefined) {
406
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
407
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
408
+ }
409
+ if (typeof v !== 'number' && typeof v !== 'string') {
410
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
411
+ }
412
+ if (typeof v === 'number' && !Number.isFinite(v)) {
413
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
414
+ }
415
+ conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
416
+ }
417
+ if (!conds.length)
418
+ return '';
419
+ return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
420
+ }
281
421
  /** Bind a value, lowercasing for case-insensitive comparisons. */
282
422
  bind(value, params, insensitive) {
283
423
  const ph = this.param(value, params);
@@ -419,7 +559,7 @@ export class PowqlInterface {
419
559
  const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
420
560
  const params = [];
421
561
  const ph = chunk.map((v) => this.param(v, params)).join(', ');
422
- const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
562
+ const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
423
563
  for (const r of rows) {
424
564
  const v = r[sourceJCol];
425
565
  if (v != null)
@@ -467,8 +607,20 @@ export class PowqlInterface {
467
607
  projection(cols) {
468
608
  return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
469
609
  }
470
- /** `order .c1 asc, .c2 desc` clause (empty string when no orderBy). */
471
- buildOrder(orderBy) {
610
+ /**
611
+ * `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
612
+ * besides a plain direction:
613
+ * - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
614
+ * type?, direction? } }` → `order .data->$n asc` (or
615
+ * `cast(.data->$n, "float")` for `type: 'numeric'`);
616
+ * - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
617
+ * no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
618
+ *
619
+ * PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
620
+ * contract): for identical cross-engine results pass `nulls: 'last'`
621
+ * explicitly on Postgres, which defaults nulls-first for `desc`.
622
+ */
623
+ buildOrder(orderBy, params) {
472
624
  if (!orderBy)
473
625
  return '';
474
626
  const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
@@ -476,32 +628,82 @@ export class PowqlInterface {
476
628
  return '';
477
629
  const parts = keys.map(([field, dir]) => {
478
630
  if (dir && typeof dir === 'object') {
631
+ const o = dir;
632
+ // JSON-path ordering on a json column.
633
+ if (Array.isArray(o.path)) {
634
+ return this.buildJsonPathOrder(field, dir, params);
635
+ }
636
+ // OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
637
+ // nulls-first (no placement grammar). Distinct from vector/pick/_count.
638
+ if ('sort' in o && !('distance' in o) && !('_count' in o) && !isRelationPickOrderBy(dir)) {
639
+ const spec = dir;
640
+ if (spec.nulls === 'first') {
641
+ throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
642
+ }
643
+ return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
644
+ }
479
645
  // Name the actual feature in the refusal — a pick-row ordering
480
646
  // reported as "vector / distance ordering" sends users hunting for
481
- // pgvector docs. All object-valued orderings stay E017 on PowDB.
482
- const o = dir;
647
+ // pgvector docs. Everything else stays E017 on PowDB.
483
648
  const feature = isRelationPickOrderBy(dir)
484
649
  ? 'relation pick-row ordering'
485
650
  : 'distance' in o
486
651
  ? 'vector / distance ordering'
487
- : Array.isArray(o.path)
488
- ? 'JSON-path ordering'
489
- : '_count' in o
490
- ? 'relation _count ordering'
491
- : 'sort' in o || 'nulls' in o
492
- ? 'NULLS placement / sort-spec ordering'
493
- : 'object-valued ordering';
652
+ : '_count' in o
653
+ ? 'relation _count ordering'
654
+ : 'nulls' in o
655
+ ? 'NULLS placement / sort-spec ordering'
656
+ : 'object-valued ordering';
494
657
  throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
495
658
  }
496
659
  return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
497
660
  });
498
661
  return ` order ${parts.join(', ')}`;
499
662
  }
663
+ /** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
664
+ buildJsonPathOrder(field, spec, params) {
665
+ const col = this.column(field);
666
+ if (!isJsonColumn(col)) {
667
+ throw new UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
668
+ }
669
+ requireCapability(this.capabilities, 'jsonDocs', 'JSON path ordering');
670
+ if (spec.nulls === 'first') {
671
+ throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
672
+ }
673
+ const pathExpr = this.jsonPathExpr(col, spec.path, params);
674
+ // `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
675
+ // JSON numbers already order numerically without a cast.
676
+ const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
677
+ return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
678
+ }
500
679
  // -------------------------------------------------------------------------
501
680
  // Execution plumbing
502
681
  // -------------------------------------------------------------------------
503
- /** Run PowQL with optional timeout, emitting a query event either way. */
504
- async exec(powql, params, timeout) {
682
+ /**
683
+ * Run PowQL with optional timeout, emitting a query event either way. The
684
+ * `action` is passed PER CALL (never read from shared instance state) so the
685
+ * retry-eligibility and the emitted event action stay correct even when a
686
+ * concurrent operation runs on the same cached interface: a WRITE statement
687
+ * carries a write action and can therefore never be mistaken for a replayable
688
+ * read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
689
+ */
690
+ async exec(powql, params, timeout, action = 'raw') {
691
+ return this.execOnce(powql, params, timeout, action, false);
692
+ }
693
+ /**
694
+ * Execute one statement, with the opt-in single stale-frame READ replay. When
695
+ * `retryStaleReads` is on and a first-statement READ fails with the stale-wire
696
+ * {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
697
+ * unexpected frame" that the client cannot recover), the statement is retried
698
+ * exactly once on a fresh pooled connection (the broken one was destroyed).
699
+ * The replay is refused for writes (an ambiguous mutation reply is unsafe to
700
+ * replay) and inside a transaction (a mid-tx statement cannot move connection),
701
+ * so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
702
+ * `_txScoped` interface, are eligible. `action` is a per-call argument (never
703
+ * `this`-state), so a concurrent op flipping instance fields cannot turn a
704
+ * write into a retryable read.
705
+ */
706
+ async execOnce(powql, params, timeout, action, isRetry) {
505
707
  const start = performance.now();
506
708
  const run = this.pool.query(powql, params);
507
709
  try {
@@ -511,15 +713,37 @@ export class PowqlInterface {
511
713
  new Promise((_, reject) => setTimeout(() => reject(new TimeoutError(timeout)), timeout)),
512
714
  ])
513
715
  : await run;
514
- this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
515
- return result;
716
+ this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
717
+ // The pool tags each result with the wire that actually served it
718
+ // (adaptResult → false, adaptNativeResult → true). A heterogeneous
719
+ // injected pool can fall back to the legacy wire per call, so read the
720
+ // per-result flag and only fall back to the pool-level capability when a
721
+ // hand-built pool (tests) omits the tag; never coerce legacy rows with
722
+ // the native policy just because the pool reports nativeRaw.
723
+ const native = result.native ?? Boolean(this.capabilities.nativeRaw);
724
+ return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
516
725
  }
517
726
  catch (err) {
518
- this.emit(powql, params, performance.now() - start, 0, err);
727
+ if (!isRetry && this.shouldRetryStaleRead(err, action)) {
728
+ // Transparent single replay on a fresh connection; the first (swallowed)
729
+ // failure is not emitted, only the retried outcome is observed.
730
+ return this.execOnce(powql, params, timeout, action, true);
731
+ }
732
+ this.emit(action, powql, params, performance.now() - start, 0, err);
519
733
  throw err;
520
734
  }
521
735
  }
522
- emit(sql, params, duration, rows, error) {
736
+ /** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
737
+ shouldRetryStaleRead(err, action) {
738
+ if (!this.pool.retryStaleReads)
739
+ return false;
740
+ if (this.isTxScoped())
741
+ return false;
742
+ if (!POWQL_READ_ACTIONS.has(action))
743
+ return false;
744
+ return isStaleFramePowdbError(err);
745
+ }
746
+ emit(action, sql, params, duration, rows, error) {
523
747
  if (!this.onQuery)
524
748
  return;
525
749
  try {
@@ -528,7 +752,7 @@ export class PowqlInterface {
528
752
  params,
529
753
  duration,
530
754
  model: this.table,
531
- action: this.currentAction,
755
+ action,
532
756
  rows,
533
757
  timestamp: new Date(),
534
758
  error,
@@ -540,7 +764,6 @@ export class PowqlInterface {
540
764
  }
541
765
  /** Run a method body through the middleware chain (mirrors QueryInterface). */
542
766
  async withMiddleware(action, args, executor) {
543
- this.currentAction = action;
544
767
  if (this.middlewares.length === 0)
545
768
  return executor();
546
769
  let index = 0;
@@ -551,24 +774,30 @@ export class PowqlInterface {
551
774
  };
552
775
  return next({ model: this.table, action, args: { ...args } });
553
776
  }
554
- /** Map raw rows to typed entities. */
555
- shape(rows) {
556
- return rows.map((r) => rowToEntity(r, this.meta));
777
+ /** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
778
+ * this result (threaded from {@link execOnce}, not the pool-level capability),
779
+ * so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
780
+ * string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
781
+ * to null) while a per-call legacy fallback on a native-capable pool still
782
+ * coerces its string cells correctly. Defaults to the pool capability for the
783
+ * rare caller with no per-result flag (hand-built test pools). */
784
+ shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
785
+ return rows.map((r) => rowToEntity(r, this.meta, native));
557
786
  }
558
787
  // -------------------------------------------------------------------------
559
788
  // Reads
560
789
  // -------------------------------------------------------------------------
561
790
  async findMany(args = {}) {
562
791
  return this.withMiddleware('findMany', args, async () => {
563
- const rows = await this.runFind(args);
564
- const entities = this.shape(rows);
792
+ const { rows, native } = await this.runFind(args, 'findMany');
793
+ const entities = this.shape(rows, native);
565
794
  if (args.with)
566
795
  await this.loadRelations(entities, args.with, args.timeout);
567
796
  return entities;
568
797
  });
569
798
  }
570
- /** Build + run the flat findMany select; returns raw rows. */
571
- async runFind(args) {
799
+ /** Build + run the flat findMany select; returns raw rows + the serving wire. */
800
+ async runFind(args, action = 'findMany') {
572
801
  if (args.cursor) {
573
802
  throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
574
803
  }
@@ -578,7 +807,7 @@ export class PowqlInterface {
578
807
  const cols = this.projectedColumns(args.select, args.omit);
579
808
  const distinct = args.distinct?.length ? ' distinct' : '';
580
809
  const filter = where ? ` filter ${where}` : '';
581
- const order = this.buildOrder(args.orderBy);
810
+ const order = this.buildOrder(args.orderBy, params);
582
811
  const limit = args.limit ?? args.take ?? this.defaultLimit;
583
812
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
584
813
  this.warnedUnlimited = true;
@@ -587,15 +816,15 @@ export class PowqlInterface {
587
816
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
588
817
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
589
818
  const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
590
- const { rows } = await this.exec(powql, params, args.timeout);
591
- return rows;
819
+ const { rows, native } = await this.exec(powql, params, args.timeout, action);
820
+ return { rows, native };
592
821
  }
593
822
  async findUnique(args) {
594
823
  return this.withMiddleware('findUnique', args, async () => {
595
- const rows = await this.runFind({ ...args, limit: 1 });
824
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
596
825
  if (!rows.length)
597
826
  return null;
598
- const entities = this.shape(rows);
827
+ const entities = this.shape(rows, native);
599
828
  if (args.with)
600
829
  await this.loadRelations(entities, args.with, args.timeout);
601
830
  return entities[0];
@@ -603,10 +832,10 @@ export class PowqlInterface {
603
832
  }
604
833
  async findFirst(args = {}) {
605
834
  return this.withMiddleware('findFirst', args, async () => {
606
- const rows = await this.runFind({ ...args, limit: 1 });
835
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
607
836
  if (!rows.length)
608
837
  return null;
609
- const entities = this.shape(rows);
838
+ const entities = this.shape(rows, native);
610
839
  if (args.with)
611
840
  await this.loadRelations(entities, args.with, args.timeout);
612
841
  return entities[0];
@@ -736,7 +965,7 @@ export class PowqlInterface {
736
965
  const params = [];
737
966
  const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
738
967
  const powql = `${quotePowqlIdent(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
739
- const { rows } = await this.exec(powql, params, timeout);
968
+ const { rows } = await this.exec(powql, params, timeout, 'findMany');
740
969
  for (const row of rows) {
741
970
  const sv = String(row[sourceJCol]);
742
971
  const tv = String(row[targetJCol]);
@@ -837,8 +1066,8 @@ export class PowqlInterface {
837
1066
  .map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
838
1067
  .join(', ');
839
1068
  // `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
840
- const { rows } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout);
841
- const row = rows.length ? this.shape(rows)[0] : null;
1069
+ const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
1070
+ const row = rows.length ? this.shape(rows, native)[0] : null;
842
1071
  if (!row)
843
1072
  throw new NotFoundError({ table: this.table, where: data });
844
1073
  return row;
@@ -855,8 +1084,8 @@ export class PowqlInterface {
855
1084
  return `{ ${assigns.map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
856
1085
  });
857
1086
  // Multi-row insert with `returning` hands back every inserted row in one round-trip.
858
- const { rows } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout);
859
- return this.shape(rows);
1087
+ const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
1088
+ return this.shape(rows, native);
860
1089
  });
861
1090
  }
862
1091
  async update(args) {
@@ -870,8 +1099,8 @@ export class PowqlInterface {
870
1099
  this.assertCompiledWhere(where, false, 'update');
871
1100
  const setClause = this.buildUpdateAssignments(args.data, params);
872
1101
  // `returning` hands back the post-update row(s); take the first (single-row contract).
873
- const { rows } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout);
874
- const row = rows.length ? this.shape(rows)[0] : null;
1102
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
1103
+ const row = rows.length ? this.shape(rows, native)[0] : null;
875
1104
  if (!row)
876
1105
  throw new NotFoundError({ table: this.table, where: args.where });
877
1106
  return row;
@@ -885,7 +1114,7 @@ export class PowqlInterface {
885
1114
  this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
886
1115
  const setClause = this.buildUpdateAssignments(args.data, params);
887
1116
  const filter = where ? ` filter ${where}` : '';
888
- const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
1117
+ const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
889
1118
  return { count: rowCount };
890
1119
  });
891
1120
  }
@@ -1008,8 +1237,8 @@ export class PowqlInterface {
1008
1237
  const where = this.buildWhere(resolvedWhere, params);
1009
1238
  this.assertCompiledWhere(where, false, 'delete');
1010
1239
  // `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
1011
- const { rows } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout);
1012
- const row = rows.length ? this.shape(rows)[0] : null;
1240
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
1241
+ const row = rows.length ? this.shape(rows, native)[0] : null;
1013
1242
  if (!row)
1014
1243
  throw new NotFoundError({ table: this.table, where: args.where });
1015
1244
  return row;
@@ -1022,7 +1251,7 @@ export class PowqlInterface {
1022
1251
  const where = this.buildWhere(resolvedWhere, params);
1023
1252
  this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
1024
1253
  const filter = where ? ` filter ${where}` : '';
1025
- const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
1254
+ const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
1026
1255
  return { count: rowCount };
1027
1256
  });
1028
1257
  }
@@ -1044,7 +1273,7 @@ export class PowqlInterface {
1044
1273
  // (verified: "unexpected trailing token … 'returning'"), because it is one
1045
1274
  // atomic insert-or-update, not two branches. So upsert alone keeps the
1046
1275
  // reselect-by-PK fetch; create/update/delete all use `returning`.
1047
- await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
1276
+ await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
1048
1277
  const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
1049
1278
  const row = await this.reselectByPk(createData[pkField], args.timeout);
1050
1279
  if (!row)
@@ -1089,7 +1318,7 @@ export class PowqlInterface {
1089
1318
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1090
1319
  const where = this.buildWhere(resolvedWhere, params);
1091
1320
  const filter = where ? ` filter ${where}` : '';
1092
- const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
1321
+ const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
1093
1322
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
1094
1323
  });
1095
1324
  }
@@ -1103,7 +1332,7 @@ export class PowqlInterface {
1103
1332
  const filter = where ? ` filter ${where}` : '';
1104
1333
  const scalar = async (expr) => {
1105
1334
  const params = [...filterParams];
1106
- const { rows } = await this.exec(expr, params, args.timeout);
1335
+ const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
1107
1336
  const v = rows[0]?.value;
1108
1337
  return v == null || v === 'null' ? null : Number(v);
1109
1338
  };
@@ -1135,90 +1364,196 @@ export class PowqlInterface {
1135
1364
  }
1136
1365
  async groupBy(args) {
1137
1366
  return this.withMiddleware('groupBy', args, async () => {
1138
- // The SQL-only groupBy extensions (DISTINCT ON row source, JSON-path
1139
- // group keys / aggregate targets) have no PowQL equivalent: refuse
1140
- // clearly instead of emitting broken PowQL.
1367
+ // DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
1141
1368
  if (args.distinctOn) {
1142
1369
  throw new UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
1143
1370
  }
1144
- for (const entry of args.by) {
1145
- if (typeof entry !== 'string') {
1146
- throw new UnsupportedFeatureError('JSON-path groupBy keys', 'PowDB');
1147
- }
1148
- }
1149
- for (const fn of ['_sum', '_avg', '_min', '_max']) {
1150
- const spec = args[fn];
1151
- if (!spec)
1152
- continue;
1153
- for (const value of Object.values(spec)) {
1154
- if (value !== undefined && typeof value !== 'boolean') {
1155
- throw new UnsupportedFeatureError(`JSON-path ${fn} aggregate targets`, 'PowDB');
1156
- }
1157
- }
1371
+ // JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
1372
+ const usesJson = args.by.some((e) => typeof e !== 'string') ||
1373
+ ['_sum', '_avg', '_min', '_max'].some((fn) => {
1374
+ const spec = args[fn];
1375
+ return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
1376
+ });
1377
+ if (usesJson) {
1378
+ requireCapability(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
1158
1379
  }
1380
+ // `emitNative` decides whether the query GENERATION needs the legacy-wire
1381
+ // `json_type` discriminator (pool-level capability). The DECODE side reads
1382
+ // the wire that ACTUALLY served the result (`resultNative`, from exec), so
1383
+ // a per-call legacy fallback on a native-capable pool still decodes right.
1384
+ const emitNative = Boolean(this.capabilities.nativeRaw);
1159
1385
  const params = [];
1160
1386
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1161
1387
  const where = this.buildWhere(resolvedWhere, params);
1162
1388
  const filter = where ? ` filter ${where}` : '';
1163
- const groupKeys = args.by.map((f) => this.ref(f));
1164
- // Safe aliases PowQL rejects reserved-word aliases (e.g. `count:`).
1165
- const aliasMap = [];
1166
- let n = 0;
1167
- const proj = args.by.map((f) => this.ref(f));
1168
- if (args._count) {
1169
- aliasMap.push({ alias: `agg_${n++}`, fn: 'count', field: null, outKey: '_count' });
1389
+ // Result-key namespace, mirroring the SQL builder's `claimResultKey`
1390
+ // (query/builder.ts): a group-key / aggregate output-name collision (with
1391
+ // `_count`, another key, or an aggregate output) throws E003.
1392
+ const usedKeys = new Set();
1393
+ const claim = (key, what) => {
1394
+ if (key === '_count' || usedKeys.has(key)) {
1395
+ throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1396
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1397
+ }
1398
+ usedKeys.add(key);
1399
+ };
1400
+ const groupExprs = [];
1401
+ const proj = [];
1402
+ const byOrderExprs = new Map();
1403
+ const byReaders = [];
1404
+ let gkN = 0;
1405
+ let gtN = 0;
1406
+ for (const entry of args.by) {
1407
+ if (typeof entry === 'string') {
1408
+ const col = this.column(entry);
1409
+ claim(entry, `column "${col.name}"`);
1410
+ if (col.name !== entry)
1411
+ claim(col.name, `column "${col.name}"`);
1412
+ groupExprs.push(`.${col.name}`);
1413
+ proj.push(`.${col.name}`);
1414
+ byOrderExprs.set(entry, `.${col.name}`);
1415
+ byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
1416
+ }
1417
+ else {
1418
+ const col = this.column(entry.field);
1419
+ if (!isJsonColumn(col)) {
1420
+ throw new ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
1421
+ }
1422
+ this.assertJsonPath('group key', entry.field, entry.path);
1423
+ const pathExpr = this.jsonPathExpr(col, entry.path, params);
1424
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1425
+ claim(alias, `JSON path on "${entry.field}"`);
1426
+ const gkAlias = `gk_${gkN++}`;
1427
+ groupExprs.push(pathExpr);
1428
+ proj.push(`${gkAlias}: ${pathExpr}`);
1429
+ byOrderExprs.set(alias, `.${gkAlias}`);
1430
+ let discrim;
1431
+ if (!emitNative) {
1432
+ // Legacy wire renders a missing value, JSON null, AND the string
1433
+ // "null" all as the cell "null". `min(json_type(path))` over the
1434
+ // group is "string" ONLY for the string-"null" group and "null"
1435
+ // otherwise (a bare `json_type` projection is not group-correlated).
1436
+ discrim = `gt_${gtN++}`;
1437
+ proj.push(`${discrim}: min(json_type(${pathExpr}))`);
1438
+ }
1439
+ byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
1440
+ }
1441
+ }
1442
+ // Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
1443
+ // aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
1444
+ // expression by user key; `aggOrderExprs` lets orderBy reference the alias.
1445
+ const aggReaders = [];
1446
+ const aggOrderExprs = new Map();
1447
+ const aggInner = new Map();
1448
+ let aggN = 0;
1449
+ // Parity with the SQL builder (query/builder.ts): `_count` is selected by
1450
+ // DEFAULT unless the caller explicitly opts out with `_count: false`, so
1451
+ // every groupBy row carries `_count` and `orderBy: { _count }` works
1452
+ // without requesting it (the alias is seeded into `aggOrderExprs`).
1453
+ const countSelected = args._count === true || args._count === undefined;
1454
+ if (countSelected) {
1455
+ const alias = `agg_${aggN++}`;
1456
+ proj.push(`${alias}: count(*)`);
1457
+ aggReaders.push({ alias, outKey: '_count', numeric: true });
1458
+ aggOrderExprs.set('_count', `.${alias}`);
1170
1459
  }
1171
1460
  for (const fn of ['_sum', '_avg', '_min', '_max']) {
1172
1461
  const spec = args[fn];
1173
1462
  if (!spec)
1174
1463
  continue;
1175
- for (const field of Object.keys(spec).filter((f) => spec[f])) {
1176
- aliasMap.push({ alias: `agg_${n++}`, fn: fn.slice(1), field, outKey: `${fn}:${field}` });
1177
- }
1178
- }
1179
- for (const a of aliasMap) {
1180
- proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
1181
- }
1182
- const having = this.buildHaving(args.having, params);
1183
- // groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
1184
- // equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
1185
- // a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
1186
- // Refuse those keys explicitly; plain by-field ordering still flows through.
1187
- if (args.orderBy) {
1188
- for (const key of Object.keys(args.orderBy)) {
1189
- if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
1190
- throw new UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
1464
+ const powfn = fn.slice(1); // sum/avg/min/max
1465
+ for (const [key, target] of Object.entries(spec)) {
1466
+ if (!target)
1467
+ continue;
1468
+ const alias = `agg_${aggN++}`;
1469
+ if (target === true) {
1470
+ const col = this.column(key);
1471
+ claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
1472
+ const inner = `.${col.name}`;
1473
+ proj.push(`${alias}: ${powfn}(${inner})`);
1474
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
1475
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1476
+ aggInner.set(key, inner);
1477
+ }
1478
+ else {
1479
+ const col = this.column(target.field);
1480
+ if (!isJsonColumn(col)) {
1481
+ throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
1482
+ }
1483
+ this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
1484
+ const alwaysNumeric = fn === '_sum' || fn === '_avg';
1485
+ if (alwaysNumeric && target.type === 'text') {
1486
+ throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
1487
+ `${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1488
+ }
1489
+ const numeric = alwaysNumeric || target.type === 'numeric';
1490
+ claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
1491
+ const pathExpr = this.jsonPathExpr(col, target.path, params);
1492
+ const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
1493
+ proj.push(`${alias}: ${powfn}(${inner})`);
1494
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
1495
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1496
+ aggInner.set(key, inner);
1191
1497
  }
1192
1498
  }
1193
1499
  }
1194
- const order = this.buildOrder(args.orderBy);
1195
- const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1196
- const { rows } = await this.exec(powql, params, args.timeout);
1197
- // Reshape: group keys camel fields + coerced; aggregates nested {_sum:{field}}.
1500
+ const having = this.buildHaving(args.having, params, aggInner);
1501
+ const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
1502
+ const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1503
+ const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
1504
+ // Reshape: group keys → user fields (coerced / null-disambiguated),
1505
+ // aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
1506
+ // Group-key cells go through the SAME coercion policy `rowToEntity` uses,
1507
+ // by the wire that actually served this result, so a native-wire int cell
1508
+ // (bigint) or datetime cell (micros) never leaks into the result: it
1509
+ // becomes the same number / Date / PG-text-parity string an embedded /
1510
+ // legacy / SQL groupBy returns for the identical query.
1198
1511
  return rows.map((raw) => {
1199
1512
  const out = {};
1200
- for (const f of args.by) {
1201
- const col = this.column(f);
1202
- out[f] =
1203
- typeof raw[col.name] === 'string' ? coerceScalar(raw[col.name], col.tsType) : raw[col.name];
1513
+ for (const r of byReaders) {
1514
+ if (r.kind === 'plain') {
1515
+ const cell = raw[r.rowKey];
1516
+ out[r.resultKey] = resultNative
1517
+ ? coerceNativeValue(cell, r.col)
1518
+ : typeof cell === 'string'
1519
+ ? coerceScalar(cell, r.col.tsType)
1520
+ : cell;
1521
+ }
1522
+ else {
1523
+ out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
1524
+ }
1204
1525
  }
1205
- for (const a of aliasMap) {
1206
- const val = raw[a.alias];
1207
- const num = val == null || val === 'null' ? null : Number(val);
1526
+ for (const a of aggReaders) {
1527
+ const cell = raw[a.alias];
1528
+ const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
1208
1529
  if (a.outKey === '_count')
1209
- out._count = num ?? 0;
1530
+ out._count = v ?? 0;
1210
1531
  else {
1211
1532
  const [bucket, field] = a.outKey.split(':');
1212
1533
  out[bucket] ??= {};
1213
- out[bucket][field] = num;
1534
+ out[bucket][field] = v;
1214
1535
  }
1215
1536
  }
1216
1537
  return out;
1217
1538
  });
1218
1539
  });
1219
1540
  }
1220
- /** `having <expr>` over group aggregates (count/sum/avg/min/max). */
1221
- buildHaving(having, params) {
1541
+ /** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
1542
+ assertJsonPath(context, field, path) {
1543
+ if (!Array.isArray(path) ||
1544
+ path.length === 0 ||
1545
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
1546
+ throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
1547
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
1548
+ }
1549
+ }
1550
+ /**
1551
+ * `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
1552
+ * with the projection); a per-field aggregate re-emits its inner expression
1553
+ * (from `aggInner` when the field is a requested aggregate, so a JSON-path
1554
+ * aggregate reuses its bound placeholders, else `.field` for a plain column).
1555
+ */
1556
+ buildHaving(having, params, aggInner) {
1222
1557
  if (!having)
1223
1558
  return '';
1224
1559
  const conds = [];
@@ -1236,18 +1571,88 @@ export class PowqlInterface {
1236
1571
  if (spec == null)
1237
1572
  continue;
1238
1573
  if (key === '_count') {
1239
- conds.push(cmp(`count(.${this.meta.primaryKey[0]})`, spec));
1574
+ conds.push(cmp('count(*)', spec));
1240
1575
  }
1241
1576
  else {
1242
1577
  for (const [fn, filter] of Object.entries(spec)) {
1243
1578
  if (filter == null)
1244
1579
  continue;
1245
- conds.push(cmp(`${fn.slice(1)}(${this.ref(key)})`, filter));
1580
+ const inner = aggInner.get(key) ?? this.ref(key);
1581
+ conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
1246
1582
  }
1247
1583
  }
1248
1584
  }
1249
1585
  return conds.length ? ` having ${conds.join(' and ')}` : '';
1250
1586
  }
1587
+ /**
1588
+ * Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
1589
+ * columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
1590
+ * cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
1591
+ * order by a projection alias on a grouped query (probed), so each key maps to
1592
+ * its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
1593
+ * surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
1594
+ * requested in this call, or an unknown by-key, throws E003 listing the valid
1595
+ * keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
1596
+ */
1597
+ buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
1598
+ if (!orderBy)
1599
+ return '';
1600
+ const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
1601
+ const validKeys = () => {
1602
+ const keys = [...byOrderExprs.keys()];
1603
+ for (const k of aggOrderExprs.keys())
1604
+ keys.push(k.includes(':') ? k.replace(':', '.') : k);
1605
+ return keys.join(', ') || '(none)';
1606
+ };
1607
+ const parts = [];
1608
+ for (const [key, value] of Object.entries(orderBy)) {
1609
+ if (value === undefined)
1610
+ continue;
1611
+ if (aggBlocks.has(key)) {
1612
+ if (key === '_count') {
1613
+ const expr = aggOrderExprs.get('_count');
1614
+ if (!expr) {
1615
+ throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
1616
+ `Orderable keys: ${validKeys()}.`);
1617
+ }
1618
+ parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
1619
+ continue;
1620
+ }
1621
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1622
+ throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
1623
+ `expected a field map like { ${key}: { amount: 'desc' } }.`);
1624
+ }
1625
+ for (const [field, dirSpec] of Object.entries(value)) {
1626
+ if (dirSpec === undefined)
1627
+ continue;
1628
+ const expr = aggOrderExprs.get(`${key}:${field}`);
1629
+ if (!expr) {
1630
+ throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
1631
+ `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
1632
+ }
1633
+ parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
1634
+ }
1635
+ continue;
1636
+ }
1637
+ const expr = byOrderExprs.get(key);
1638
+ if (!expr) {
1639
+ throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
1640
+ }
1641
+ parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
1642
+ }
1643
+ return parts.length ? ` order ${parts.join(', ')}` : '';
1644
+ }
1645
+ /** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
1646
+ groupOrderDir(value, keyForMsg) {
1647
+ if (value !== null && typeof value === 'object') {
1648
+ const spec = value;
1649
+ if (spec.nulls === 'first') {
1650
+ throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
1651
+ }
1652
+ return spec.sort === 'desc' ? 'desc' : 'asc';
1653
+ }
1654
+ return value === 'desc' ? 'desc' : 'asc';
1655
+ }
1251
1656
  // -------------------------------------------------------------------------
1252
1657
  // Streaming / unsupported
1253
1658
  // -------------------------------------------------------------------------
@@ -1261,12 +1666,12 @@ export class PowqlInterface {
1261
1666
  /** Reselect a single row by its single-column primary key value. */
1262
1667
  async reselectByPk(pkValue, timeout) {
1263
1668
  const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
1264
- const rows = await this.runFind({
1669
+ const { rows, native } = await this.runFind({
1265
1670
  where: { [pkField]: pkValue },
1266
1671
  limit: 1,
1267
1672
  timeout,
1268
1673
  });
1269
- return rows.length ? this.shape(rows)[0] : null;
1674
+ return rows.length ? this.shape(rows, native)[0] : null;
1270
1675
  }
1271
1676
  /**
1272
1677
  * Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
@@ -1301,3 +1706,53 @@ function coerceScalar(raw, tsType) {
1301
1706
  return new Date(Number(raw) / 1000);
1302
1707
  return raw;
1303
1708
  }
1709
+ /**
1710
+ * Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
1711
+ * normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
1712
+ *
1713
+ * On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
1714
+ * `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
1715
+ * would diverge from every other transport: the embedded / legacy / SQL wire
1716
+ * all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
1717
+ * on `JSON.stringify`. So a native scalar cell is rendered to its text form
1718
+ * ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
1719
+ * `"null"` stays the string (the wart the native wire was adopted to fix).
1720
+ *
1721
+ * On the legacy string wire a missing value, JSON null, AND the string `"null"`
1722
+ * all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
1723
+ * `"string"` ONLY for the string-`"null"` group, so the cell is the string
1724
+ * `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
1725
+ * pass through as the extracted string.
1726
+ */
1727
+ function decodeGroupKeyCell(cell, discrim, native) {
1728
+ if (native)
1729
+ return nativeJsonKeyText(cell);
1730
+ if (cell == null)
1731
+ return null;
1732
+ if (cell === 'null')
1733
+ return discrim === 'string' ? 'null' : null;
1734
+ return cell;
1735
+ }
1736
+ /**
1737
+ * Render a native-wire JSON group-key cell to the extracted-text shape the other
1738
+ * transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
1739
+ * `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
1740
+ * string form; an object/array json sub-document is stringified (best-effort
1741
+ * parity: canonical byte-for-byte matching is not guaranteed for nested docs).
1742
+ */
1743
+ function nativeJsonKeyText(cell) {
1744
+ if (cell === null || cell === undefined)
1745
+ return null;
1746
+ if (typeof cell === 'bigint')
1747
+ return cell.toString();
1748
+ if (typeof cell === 'number' || typeof cell === 'boolean')
1749
+ return String(cell);
1750
+ if (typeof cell === 'string')
1751
+ return cell;
1752
+ try {
1753
+ return JSON.stringify(cell);
1754
+ }
1755
+ catch {
1756
+ return String(cell);
1757
+ }
1758
+ }