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/cjs/powql.js CHANGED
@@ -84,6 +84,21 @@ const schema_js_1 = require("./schema.js");
84
84
  * before grouping. Mirrors the chunking the parity matrix documents.
85
85
  */
86
86
  const MAX_RELATION_KEYS = 1000;
87
+ /**
88
+ * Read-shaped actions whose statement may be transparently replayed once on a
89
+ * stale wire frame when `retryStaleReads` is enabled (see
90
+ * {@link PowqlInterface.execOnce}). Writes are deliberately absent: replaying a
91
+ * mutation after an ambiguous reply can double-execute, matching the client's
92
+ * own native-path policy.
93
+ */
94
+ const POWQL_READ_ACTIONS = new Set([
95
+ 'findMany',
96
+ 'findUnique',
97
+ 'findFirst',
98
+ 'count',
99
+ 'aggregate',
100
+ 'groupBy',
101
+ ]);
87
102
  /** Operator keys recognised inside a `WhereOperator` object. */
88
103
  const OPERATOR_KEYS = new Set([
89
104
  'equals',
@@ -129,7 +144,6 @@ class PowqlInterface {
129
144
  defaultLimit;
130
145
  warnOnUnlimited;
131
146
  onQuery;
132
- currentAction = 'raw';
133
147
  warnedUnlimited = false;
134
148
  constructor(pool, table, schema, middlewares = [], options = {}) {
135
149
  this.pool = pool;
@@ -180,7 +194,17 @@ class PowqlInterface {
180
194
  * {@link toPowdbParam}, so the wire param is unchanged.
181
195
  */
182
196
  param(value, params, col) {
183
- const tagged = col && typeof value === 'number' && this.isFloatCol(col) ? new powdb_js_1.PowdbFloatParam(value) : value;
197
+ let tagged = value;
198
+ if (col && typeof value === 'number' && this.isFloatCol(col)) {
199
+ // Float column: force a float-form literal even for an integer value.
200
+ tagged = new powdb_js_1.PowdbFloatParam(value);
201
+ }
202
+ else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && (0, powdb_js_1.isJsonColumn)(col)) {
203
+ // json document column: a JS object/array is serialized to canonical JSON
204
+ // text and stored as a json document (a JS string passes through raw, same
205
+ // contract as pg jsonb; `null` stays `null`).
206
+ tagged = new powdb_js_1.PowdbJsonParam(value);
207
+ }
184
208
  params.push(tagged);
185
209
  return `$${params.length}`;
186
210
  }
@@ -208,6 +232,15 @@ class PowqlInterface {
208
232
  return false;
209
233
  }
210
234
  }
235
+ /**
236
+ * The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
237
+ * default (all feature gates on, `nativeRaw` off) when a directly-constructed
238
+ * pool did not carry them, matching {@link PowdbPool}'s own constructor
239
+ * default so a hand-built test pool never crashes the version gates.
240
+ */
241
+ get capabilities() {
242
+ return this.pool.capabilities ?? powdb_js_1.ALL_POWDB_CAPABILITIES;
243
+ }
211
244
  /** A predicate that is always false — the empty-`in` / contradiction sentinel. */
212
245
  alwaysFalse() {
213
246
  const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
@@ -249,20 +282,33 @@ class PowqlInterface {
249
282
  throw new errors_js_1.ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
250
283
  }
251
284
  else {
252
- parts.push(this.buildFieldCondition(key, value, params));
285
+ // A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
286
+ // empty results so buildWhere never emits a dangling ` and `.
287
+ const cond = this.buildFieldCondition(key, value, params);
288
+ if (cond)
289
+ parts.push(cond);
253
290
  }
254
291
  }
255
292
  return parts.join(' and ');
256
293
  }
257
294
  /** Build a single `field: value | operator` condition. */
258
295
  buildFieldCondition(field, value, params) {
296
+ const colMeta = this.column(field);
259
297
  const ref = this.ref(field);
260
298
  if (value === null)
261
299
  return `${ref} is null`;
262
300
  if (value instanceof Date || typeof value !== 'object') {
263
- return `${ref} = ${this.param(value, params)}`;
301
+ return `${ref} = ${this.param(value, params, colMeta)}`;
264
302
  }
265
303
  const op = value;
304
+ // JSON path / key filters on a json document column compile to PowQL `->`
305
+ // path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
306
+ // `hasKey`; on a NON-json column those fall through to the scalar operator
307
+ // path below (e.g. `equals` stays a plain equality), exactly like SQL.
308
+ if ((0, powdb_js_1.isJsonColumn)(colMeta) && (0, filters_js_1.isJsonFilter)(value)) {
309
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path filters');
310
+ return this.buildJsonPathCondition(colMeta, value, params);
311
+ }
266
312
  rejectUnsupportedFilter(op, field);
267
313
  if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
268
314
  // A bare object that is not an operator set — equality by value.
@@ -314,6 +360,100 @@ class PowqlInterface {
314
360
  }
315
361
  return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
316
362
  }
363
+ /**
364
+ * PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
365
+ * positional param (a string segment as a `str` token, an integer index as an
366
+ * `int` token). `->` binds tighter than every operator, so no parens are
367
+ * needed around the path in a comparison. Segments are bound (never inlined)
368
+ * to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
369
+ * that literally contained `$1` would otherwise be rewritten. Shared by the
370
+ * F1 where-filter path and the F2 orderBy / groupBy path emitters.
371
+ *
372
+ * A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
373
+ * the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
374
+ * can only be expressed as a digit string, and the SQL builder converts it the
375
+ * same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
376
+ * `->` treats `'0'` as a string KEY and silently matches nothing on an array
377
+ * (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
378
+ * json object whose key is literally `"0"` is addressed as an array index.
379
+ */
380
+ jsonPathExpr(col, path, params) {
381
+ let expr = `.${col.name}`;
382
+ for (const seg of path) {
383
+ const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
384
+ expr += `->${this.param(bound, params)}`;
385
+ }
386
+ return expr;
387
+ }
388
+ /**
389
+ * Compile a {@link JsonFilter} on a json document column into a PowQL filter
390
+ * (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
391
+ * (never a wrong result): containment (`contains`, and `equals` without a
392
+ * `path`) has no PowQL operator. The mapped shapes:
393
+ * - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
394
+ * integral number→int, fractional→float; NOT stringified)
395
+ * - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
396
+ * key, a deliberate divergence from the PG driver, documented on
397
+ * {@link JsonFilter})
398
+ * - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
399
+ * engine coerces int/float numerically)
400
+ * - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
401
+ * ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
402
+ * null)
403
+ * A bare `{ path }` with no operators compiles to zero clauses (byte-parity
404
+ * with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
405
+ * by the empty-where guard.
406
+ */
407
+ buildJsonPathCondition(col, filter, params) {
408
+ const conds = [];
409
+ // Bind the path segments at most once and reuse the expression string across
410
+ // equals + range comparisons (they share the same `path`).
411
+ let pathExpr = null;
412
+ const pathP = () => {
413
+ pathExpr ??= this.jsonPathExpr(col, filter.path, params);
414
+ return pathExpr;
415
+ };
416
+ if (filter.contains !== undefined) {
417
+ throw new errors_js_1.UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
418
+ }
419
+ if (filter.equals !== undefined) {
420
+ if (filter.path === undefined || filter.path.length === 0) {
421
+ throw new errors_js_1.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`);
422
+ }
423
+ conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
424
+ }
425
+ if (filter.hasKey !== undefined) {
426
+ // Top-level key existence, independent of `path` (mirrors PG `col ? key`).
427
+ conds.push(`json_type(.${col.name}->${this.param(filter.hasKey, params)}) is not null`);
428
+ }
429
+ // Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
430
+ // Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
431
+ // number or a string.
432
+ for (const [op, powOp] of [
433
+ ['gt', '>'],
434
+ ['gte', '>='],
435
+ ['lt', '<'],
436
+ ['lte', '<='],
437
+ ]) {
438
+ const v = filter[op];
439
+ if (v === undefined)
440
+ continue;
441
+ if (filter.path === undefined) {
442
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
443
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
444
+ }
445
+ if (typeof v !== 'number' && typeof v !== 'string') {
446
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
447
+ }
448
+ if (typeof v === 'number' && !Number.isFinite(v)) {
449
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
450
+ }
451
+ conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
452
+ }
453
+ if (!conds.length)
454
+ return '';
455
+ return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
456
+ }
317
457
  /** Bind a value, lowercasing for case-insensitive comparisons. */
318
458
  bind(value, params, insensitive) {
319
459
  const ph = this.param(value, params);
@@ -455,7 +595,7 @@ class PowqlInterface {
455
595
  const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
456
596
  const params = [];
457
597
  const ph = chunk.map((v) => this.param(v, params)).join(', ');
458
- const { rows } = await this.exec(`${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
598
+ const { rows } = await this.exec(`${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
459
599
  for (const r of rows) {
460
600
  const v = r[sourceJCol];
461
601
  if (v != null)
@@ -503,8 +643,20 @@ class PowqlInterface {
503
643
  projection(cols) {
504
644
  return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
505
645
  }
506
- /** `order .c1 asc, .c2 desc` clause (empty string when no orderBy). */
507
- buildOrder(orderBy) {
646
+ /**
647
+ * `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
648
+ * besides a plain direction:
649
+ * - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
650
+ * type?, direction? } }` → `order .data->$n asc` (or
651
+ * `cast(.data->$n, "float")` for `type: 'numeric'`);
652
+ * - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
653
+ * no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
654
+ *
655
+ * PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
656
+ * contract): for identical cross-engine results pass `nulls: 'last'`
657
+ * explicitly on Postgres, which defaults nulls-first for `desc`.
658
+ */
659
+ buildOrder(orderBy, params) {
508
660
  if (!orderBy)
509
661
  return '';
510
662
  const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
@@ -512,32 +664,82 @@ class PowqlInterface {
512
664
  return '';
513
665
  const parts = keys.map(([field, dir]) => {
514
666
  if (dir && typeof dir === 'object') {
667
+ const o = dir;
668
+ // JSON-path ordering on a json column.
669
+ if (Array.isArray(o.path)) {
670
+ return this.buildJsonPathOrder(field, dir, params);
671
+ }
672
+ // OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
673
+ // nulls-first (no placement grammar). Distinct from vector/pick/_count.
674
+ if ('sort' in o && !('distance' in o) && !('_count' in o) && !(0, filters_js_1.isRelationPickOrderBy)(dir)) {
675
+ const spec = dir;
676
+ if (spec.nulls === 'first') {
677
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
678
+ }
679
+ return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
680
+ }
515
681
  // Name the actual feature in the refusal — a pick-row ordering
516
682
  // reported as "vector / distance ordering" sends users hunting for
517
- // pgvector docs. All object-valued orderings stay E017 on PowDB.
518
- const o = dir;
683
+ // pgvector docs. Everything else stays E017 on PowDB.
519
684
  const feature = (0, filters_js_1.isRelationPickOrderBy)(dir)
520
685
  ? 'relation pick-row ordering'
521
686
  : 'distance' in o
522
687
  ? 'vector / distance ordering'
523
- : Array.isArray(o.path)
524
- ? 'JSON-path ordering'
525
- : '_count' in o
526
- ? 'relation _count ordering'
527
- : 'sort' in o || 'nulls' in o
528
- ? 'NULLS placement / sort-spec ordering'
529
- : 'object-valued ordering';
688
+ : '_count' in o
689
+ ? 'relation _count ordering'
690
+ : 'nulls' in o
691
+ ? 'NULLS placement / sort-spec ordering'
692
+ : 'object-valued ordering';
530
693
  throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
531
694
  }
532
695
  return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
533
696
  });
534
697
  return ` order ${parts.join(', ')}`;
535
698
  }
699
+ /** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
700
+ buildJsonPathOrder(field, spec, params) {
701
+ const col = this.column(field);
702
+ if (!(0, powdb_js_1.isJsonColumn)(col)) {
703
+ throw new errors_js_1.UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
704
+ }
705
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path ordering');
706
+ if (spec.nulls === 'first') {
707
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
708
+ }
709
+ const pathExpr = this.jsonPathExpr(col, spec.path, params);
710
+ // `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
711
+ // JSON numbers already order numerically without a cast.
712
+ const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
713
+ return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
714
+ }
536
715
  // -------------------------------------------------------------------------
537
716
  // Execution plumbing
538
717
  // -------------------------------------------------------------------------
539
- /** Run PowQL with optional timeout, emitting a query event either way. */
540
- async exec(powql, params, timeout) {
718
+ /**
719
+ * Run PowQL with optional timeout, emitting a query event either way. The
720
+ * `action` is passed PER CALL (never read from shared instance state) so the
721
+ * retry-eligibility and the emitted event action stay correct even when a
722
+ * concurrent operation runs on the same cached interface: a WRITE statement
723
+ * carries a write action and can therefore never be mistaken for a replayable
724
+ * read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
725
+ */
726
+ async exec(powql, params, timeout, action = 'raw') {
727
+ return this.execOnce(powql, params, timeout, action, false);
728
+ }
729
+ /**
730
+ * Execute one statement, with the opt-in single stale-frame READ replay. When
731
+ * `retryStaleReads` is on and a first-statement READ fails with the stale-wire
732
+ * {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
733
+ * unexpected frame" that the client cannot recover), the statement is retried
734
+ * exactly once on a fresh pooled connection (the broken one was destroyed).
735
+ * The replay is refused for writes (an ambiguous mutation reply is unsafe to
736
+ * replay) and inside a transaction (a mid-tx statement cannot move connection),
737
+ * so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
738
+ * `_txScoped` interface, are eligible. `action` is a per-call argument (never
739
+ * `this`-state), so a concurrent op flipping instance fields cannot turn a
740
+ * write into a retryable read.
741
+ */
742
+ async execOnce(powql, params, timeout, action, isRetry) {
541
743
  const start = performance.now();
542
744
  const run = this.pool.query(powql, params);
543
745
  try {
@@ -547,15 +749,37 @@ class PowqlInterface {
547
749
  new Promise((_, reject) => setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout)),
548
750
  ])
549
751
  : await run;
550
- this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
551
- return result;
752
+ this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
753
+ // The pool tags each result with the wire that actually served it
754
+ // (adaptResult → false, adaptNativeResult → true). A heterogeneous
755
+ // injected pool can fall back to the legacy wire per call, so read the
756
+ // per-result flag and only fall back to the pool-level capability when a
757
+ // hand-built pool (tests) omits the tag; never coerce legacy rows with
758
+ // the native policy just because the pool reports nativeRaw.
759
+ const native = result.native ?? Boolean(this.capabilities.nativeRaw);
760
+ return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
552
761
  }
553
762
  catch (err) {
554
- this.emit(powql, params, performance.now() - start, 0, err);
763
+ if (!isRetry && this.shouldRetryStaleRead(err, action)) {
764
+ // Transparent single replay on a fresh connection; the first (swallowed)
765
+ // failure is not emitted, only the retried outcome is observed.
766
+ return this.execOnce(powql, params, timeout, action, true);
767
+ }
768
+ this.emit(action, powql, params, performance.now() - start, 0, err);
555
769
  throw err;
556
770
  }
557
771
  }
558
- emit(sql, params, duration, rows, error) {
772
+ /** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
773
+ shouldRetryStaleRead(err, action) {
774
+ if (!this.pool.retryStaleReads)
775
+ return false;
776
+ if (this.isTxScoped())
777
+ return false;
778
+ if (!POWQL_READ_ACTIONS.has(action))
779
+ return false;
780
+ return (0, powdb_js_1.isStaleFramePowdbError)(err);
781
+ }
782
+ emit(action, sql, params, duration, rows, error) {
559
783
  if (!this.onQuery)
560
784
  return;
561
785
  try {
@@ -564,7 +788,7 @@ class PowqlInterface {
564
788
  params,
565
789
  duration,
566
790
  model: this.table,
567
- action: this.currentAction,
791
+ action,
568
792
  rows,
569
793
  timestamp: new Date(),
570
794
  error,
@@ -576,7 +800,6 @@ class PowqlInterface {
576
800
  }
577
801
  /** Run a method body through the middleware chain (mirrors QueryInterface). */
578
802
  async withMiddleware(action, args, executor) {
579
- this.currentAction = action;
580
803
  if (this.middlewares.length === 0)
581
804
  return executor();
582
805
  let index = 0;
@@ -587,24 +810,30 @@ class PowqlInterface {
587
810
  };
588
811
  return next({ model: this.table, action, args: { ...args } });
589
812
  }
590
- /** Map raw rows to typed entities. */
591
- shape(rows) {
592
- return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta));
813
+ /** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
814
+ * this result (threaded from {@link execOnce}, not the pool-level capability),
815
+ * so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
816
+ * string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
817
+ * to null) while a per-call legacy fallback on a native-capable pool still
818
+ * coerces its string cells correctly. Defaults to the pool capability for the
819
+ * rare caller with no per-result flag (hand-built test pools). */
820
+ shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
821
+ return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta, native));
593
822
  }
594
823
  // -------------------------------------------------------------------------
595
824
  // Reads
596
825
  // -------------------------------------------------------------------------
597
826
  async findMany(args = {}) {
598
827
  return this.withMiddleware('findMany', args, async () => {
599
- const rows = await this.runFind(args);
600
- const entities = this.shape(rows);
828
+ const { rows, native } = await this.runFind(args, 'findMany');
829
+ const entities = this.shape(rows, native);
601
830
  if (args.with)
602
831
  await this.loadRelations(entities, args.with, args.timeout);
603
832
  return entities;
604
833
  });
605
834
  }
606
- /** Build + run the flat findMany select; returns raw rows. */
607
- async runFind(args) {
835
+ /** Build + run the flat findMany select; returns raw rows + the serving wire. */
836
+ async runFind(args, action = 'findMany') {
608
837
  if (args.cursor) {
609
838
  throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
610
839
  }
@@ -614,7 +843,7 @@ class PowqlInterface {
614
843
  const cols = this.projectedColumns(args.select, args.omit);
615
844
  const distinct = args.distinct?.length ? ' distinct' : '';
616
845
  const filter = where ? ` filter ${where}` : '';
617
- const order = this.buildOrder(args.orderBy);
846
+ const order = this.buildOrder(args.orderBy, params);
618
847
  const limit = args.limit ?? args.take ?? this.defaultLimit;
619
848
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
620
849
  this.warnedUnlimited = true;
@@ -623,15 +852,15 @@ class PowqlInterface {
623
852
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
624
853
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
625
854
  const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
626
- const { rows } = await this.exec(powql, params, args.timeout);
627
- return rows;
855
+ const { rows, native } = await this.exec(powql, params, args.timeout, action);
856
+ return { rows, native };
628
857
  }
629
858
  async findUnique(args) {
630
859
  return this.withMiddleware('findUnique', args, async () => {
631
- const rows = await this.runFind({ ...args, limit: 1 });
860
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
632
861
  if (!rows.length)
633
862
  return null;
634
- const entities = this.shape(rows);
863
+ const entities = this.shape(rows, native);
635
864
  if (args.with)
636
865
  await this.loadRelations(entities, args.with, args.timeout);
637
866
  return entities[0];
@@ -639,10 +868,10 @@ class PowqlInterface {
639
868
  }
640
869
  async findFirst(args = {}) {
641
870
  return this.withMiddleware('findFirst', args, async () => {
642
- const rows = await this.runFind({ ...args, limit: 1 });
871
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
643
872
  if (!rows.length)
644
873
  return null;
645
- const entities = this.shape(rows);
874
+ const entities = this.shape(rows, native);
646
875
  if (args.with)
647
876
  await this.loadRelations(entities, args.with, args.timeout);
648
877
  return entities[0];
@@ -772,7 +1001,7 @@ class PowqlInterface {
772
1001
  const params = [];
773
1002
  const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
774
1003
  const powql = `${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
775
- const { rows } = await this.exec(powql, params, timeout);
1004
+ const { rows } = await this.exec(powql, params, timeout, 'findMany');
776
1005
  for (const row of rows) {
777
1006
  const sv = String(row[sourceJCol]);
778
1007
  const tv = String(row[targetJCol]);
@@ -873,8 +1102,8 @@ class PowqlInterface {
873
1102
  .map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
874
1103
  .join(', ');
875
1104
  // `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
876
- const { rows } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout);
877
- const row = rows.length ? this.shape(rows)[0] : null;
1105
+ const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
1106
+ const row = rows.length ? this.shape(rows, native)[0] : null;
878
1107
  if (!row)
879
1108
  throw new errors_js_1.NotFoundError({ table: this.table, where: data });
880
1109
  return row;
@@ -891,8 +1120,8 @@ class PowqlInterface {
891
1120
  return `{ ${assigns.map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
892
1121
  });
893
1122
  // Multi-row insert with `returning` hands back every inserted row in one round-trip.
894
- const { rows } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout);
895
- return this.shape(rows);
1123
+ const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
1124
+ return this.shape(rows, native);
896
1125
  });
897
1126
  }
898
1127
  async update(args) {
@@ -906,8 +1135,8 @@ class PowqlInterface {
906
1135
  this.assertCompiledWhere(where, false, 'update');
907
1136
  const setClause = this.buildUpdateAssignments(args.data, params);
908
1137
  // `returning` hands back the post-update row(s); take the first (single-row contract).
909
- const { rows } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout);
910
- const row = rows.length ? this.shape(rows)[0] : null;
1138
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
1139
+ const row = rows.length ? this.shape(rows, native)[0] : null;
911
1140
  if (!row)
912
1141
  throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
913
1142
  return row;
@@ -921,7 +1150,7 @@ class PowqlInterface {
921
1150
  this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
922
1151
  const setClause = this.buildUpdateAssignments(args.data, params);
923
1152
  const filter = where ? ` filter ${where}` : '';
924
- const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
1153
+ const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
925
1154
  return { count: rowCount };
926
1155
  });
927
1156
  }
@@ -1044,8 +1273,8 @@ class PowqlInterface {
1044
1273
  const where = this.buildWhere(resolvedWhere, params);
1045
1274
  this.assertCompiledWhere(where, false, 'delete');
1046
1275
  // `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
1047
- const { rows } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout);
1048
- const row = rows.length ? this.shape(rows)[0] : null;
1276
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
1277
+ const row = rows.length ? this.shape(rows, native)[0] : null;
1049
1278
  if (!row)
1050
1279
  throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
1051
1280
  return row;
@@ -1058,7 +1287,7 @@ class PowqlInterface {
1058
1287
  const where = this.buildWhere(resolvedWhere, params);
1059
1288
  this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
1060
1289
  const filter = where ? ` filter ${where}` : '';
1061
- const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
1290
+ const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
1062
1291
  return { count: rowCount };
1063
1292
  });
1064
1293
  }
@@ -1080,7 +1309,7 @@ class PowqlInterface {
1080
1309
  // (verified: "unexpected trailing token … 'returning'"), because it is one
1081
1310
  // atomic insert-or-update, not two branches. So upsert alone keeps the
1082
1311
  // reselect-by-PK fetch; create/update/delete all use `returning`.
1083
- await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
1312
+ await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
1084
1313
  const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
1085
1314
  const row = await this.reselectByPk(createData[pkField], args.timeout);
1086
1315
  if (!row)
@@ -1125,7 +1354,7 @@ class PowqlInterface {
1125
1354
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1126
1355
  const where = this.buildWhere(resolvedWhere, params);
1127
1356
  const filter = where ? ` filter ${where}` : '';
1128
- const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
1357
+ const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
1129
1358
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
1130
1359
  });
1131
1360
  }
@@ -1139,7 +1368,7 @@ class PowqlInterface {
1139
1368
  const filter = where ? ` filter ${where}` : '';
1140
1369
  const scalar = async (expr) => {
1141
1370
  const params = [...filterParams];
1142
- const { rows } = await this.exec(expr, params, args.timeout);
1371
+ const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
1143
1372
  const v = rows[0]?.value;
1144
1373
  return v == null || v === 'null' ? null : Number(v);
1145
1374
  };
@@ -1171,90 +1400,196 @@ class PowqlInterface {
1171
1400
  }
1172
1401
  async groupBy(args) {
1173
1402
  return this.withMiddleware('groupBy', args, async () => {
1174
- // The SQL-only groupBy extensions (DISTINCT ON row source, JSON-path
1175
- // group keys / aggregate targets) have no PowQL equivalent: refuse
1176
- // clearly instead of emitting broken PowQL.
1403
+ // DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
1177
1404
  if (args.distinctOn) {
1178
1405
  throw new errors_js_1.UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
1179
1406
  }
1180
- for (const entry of args.by) {
1181
- if (typeof entry !== 'string') {
1182
- throw new errors_js_1.UnsupportedFeatureError('JSON-path groupBy keys', 'PowDB');
1183
- }
1184
- }
1185
- for (const fn of ['_sum', '_avg', '_min', '_max']) {
1186
- const spec = args[fn];
1187
- if (!spec)
1188
- continue;
1189
- for (const value of Object.values(spec)) {
1190
- if (value !== undefined && typeof value !== 'boolean') {
1191
- throw new errors_js_1.UnsupportedFeatureError(`JSON-path ${fn} aggregate targets`, 'PowDB');
1192
- }
1193
- }
1407
+ // JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
1408
+ const usesJson = args.by.some((e) => typeof e !== 'string') ||
1409
+ ['_sum', '_avg', '_min', '_max'].some((fn) => {
1410
+ const spec = args[fn];
1411
+ return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
1412
+ });
1413
+ if (usesJson) {
1414
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
1194
1415
  }
1416
+ // `emitNative` decides whether the query GENERATION needs the legacy-wire
1417
+ // `json_type` discriminator (pool-level capability). The DECODE side reads
1418
+ // the wire that ACTUALLY served the result (`resultNative`, from exec), so
1419
+ // a per-call legacy fallback on a native-capable pool still decodes right.
1420
+ const emitNative = Boolean(this.capabilities.nativeRaw);
1195
1421
  const params = [];
1196
1422
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1197
1423
  const where = this.buildWhere(resolvedWhere, params);
1198
1424
  const filter = where ? ` filter ${where}` : '';
1199
- const groupKeys = args.by.map((f) => this.ref(f));
1200
- // Safe aliases PowQL rejects reserved-word aliases (e.g. `count:`).
1201
- const aliasMap = [];
1202
- let n = 0;
1203
- const proj = args.by.map((f) => this.ref(f));
1204
- if (args._count) {
1205
- aliasMap.push({ alias: `agg_${n++}`, fn: 'count', field: null, outKey: '_count' });
1425
+ // Result-key namespace, mirroring the SQL builder's `claimResultKey`
1426
+ // (query/builder.ts): a group-key / aggregate output-name collision (with
1427
+ // `_count`, another key, or an aggregate output) throws E003.
1428
+ const usedKeys = new Set();
1429
+ const claim = (key, what) => {
1430
+ if (key === '_count' || usedKeys.has(key)) {
1431
+ throw new errors_js_1.ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1432
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1433
+ }
1434
+ usedKeys.add(key);
1435
+ };
1436
+ const groupExprs = [];
1437
+ const proj = [];
1438
+ const byOrderExprs = new Map();
1439
+ const byReaders = [];
1440
+ let gkN = 0;
1441
+ let gtN = 0;
1442
+ for (const entry of args.by) {
1443
+ if (typeof entry === 'string') {
1444
+ const col = this.column(entry);
1445
+ claim(entry, `column "${col.name}"`);
1446
+ if (col.name !== entry)
1447
+ claim(col.name, `column "${col.name}"`);
1448
+ groupExprs.push(`.${col.name}`);
1449
+ proj.push(`.${col.name}`);
1450
+ byOrderExprs.set(entry, `.${col.name}`);
1451
+ byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
1452
+ }
1453
+ else {
1454
+ const col = this.column(entry.field);
1455
+ if (!(0, powdb_js_1.isJsonColumn)(col)) {
1456
+ throw new errors_js_1.ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
1457
+ }
1458
+ this.assertJsonPath('group key', entry.field, entry.path);
1459
+ const pathExpr = this.jsonPathExpr(col, entry.path, params);
1460
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1461
+ claim(alias, `JSON path on "${entry.field}"`);
1462
+ const gkAlias = `gk_${gkN++}`;
1463
+ groupExprs.push(pathExpr);
1464
+ proj.push(`${gkAlias}: ${pathExpr}`);
1465
+ byOrderExprs.set(alias, `.${gkAlias}`);
1466
+ let discrim;
1467
+ if (!emitNative) {
1468
+ // Legacy wire renders a missing value, JSON null, AND the string
1469
+ // "null" all as the cell "null". `min(json_type(path))` over the
1470
+ // group is "string" ONLY for the string-"null" group and "null"
1471
+ // otherwise (a bare `json_type` projection is not group-correlated).
1472
+ discrim = `gt_${gtN++}`;
1473
+ proj.push(`${discrim}: min(json_type(${pathExpr}))`);
1474
+ }
1475
+ byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
1476
+ }
1477
+ }
1478
+ // Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
1479
+ // aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
1480
+ // expression by user key; `aggOrderExprs` lets orderBy reference the alias.
1481
+ const aggReaders = [];
1482
+ const aggOrderExprs = new Map();
1483
+ const aggInner = new Map();
1484
+ let aggN = 0;
1485
+ // Parity with the SQL builder (query/builder.ts): `_count` is selected by
1486
+ // DEFAULT unless the caller explicitly opts out with `_count: false`, so
1487
+ // every groupBy row carries `_count` and `orderBy: { _count }` works
1488
+ // without requesting it (the alias is seeded into `aggOrderExprs`).
1489
+ const countSelected = args._count === true || args._count === undefined;
1490
+ if (countSelected) {
1491
+ const alias = `agg_${aggN++}`;
1492
+ proj.push(`${alias}: count(*)`);
1493
+ aggReaders.push({ alias, outKey: '_count', numeric: true });
1494
+ aggOrderExprs.set('_count', `.${alias}`);
1206
1495
  }
1207
1496
  for (const fn of ['_sum', '_avg', '_min', '_max']) {
1208
1497
  const spec = args[fn];
1209
1498
  if (!spec)
1210
1499
  continue;
1211
- for (const field of Object.keys(spec).filter((f) => spec[f])) {
1212
- aliasMap.push({ alias: `agg_${n++}`, fn: fn.slice(1), field, outKey: `${fn}:${field}` });
1213
- }
1214
- }
1215
- for (const a of aliasMap) {
1216
- proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
1217
- }
1218
- const having = this.buildHaving(args.having, params);
1219
- // groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
1220
- // equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
1221
- // a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
1222
- // Refuse those keys explicitly; plain by-field ordering still flows through.
1223
- if (args.orderBy) {
1224
- for (const key of Object.keys(args.orderBy)) {
1225
- if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
1226
- throw new errors_js_1.UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
1500
+ const powfn = fn.slice(1); // sum/avg/min/max
1501
+ for (const [key, target] of Object.entries(spec)) {
1502
+ if (!target)
1503
+ continue;
1504
+ const alias = `agg_${aggN++}`;
1505
+ if (target === true) {
1506
+ const col = this.column(key);
1507
+ claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
1508
+ const inner = `.${col.name}`;
1509
+ proj.push(`${alias}: ${powfn}(${inner})`);
1510
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
1511
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1512
+ aggInner.set(key, inner);
1513
+ }
1514
+ else {
1515
+ const col = this.column(target.field);
1516
+ if (!(0, powdb_js_1.isJsonColumn)(col)) {
1517
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
1518
+ }
1519
+ this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
1520
+ const alwaysNumeric = fn === '_sum' || fn === '_avg';
1521
+ if (alwaysNumeric && target.type === 'text') {
1522
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
1523
+ `${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1524
+ }
1525
+ const numeric = alwaysNumeric || target.type === 'numeric';
1526
+ claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
1527
+ const pathExpr = this.jsonPathExpr(col, target.path, params);
1528
+ const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
1529
+ proj.push(`${alias}: ${powfn}(${inner})`);
1530
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
1531
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1532
+ aggInner.set(key, inner);
1227
1533
  }
1228
1534
  }
1229
1535
  }
1230
- const order = this.buildOrder(args.orderBy);
1231
- const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1232
- const { rows } = await this.exec(powql, params, args.timeout);
1233
- // Reshape: group keys camel fields + coerced; aggregates nested {_sum:{field}}.
1536
+ const having = this.buildHaving(args.having, params, aggInner);
1537
+ const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
1538
+ const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1539
+ const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
1540
+ // Reshape: group keys → user fields (coerced / null-disambiguated),
1541
+ // aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
1542
+ // Group-key cells go through the SAME coercion policy `rowToEntity` uses,
1543
+ // by the wire that actually served this result, so a native-wire int cell
1544
+ // (bigint) or datetime cell (micros) never leaks into the result: it
1545
+ // becomes the same number / Date / PG-text-parity string an embedded /
1546
+ // legacy / SQL groupBy returns for the identical query.
1234
1547
  return rows.map((raw) => {
1235
1548
  const out = {};
1236
- for (const f of args.by) {
1237
- const col = this.column(f);
1238
- out[f] =
1239
- typeof raw[col.name] === 'string' ? coerceScalar(raw[col.name], col.tsType) : raw[col.name];
1549
+ for (const r of byReaders) {
1550
+ if (r.kind === 'plain') {
1551
+ const cell = raw[r.rowKey];
1552
+ out[r.resultKey] = resultNative
1553
+ ? (0, powdb_js_1.coerceNativeValue)(cell, r.col)
1554
+ : typeof cell === 'string'
1555
+ ? coerceScalar(cell, r.col.tsType)
1556
+ : cell;
1557
+ }
1558
+ else {
1559
+ out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
1560
+ }
1240
1561
  }
1241
- for (const a of aliasMap) {
1242
- const val = raw[a.alias];
1243
- const num = val == null || val === 'null' ? null : Number(val);
1562
+ for (const a of aggReaders) {
1563
+ const cell = raw[a.alias];
1564
+ const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
1244
1565
  if (a.outKey === '_count')
1245
- out._count = num ?? 0;
1566
+ out._count = v ?? 0;
1246
1567
  else {
1247
1568
  const [bucket, field] = a.outKey.split(':');
1248
1569
  out[bucket] ??= {};
1249
- out[bucket][field] = num;
1570
+ out[bucket][field] = v;
1250
1571
  }
1251
1572
  }
1252
1573
  return out;
1253
1574
  });
1254
1575
  });
1255
1576
  }
1256
- /** `having <expr>` over group aggregates (count/sum/avg/min/max). */
1257
- buildHaving(having, params) {
1577
+ /** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
1578
+ assertJsonPath(context, field, path) {
1579
+ if (!Array.isArray(path) ||
1580
+ path.length === 0 ||
1581
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
1582
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
1583
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
1584
+ }
1585
+ }
1586
+ /**
1587
+ * `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
1588
+ * with the projection); a per-field aggregate re-emits its inner expression
1589
+ * (from `aggInner` when the field is a requested aggregate, so a JSON-path
1590
+ * aggregate reuses its bound placeholders, else `.field` for a plain column).
1591
+ */
1592
+ buildHaving(having, params, aggInner) {
1258
1593
  if (!having)
1259
1594
  return '';
1260
1595
  const conds = [];
@@ -1272,18 +1607,88 @@ class PowqlInterface {
1272
1607
  if (spec == null)
1273
1608
  continue;
1274
1609
  if (key === '_count') {
1275
- conds.push(cmp(`count(.${this.meta.primaryKey[0]})`, spec));
1610
+ conds.push(cmp('count(*)', spec));
1276
1611
  }
1277
1612
  else {
1278
1613
  for (const [fn, filter] of Object.entries(spec)) {
1279
1614
  if (filter == null)
1280
1615
  continue;
1281
- conds.push(cmp(`${fn.slice(1)}(${this.ref(key)})`, filter));
1616
+ const inner = aggInner.get(key) ?? this.ref(key);
1617
+ conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
1282
1618
  }
1283
1619
  }
1284
1620
  }
1285
1621
  return conds.length ? ` having ${conds.join(' and ')}` : '';
1286
1622
  }
1623
+ /**
1624
+ * Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
1625
+ * columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
1626
+ * cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
1627
+ * order by a projection alias on a grouped query (probed), so each key maps to
1628
+ * its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
1629
+ * surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
1630
+ * requested in this call, or an unknown by-key, throws E003 listing the valid
1631
+ * keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
1632
+ */
1633
+ buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
1634
+ if (!orderBy)
1635
+ return '';
1636
+ const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
1637
+ const validKeys = () => {
1638
+ const keys = [...byOrderExprs.keys()];
1639
+ for (const k of aggOrderExprs.keys())
1640
+ keys.push(k.includes(':') ? k.replace(':', '.') : k);
1641
+ return keys.join(', ') || '(none)';
1642
+ };
1643
+ const parts = [];
1644
+ for (const [key, value] of Object.entries(orderBy)) {
1645
+ if (value === undefined)
1646
+ continue;
1647
+ if (aggBlocks.has(key)) {
1648
+ if (key === '_count') {
1649
+ const expr = aggOrderExprs.get('_count');
1650
+ if (!expr) {
1651
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
1652
+ `Orderable keys: ${validKeys()}.`);
1653
+ }
1654
+ parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
1655
+ continue;
1656
+ }
1657
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1658
+ throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
1659
+ `expected a field map like { ${key}: { amount: 'desc' } }.`);
1660
+ }
1661
+ for (const [field, dirSpec] of Object.entries(value)) {
1662
+ if (dirSpec === undefined)
1663
+ continue;
1664
+ const expr = aggOrderExprs.get(`${key}:${field}`);
1665
+ if (!expr) {
1666
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
1667
+ `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
1668
+ }
1669
+ parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
1670
+ }
1671
+ continue;
1672
+ }
1673
+ const expr = byOrderExprs.get(key);
1674
+ if (!expr) {
1675
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
1676
+ }
1677
+ parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
1678
+ }
1679
+ return parts.length ? ` order ${parts.join(', ')}` : '';
1680
+ }
1681
+ /** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
1682
+ groupOrderDir(value, keyForMsg) {
1683
+ if (value !== null && typeof value === 'object') {
1684
+ const spec = value;
1685
+ if (spec.nulls === 'first') {
1686
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
1687
+ }
1688
+ return spec.sort === 'desc' ? 'desc' : 'asc';
1689
+ }
1690
+ return value === 'desc' ? 'desc' : 'asc';
1691
+ }
1287
1692
  // -------------------------------------------------------------------------
1288
1693
  // Streaming / unsupported
1289
1694
  // -------------------------------------------------------------------------
@@ -1297,12 +1702,12 @@ class PowqlInterface {
1297
1702
  /** Reselect a single row by its single-column primary key value. */
1298
1703
  async reselectByPk(pkValue, timeout) {
1299
1704
  const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
1300
- const rows = await this.runFind({
1705
+ const { rows, native } = await this.runFind({
1301
1706
  where: { [pkField]: pkValue },
1302
1707
  limit: 1,
1303
1708
  timeout,
1304
1709
  });
1305
- return rows.length ? this.shape(rows)[0] : null;
1710
+ return rows.length ? this.shape(rows, native)[0] : null;
1306
1711
  }
1307
1712
  /**
1308
1713
  * Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
@@ -1338,3 +1743,53 @@ function coerceScalar(raw, tsType) {
1338
1743
  return new Date(Number(raw) / 1000);
1339
1744
  return raw;
1340
1745
  }
1746
+ /**
1747
+ * Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
1748
+ * normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
1749
+ *
1750
+ * On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
1751
+ * `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
1752
+ * would diverge from every other transport: the embedded / legacy / SQL wire
1753
+ * all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
1754
+ * on `JSON.stringify`. So a native scalar cell is rendered to its text form
1755
+ * ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
1756
+ * `"null"` stays the string (the wart the native wire was adopted to fix).
1757
+ *
1758
+ * On the legacy string wire a missing value, JSON null, AND the string `"null"`
1759
+ * all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
1760
+ * `"string"` ONLY for the string-`"null"` group, so the cell is the string
1761
+ * `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
1762
+ * pass through as the extracted string.
1763
+ */
1764
+ function decodeGroupKeyCell(cell, discrim, native) {
1765
+ if (native)
1766
+ return nativeJsonKeyText(cell);
1767
+ if (cell == null)
1768
+ return null;
1769
+ if (cell === 'null')
1770
+ return discrim === 'string' ? 'null' : null;
1771
+ return cell;
1772
+ }
1773
+ /**
1774
+ * Render a native-wire JSON group-key cell to the extracted-text shape the other
1775
+ * transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
1776
+ * `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
1777
+ * string form; an object/array json sub-document is stringified (best-effort
1778
+ * parity: canonical byte-for-byte matching is not guaranteed for nested docs).
1779
+ */
1780
+ function nativeJsonKeyText(cell) {
1781
+ if (cell === null || cell === undefined)
1782
+ return null;
1783
+ if (typeof cell === 'bigint')
1784
+ return cell.toString();
1785
+ if (typeof cell === 'number' || typeof cell === 'boolean')
1786
+ return String(cell);
1787
+ if (typeof cell === 'string')
1788
+ return cell;
1789
+ try {
1790
+ return JSON.stringify(cell);
1791
+ }
1792
+ catch {
1793
+ return String(cell);
1794
+ }
1795
+ }