turbine-orm 0.33.0 → 0.35.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 (47) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/client.js +26 -4
  3. package/dist/cjs/dialect.js +1 -0
  4. package/dist/cjs/errors.js +41 -1
  5. package/dist/cjs/index-advisor.js +0 -0
  6. package/dist/cjs/index.js +4 -2
  7. package/dist/cjs/mssql.js +5 -0
  8. package/dist/cjs/mysql.js +4 -0
  9. package/dist/cjs/optional-peer-import.cjs +28 -0
  10. package/dist/cjs/powdb-introspect.js +222 -0
  11. package/dist/cjs/powdb.js +592 -72
  12. package/dist/cjs/powql.js +998 -134
  13. package/dist/cjs/query/builder.js +72 -1
  14. package/dist/cjs/schema-builder.js +16 -0
  15. package/dist/cjs/schema-metadata.js +81 -10
  16. package/dist/cjs/sqlite.js +3 -0
  17. package/dist/client.d.ts +32 -5
  18. package/dist/client.js +26 -4
  19. package/dist/dialect.d.ts +13 -0
  20. package/dist/dialect.js +1 -0
  21. package/dist/errors.d.ts +36 -0
  22. package/dist/errors.js +39 -0
  23. package/dist/index-advisor.d.ts +15 -1
  24. package/dist/index-advisor.js +0 -0
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.js +2 -2
  27. package/dist/mssql.js +5 -0
  28. package/dist/mysql.js +4 -0
  29. package/dist/optional-peer-import.cjs +28 -0
  30. package/dist/optional-peer-import.d.cts +19 -0
  31. package/dist/powdb-introspect.d.ts +84 -0
  32. package/dist/powdb-introspect.js +219 -0
  33. package/dist/powdb.d.ts +361 -19
  34. package/dist/powdb.js +585 -72
  35. package/dist/powql.d.ts +245 -8
  36. package/dist/powql.js +1001 -137
  37. package/dist/query/builder.d.ts +36 -1
  38. package/dist/query/builder.js +72 -1
  39. package/dist/query/deferred.d.ts +6 -2
  40. package/dist/query/types.d.ts +49 -12
  41. package/dist/schema-builder.d.ts +46 -1
  42. package/dist/schema-builder.js +15 -0
  43. package/dist/schema-metadata.d.ts +13 -7
  44. package/dist/schema-metadata.js +82 -11
  45. package/dist/schema.d.ts +25 -0
  46. package/dist/sqlite.js +3 -0
  47. package/package.json +3 -3
package/dist/cjs/powql.js CHANGED
@@ -84,6 +84,38 @@ 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
+ 'explain',
102
+ ]);
103
+ /**
104
+ * Mutating actions the {@link PowqlInterface} readonly guard refuses locally
105
+ * (before the wire) on a read-only pool. A transaction-control `begin` is
106
+ * guarded separately in {@link PowqlInterface.runInImplicitTx}. Kept keyed on
107
+ * the per-call action string (never `this`-state) so a concurrent read can
108
+ * never be mistaken for one of these.
109
+ */
110
+ const POWQL_WRITE_ACTIONS = new Set([
111
+ 'create',
112
+ 'createMany',
113
+ 'update',
114
+ 'updateMany',
115
+ 'delete',
116
+ 'deleteMany',
117
+ 'upsert',
118
+ ]);
87
119
  /** Operator keys recognised inside a `WhereOperator` object. */
88
120
  const OPERATOR_KEYS = new Set([
89
121
  'equals',
@@ -129,7 +161,6 @@ class PowqlInterface {
129
161
  defaultLimit;
130
162
  warnOnUnlimited;
131
163
  onQuery;
132
- currentAction = 'raw';
133
164
  warnedUnlimited = false;
134
165
  constructor(pool, table, schema, middlewares = [], options = {}) {
135
166
  this.pool = pool;
@@ -167,9 +198,19 @@ class PowqlInterface {
167
198
  }
168
199
  return col;
169
200
  }
170
- /** PowQL column reference (`.snake_name`) for a field. */
171
- ref(field) {
172
- return `.${this.column(field).name}`;
201
+ /**
202
+ * PowQL column reference for a field. Unqualified it is a dotted field
203
+ * reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
204
+ * is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
205
+ * column name is backtick-quoted if it is a reserved word (a qualified
206
+ * `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
207
+ */
208
+ ref(field, alias) {
209
+ return this.colRefName(this.column(field).name, alias);
210
+ }
211
+ /** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
212
+ colRefName(name, alias) {
213
+ return alias ? `${alias}.${(0, powdb_js_1.quotePowqlIdent)(name)}` : `.${name}`;
173
214
  }
174
215
  /**
175
216
  * Push a value into the param array and return its `$N` placeholder. When the
@@ -180,7 +221,17 @@ class PowqlInterface {
180
221
  * {@link toPowdbParam}, so the wire param is unchanged.
181
222
  */
182
223
  param(value, params, col) {
183
- const tagged = col && typeof value === 'number' && this.isFloatCol(col) ? new powdb_js_1.PowdbFloatParam(value) : value;
224
+ let tagged = value;
225
+ if (col && typeof value === 'number' && this.isFloatCol(col)) {
226
+ // Float column: force a float-form literal even for an integer value.
227
+ tagged = new powdb_js_1.PowdbFloatParam(value);
228
+ }
229
+ else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && (0, powdb_js_1.isJsonColumn)(col)) {
230
+ // json document column: a JS object/array is serialized to canonical JSON
231
+ // text and stored as a json document (a JS string passes through raw, same
232
+ // contract as pg jsonb; `null` stays `null`).
233
+ tagged = new powdb_js_1.PowdbJsonParam(value);
234
+ }
184
235
  params.push(tagged);
185
236
  return `$${params.length}`;
186
237
  }
@@ -208,6 +259,15 @@ class PowqlInterface {
208
259
  return false;
209
260
  }
210
261
  }
262
+ /**
263
+ * The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
264
+ * default (all feature gates on, `nativeRaw` off) when a directly-constructed
265
+ * pool did not carry them, matching {@link PowdbPool}'s own constructor
266
+ * default so a hand-built test pool never crashes the version gates.
267
+ */
268
+ get capabilities() {
269
+ return this.pool.capabilities ?? powdb_js_1.ALL_POWDB_CAPABILITIES;
270
+ }
211
271
  /** A predicate that is always false — the empty-`in` / contradiction sentinel. */
212
272
  alwaysFalse() {
213
273
  const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
@@ -219,8 +279,15 @@ class PowqlInterface {
219
279
  /**
220
280
  * Compile a {@link WhereClause} into a PowQL filter expression, pushing every
221
281
  * value as a positional `$N` param. Returns `''` when there are no conditions.
282
+ *
283
+ * When `alias` is supplied (the F2 native-join path) every field reference is
284
+ * qualified with it (`.col` → `alias.col`, JSON path bases too); params bind
285
+ * exactly as in the unqualified path. The caller only ever passes an alias for
286
+ * an already-RESOLVED where (relation filters pre-resolved to literal in-lists
287
+ * by {@link resolveRelationFilters}): the relation-key branch below still
288
+ * throws, so an unresolved relation filter can never leak into a join.
222
289
  */
223
- buildWhere(where, params) {
290
+ buildWhere(where, params, alias) {
224
291
  if (!where)
225
292
  return '';
226
293
  const parts = [];
@@ -228,17 +295,17 @@ class PowqlInterface {
228
295
  if (value === undefined)
229
296
  continue;
230
297
  if (key === 'AND') {
231
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
298
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
232
299
  if (sub.length)
233
300
  parts.push(`(${sub.join(' and ')})`);
234
301
  }
235
302
  else if (key === 'OR') {
236
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
303
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
237
304
  if (sub.length)
238
305
  parts.push(`(${sub.join(' or ')})`);
239
306
  }
240
307
  else if (key === 'NOT') {
241
- const sub = this.buildWhere(value, params);
308
+ const sub = this.buildWhere(value, params, alias);
242
309
  if (sub)
243
310
  parts.push(`not (${sub})`);
244
311
  }
@@ -249,20 +316,33 @@ class PowqlInterface {
249
316
  throw new errors_js_1.ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
250
317
  }
251
318
  else {
252
- parts.push(this.buildFieldCondition(key, value, params));
319
+ // A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
320
+ // empty results so buildWhere never emits a dangling ` and `.
321
+ const cond = this.buildFieldCondition(key, value, params, alias);
322
+ if (cond)
323
+ parts.push(cond);
253
324
  }
254
325
  }
255
326
  return parts.join(' and ');
256
327
  }
257
328
  /** Build a single `field: value | operator` condition. */
258
- buildFieldCondition(field, value, params) {
259
- const ref = this.ref(field);
329
+ buildFieldCondition(field, value, params, alias) {
330
+ const colMeta = this.column(field);
331
+ const ref = this.ref(field, alias);
260
332
  if (value === null)
261
333
  return `${ref} is null`;
262
334
  if (value instanceof Date || typeof value !== 'object') {
263
- return `${ref} = ${this.param(value, params)}`;
335
+ return `${ref} = ${this.param(value, params, colMeta)}`;
264
336
  }
265
337
  const op = value;
338
+ // JSON path / key filters on a json document column compile to PowQL `->`
339
+ // path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
340
+ // `hasKey`; on a NON-json column those fall through to the scalar operator
341
+ // path below (e.g. `equals` stays a plain equality), exactly like SQL.
342
+ if ((0, powdb_js_1.isJsonColumn)(colMeta) && (0, filters_js_1.isJsonFilter)(value)) {
343
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path filters');
344
+ return this.buildJsonPathCondition(colMeta, value, params, alias);
345
+ }
266
346
  rejectUnsupportedFilter(op, field);
267
347
  if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
268
348
  // A bare object that is not an operator set — equality by value.
@@ -314,6 +394,100 @@ class PowqlInterface {
314
394
  }
315
395
  return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
316
396
  }
397
+ /**
398
+ * PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
399
+ * positional param (a string segment as a `str` token, an integer index as an
400
+ * `int` token). `->` binds tighter than every operator, so no parens are
401
+ * needed around the path in a comparison. Segments are bound (never inlined)
402
+ * to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
403
+ * that literally contained `$1` would otherwise be rewritten. Shared by the
404
+ * F1 where-filter path and the F2 orderBy / groupBy path emitters.
405
+ *
406
+ * A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
407
+ * the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
408
+ * can only be expressed as a digit string, and the SQL builder converts it the
409
+ * same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
410
+ * `->` treats `'0'` as a string KEY and silently matches nothing on an array
411
+ * (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
412
+ * json object whose key is literally `"0"` is addressed as an array index.
413
+ */
414
+ jsonPathExpr(col, path, params, alias) {
415
+ let expr = this.colRefName(col.name, alias);
416
+ for (const seg of path) {
417
+ const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
418
+ expr += `->${this.param(bound, params)}`;
419
+ }
420
+ return expr;
421
+ }
422
+ /**
423
+ * Compile a {@link JsonFilter} on a json document column into a PowQL filter
424
+ * (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
425
+ * (never a wrong result): containment (`contains`, and `equals` without a
426
+ * `path`) has no PowQL operator. The mapped shapes:
427
+ * - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
428
+ * integral number→int, fractional→float; NOT stringified)
429
+ * - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
430
+ * key, a deliberate divergence from the PG driver, documented on
431
+ * {@link JsonFilter})
432
+ * - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
433
+ * engine coerces int/float numerically)
434
+ * - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
435
+ * ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
436
+ * null)
437
+ * A bare `{ path }` with no operators compiles to zero clauses (byte-parity
438
+ * with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
439
+ * by the empty-where guard.
440
+ */
441
+ buildJsonPathCondition(col, filter, params, alias) {
442
+ const conds = [];
443
+ // Bind the path segments at most once and reuse the expression string across
444
+ // equals + range comparisons (they share the same `path`).
445
+ let pathExpr = null;
446
+ const pathP = () => {
447
+ pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
448
+ return pathExpr;
449
+ };
450
+ if (filter.contains !== undefined) {
451
+ throw new errors_js_1.UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
452
+ }
453
+ if (filter.equals !== undefined) {
454
+ if (filter.path === undefined || filter.path.length === 0) {
455
+ 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`);
456
+ }
457
+ conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
458
+ }
459
+ if (filter.hasKey !== undefined) {
460
+ // Top-level key existence, independent of `path` (mirrors PG `col ? key`).
461
+ conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
462
+ }
463
+ // Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
464
+ // Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
465
+ // number or a string.
466
+ for (const [op, powOp] of [
467
+ ['gt', '>'],
468
+ ['gte', '>='],
469
+ ['lt', '<'],
470
+ ['lte', '<='],
471
+ ]) {
472
+ const v = filter[op];
473
+ if (v === undefined)
474
+ continue;
475
+ if (filter.path === undefined) {
476
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
477
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
478
+ }
479
+ if (typeof v !== 'number' && typeof v !== 'string') {
480
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
481
+ }
482
+ if (typeof v === 'number' && !Number.isFinite(v)) {
483
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
484
+ }
485
+ conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
486
+ }
487
+ if (!conds.length)
488
+ return '';
489
+ return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
490
+ }
317
491
  /** Bind a value, lowercasing for case-insensitive comparisons. */
318
492
  bind(value, params, insensitive) {
319
493
  const ph = this.param(value, params);
@@ -455,7 +629,7 @@ class PowqlInterface {
455
629
  const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
456
630
  const params = [];
457
631
  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);
632
+ const { rows } = await this.exec(`${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
459
633
  for (const r of rows) {
460
634
  const v = r[sourceJCol];
461
635
  if (v != null)
@@ -503,8 +677,20 @@ class PowqlInterface {
503
677
  projection(cols) {
504
678
  return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
505
679
  }
506
- /** `order .c1 asc, .c2 desc` clause (empty string when no orderBy). */
507
- buildOrder(orderBy) {
680
+ /**
681
+ * `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
682
+ * besides a plain direction:
683
+ * - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
684
+ * type?, direction? } }` → `order .data->$n asc` (or
685
+ * `cast(.data->$n, "float")` for `type: 'numeric'`);
686
+ * - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
687
+ * no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
688
+ *
689
+ * PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
690
+ * contract): for identical cross-engine results pass `nulls: 'last'`
691
+ * explicitly on Postgres, which defaults nulls-first for `desc`.
692
+ */
693
+ buildOrder(orderBy, params, alias) {
508
694
  if (!orderBy)
509
695
  return '';
510
696
  const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
@@ -512,32 +698,97 @@ class PowqlInterface {
512
698
  return '';
513
699
  const parts = keys.map(([field, dir]) => {
514
700
  if (dir && typeof dir === 'object') {
701
+ const o = dir;
702
+ // JSON-path ordering on a json column.
703
+ if (Array.isArray(o.path)) {
704
+ return this.buildJsonPathOrder(field, dir, params, alias);
705
+ }
706
+ // OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
707
+ // nulls-first (no placement grammar). Distinct from vector/pick/_count.
708
+ if ('sort' in o && !('distance' in o) && !('_count' in o) && !(0, filters_js_1.isRelationPickOrderBy)(dir)) {
709
+ const spec = dir;
710
+ if (spec.nulls === 'first') {
711
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
712
+ }
713
+ return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
714
+ }
515
715
  // Name the actual feature in the refusal — a pick-row ordering
516
716
  // reported as "vector / distance ordering" sends users hunting for
517
- // pgvector docs. All object-valued orderings stay E017 on PowDB.
518
- const o = dir;
717
+ // pgvector docs. Everything else stays E017 on PowDB.
519
718
  const feature = (0, filters_js_1.isRelationPickOrderBy)(dir)
520
719
  ? 'relation pick-row ordering'
521
720
  : 'distance' in o
522
721
  ? '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';
722
+ : '_count' in o
723
+ ? 'relation _count ordering'
724
+ : 'nulls' in o
725
+ ? 'NULLS placement / sort-spec ordering'
726
+ : 'object-valued ordering';
530
727
  throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
531
728
  }
532
- return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
729
+ return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
533
730
  });
534
731
  return ` order ${parts.join(', ')}`;
535
732
  }
733
+ /** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
734
+ buildJsonPathOrder(field, spec, params, alias) {
735
+ const col = this.column(field);
736
+ if (!(0, powdb_js_1.isJsonColumn)(col)) {
737
+ throw new errors_js_1.UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
738
+ }
739
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path ordering');
740
+ if (spec.nulls === 'first') {
741
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
742
+ }
743
+ const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
744
+ // `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
745
+ // JSON numbers already order numerically without a cast.
746
+ const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
747
+ return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
748
+ }
536
749
  // -------------------------------------------------------------------------
537
750
  // Execution plumbing
538
751
  // -------------------------------------------------------------------------
539
- /** Run PowQL with optional timeout, emitting a query event either way. */
540
- async exec(powql, params, timeout) {
752
+ /**
753
+ * Run PowQL with optional timeout, emitting a query event either way. The
754
+ * `action` is passed PER CALL (never read from shared instance state) so the
755
+ * retry-eligibility and the emitted event action stay correct even when a
756
+ * concurrent operation runs on the same cached interface: a WRITE statement
757
+ * carries a write action and can therefore never be mistaken for a replayable
758
+ * read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
759
+ */
760
+ async exec(powql, params, timeout, action = 'raw') {
761
+ return this.execOnce(powql, params, timeout, action, false);
762
+ }
763
+ /** Build the E018 refusal for a write / `begin` on a read-only pool. */
764
+ readOnlyError(operation) {
765
+ // Pass a clean detail: the ReadOnlyError constructor owns both the
766
+ // `[turbine] ` prefix and the "Route writes to a writable primary." hint,
767
+ // so adding either here would double them.
768
+ return new errors_js_1.ReadOnlyError(`${operation} on "${this.table}" refused: this PowDB connection is read-only.`);
769
+ }
770
+ /**
771
+ * Execute one statement, with the opt-in single stale-frame READ replay. When
772
+ * `retryStaleReads` is on and a first-statement READ fails with the stale-wire
773
+ * {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
774
+ * unexpected frame" that the client cannot recover), the statement is retried
775
+ * exactly once on a fresh pooled connection (the broken one was destroyed).
776
+ * The replay is refused for writes (an ambiguous mutation reply is unsafe to
777
+ * replay) and inside a transaction (a mid-tx statement cannot move connection),
778
+ * so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
779
+ * `_txScoped` interface, are eligible. `action` is a per-call argument (never
780
+ * `this`-state), so a concurrent op flipping instance fields cannot turn a
781
+ * write into a retryable read.
782
+ */
783
+ async execOnce(powql, params, timeout, action, isRetry) {
784
+ // Read-only pool guard: refuse a write action locally, before the wire, so a
785
+ // read-only target never even attempts the mutation (the engine refusal, if
786
+ // any, is only the backstop for raw/injected paths). `action` is per-call,
787
+ // so a concurrent read is never mistaken for a write. Reads (incl. explain)
788
+ // and non-classified `raw` fall through unchanged.
789
+ if (this.pool.readonly === true && POWQL_WRITE_ACTIONS.has(action)) {
790
+ throw this.readOnlyError(action);
791
+ }
541
792
  const start = performance.now();
542
793
  const run = this.pool.query(powql, params);
543
794
  try {
@@ -547,15 +798,37 @@ class PowqlInterface {
547
798
  new Promise((_, reject) => setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout)),
548
799
  ])
549
800
  : await run;
550
- this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
551
- return result;
801
+ this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
802
+ // The pool tags each result with the wire that actually served it
803
+ // (adaptResult → false, adaptNativeResult → true). A heterogeneous
804
+ // injected pool can fall back to the legacy wire per call, so read the
805
+ // per-result flag and only fall back to the pool-level capability when a
806
+ // hand-built pool (tests) omits the tag; never coerce legacy rows with
807
+ // the native policy just because the pool reports nativeRaw.
808
+ const native = result.native ?? Boolean(this.capabilities.nativeRaw);
809
+ return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
552
810
  }
553
811
  catch (err) {
554
- this.emit(powql, params, performance.now() - start, 0, err);
812
+ if (!isRetry && this.shouldRetryStaleRead(err, action)) {
813
+ // Transparent single replay on a fresh connection; the first (swallowed)
814
+ // failure is not emitted, only the retried outcome is observed.
815
+ return this.execOnce(powql, params, timeout, action, true);
816
+ }
817
+ this.emit(action, powql, params, performance.now() - start, 0, err);
555
818
  throw err;
556
819
  }
557
820
  }
558
- emit(sql, params, duration, rows, error) {
821
+ /** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
822
+ shouldRetryStaleRead(err, action) {
823
+ if (!this.pool.retryStaleReads)
824
+ return false;
825
+ if (this.isTxScoped())
826
+ return false;
827
+ if (!POWQL_READ_ACTIONS.has(action))
828
+ return false;
829
+ return (0, powdb_js_1.isStaleFramePowdbError)(err);
830
+ }
831
+ emit(action, sql, params, duration, rows, error) {
559
832
  if (!this.onQuery)
560
833
  return;
561
834
  try {
@@ -564,7 +837,7 @@ class PowqlInterface {
564
837
  params,
565
838
  duration,
566
839
  model: this.table,
567
- action: this.currentAction,
840
+ action,
568
841
  rows,
569
842
  timestamp: new Date(),
570
843
  error,
@@ -576,7 +849,6 @@ class PowqlInterface {
576
849
  }
577
850
  /** Run a method body through the middleware chain (mirrors QueryInterface). */
578
851
  async withMiddleware(action, args, executor) {
579
- this.currentAction = action;
580
852
  if (this.middlewares.length === 0)
581
853
  return executor();
582
854
  let index = 0;
@@ -587,34 +859,48 @@ class PowqlInterface {
587
859
  };
588
860
  return next({ model: this.table, action, args: { ...args } });
589
861
  }
590
- /** Map raw rows to typed entities. */
591
- shape(rows) {
592
- return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta));
862
+ /** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
863
+ * this result (threaded from {@link execOnce}, not the pool-level capability),
864
+ * so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
865
+ * string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
866
+ * to null) while a per-call legacy fallback on a native-capable pool still
867
+ * coerces its string cells correctly. Defaults to the pool capability for the
868
+ * rare caller with no per-result flag (hand-built test pools). */
869
+ shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
870
+ return rows.map((r) => (0, powdb_js_1.rowToEntity)(r, this.meta, native));
593
871
  }
594
872
  // -------------------------------------------------------------------------
595
873
  // Reads
596
874
  // -------------------------------------------------------------------------
597
875
  async findMany(args = {}) {
598
876
  return this.withMiddleware('findMany', args, async () => {
599
- const rows = await this.runFind(args);
600
- const entities = this.shape(rows);
601
- if (args.with)
602
- await this.loadRelations(entities, args.with, args.timeout);
877
+ const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
878
+ const entities = this.shape(rows, native);
879
+ if (args.with) {
880
+ await this.loadRelations(entities, args.with, args.timeout, 0, {
881
+ args,
882
+ resolvedWhere,
883
+ });
884
+ }
603
885
  return entities;
604
886
  });
605
887
  }
606
- /** Build + run the flat findMany select; returns raw rows. */
607
- async runFind(args) {
888
+ /**
889
+ * Compile the flat findMany select into PowQL (no execution), pushing values
890
+ * into `params`. Returns the query plus the RESOLVED where (relation filters
891
+ * already collapsed to literal in-lists) so the F2 join path can re-emit the
892
+ * exact parent predicate alias-qualified, and so {@link explain} can wrap it.
893
+ */
894
+ async buildFind(args, params) {
608
895
  if (args.cursor) {
609
896
  throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
610
897
  }
611
- const params = [];
612
898
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
613
899
  const where = this.buildWhere(resolvedWhere, params);
614
900
  const cols = this.projectedColumns(args.select, args.omit);
615
901
  const distinct = args.distinct?.length ? ' distinct' : '';
616
902
  const filter = where ? ` filter ${where}` : '';
617
- const order = this.buildOrder(args.orderBy);
903
+ const order = this.buildOrder(args.orderBy, params);
618
904
  const limit = args.limit ?? args.take ?? this.defaultLimit;
619
905
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
620
906
  this.warnedUnlimited = true;
@@ -623,15 +909,45 @@ class PowqlInterface {
623
909
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
624
910
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
625
911
  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;
912
+ return { powql, resolvedWhere };
913
+ }
914
+ /** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
915
+ async runFind(args, action = 'findMany') {
916
+ const params = [];
917
+ const { powql, resolvedWhere } = await this.buildFind(args, params);
918
+ const { rows, native } = await this.exec(powql, params, args.timeout, action);
919
+ return { rows, native, resolvedWhere };
920
+ }
921
+ /**
922
+ * Diagnostic surface: compile the same PowQL {@link findMany} would run for
923
+ * `args` (no cache) and return the engine's plan as one string per line.
924
+ *
925
+ * Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
926
+ * eligible for the stale-read replay. The line content is engine-owned and is
927
+ * NOT covered by semver (match plan node names / tree shape, never exact
928
+ * bytes; mirrors PowDB's own `explain` contract).
929
+ *
930
+ * Does NOT run through the middleware chain: plan text is a diagnostic, not
931
+ * entity rows, and `QueryInterface.explain` deliberately bypasses middleware
932
+ * too, so both engines agree.
933
+ */
934
+ async explain(args = {}) {
935
+ const params = [];
936
+ const { powql } = await this.buildFind(args, params);
937
+ const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
938
+ return rows
939
+ .map((r) => {
940
+ const line = r.plan ?? Object.values(r)[0];
941
+ return line == null ? '' : String(line);
942
+ })
943
+ .filter((line) => line.length > 0);
628
944
  }
629
945
  async findUnique(args) {
630
946
  return this.withMiddleware('findUnique', args, async () => {
631
- const rows = await this.runFind({ ...args, limit: 1 });
947
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
632
948
  if (!rows.length)
633
949
  return null;
634
- const entities = this.shape(rows);
950
+ const entities = this.shape(rows, native);
635
951
  if (args.with)
636
952
  await this.loadRelations(entities, args.with, args.timeout);
637
953
  return entities[0];
@@ -639,10 +955,10 @@ class PowqlInterface {
639
955
  }
640
956
  async findFirst(args = {}) {
641
957
  return this.withMiddleware('findFirst', args, async () => {
642
- const rows = await this.runFind({ ...args, limit: 1 });
958
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
643
959
  if (!rows.length)
644
960
  return null;
645
- const entities = this.shape(rows);
961
+ const entities = this.shape(rows, native);
646
962
  if (args.with)
647
963
  await this.loadRelations(entities, args.with, args.timeout);
648
964
  return entities[0];
@@ -663,19 +979,48 @@ class PowqlInterface {
663
979
  // -------------------------------------------------------------------------
664
980
  // Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
665
981
  // -------------------------------------------------------------------------
666
- /** Load each requested relation for `parents` and attach it onto each row. */
667
- async loadRelations(parents, withClause, timeout, depth = 0) {
982
+ /**
983
+ * Load each requested relation for `parents` and attach it onto each row.
984
+ *
985
+ * `parent` is supplied ONLY by the top-level {@link findMany} (its args +
986
+ * resolved where). When the effective `relationLoadStrategy` resolves to an
987
+ * explicit `'join'` and the pool advertises `serverJoins`, an eligible
988
+ * top-level relation is loaded with a native PowQL join instead of the keyed
989
+ * loaders (F2); everything else (nested `with` levels, ineligible shapes, and
990
+ * the default `'batched'` strategy) keeps the loaders. Output is byte-equal
991
+ * either way (the join reuses the same stitch / shape helpers).
992
+ */
993
+ async loadRelations(parents, withClause, timeout, depth = 0, parent) {
668
994
  if (depth >= 10) {
669
995
  throw new errors_js_1.ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
670
996
  }
671
997
  if (!parents.length)
672
998
  return;
999
+ // The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
1000
+ // or a client config the user set). The serverJoins capability is consulted
1001
+ // PER RELATION below, AFTER joinEligible, so a relation that would have
1002
+ // fallen back to the loaders anyway (paged parent, nested `with`, composite
1003
+ // key, …) never triggers the capability's E017.
1004
+ const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
673
1005
  for (const [relName, opt] of Object.entries(withClause)) {
674
1006
  if (!opt)
675
1007
  continue;
676
1008
  const rel = this.meta.relations[relName];
677
1009
  if (!rel)
678
1010
  throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
1011
+ if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
1012
+ if (this.capabilities.serverJoins) {
1013
+ await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout);
1014
+ continue;
1015
+ }
1016
+ // An otherwise-eligible relation the engine cannot join: a PER-QUERY
1017
+ // `relationLoadStrategy: 'join'` is an explicit request, so throw a typed
1018
+ // E017; a CLIENT-LEVEL default silently falls back to the keyed loaders
1019
+ // (so pointing an existing app at an older engine keeps working).
1020
+ if (parent.args.relationLoadStrategy === 'join') {
1021
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'serverJoins', 'native PowQL relation joins');
1022
+ }
1023
+ }
679
1024
  if (rel.type === 'manyToMany') {
680
1025
  await this.loadManyToMany(parents, rel, relName, opt, timeout);
681
1026
  continue;
@@ -696,23 +1041,49 @@ class PowqlInterface {
696
1041
  const keys = [
697
1042
  ...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
698
1043
  ];
699
- const childByKey = new Map();
1044
+ // The loader buckets children by their correlation column, so that column
1045
+ // MUST be in the fetched projection even when the user's select/omit drops
1046
+ // it. Force it into the fetch here and strip it back off the entities after
1047
+ // stitching (the join path already gets this for free via `__tpk`).
1048
+ const userSelect = options.select;
1049
+ const userOmit = options.omit;
1050
+ const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
1051
+ let fetchOptions = options;
1052
+ if (!fkProjected) {
1053
+ if (userSelect) {
1054
+ fetchOptions = {
1055
+ ...options,
1056
+ select: { ...userSelect, [childKeyField]: true },
1057
+ };
1058
+ }
1059
+ else if (userOmit) {
1060
+ const omitWithoutFk = { ...userOmit };
1061
+ delete omitWithoutFk[childKeyField];
1062
+ fetchOptions = { ...options, omit: omitWithoutFk };
1063
+ }
1064
+ }
700
1065
  // Chunk the key set so a single `in (…)` never exceeds PowDB's
701
- // per-statement param / row limits; merge each chunk's children.
1066
+ // per-statement param / row limits; merge each chunk's children. Keys are
1067
+ // normalized through joinKey (a Date maps to micros, matching the child
1068
+ // cell) so a datetime correlation column stitches instead of silently
1069
+ // returning [].
1070
+ const childByKey = new Map();
702
1071
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
703
1072
  const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
704
1073
  const childWhere = {
705
- ...options.where,
1074
+ ...fetchOptions.where,
706
1075
  [childKeyField]: { in: chunk },
707
1076
  };
708
1077
  const children = (await targetQi.findMany({
709
- ...options,
1078
+ ...fetchOptions,
710
1079
  where: childWhere,
711
1080
  with: options.with,
712
1081
  timeout: options.timeout ?? timeout,
713
1082
  }));
714
1083
  for (const child of children) {
715
- const k = child[childKeyField];
1084
+ const k = this.joinKey(child[childKeyField]);
1085
+ if (k == null)
1086
+ continue;
716
1087
  const bucket = childByKey.get(k);
717
1088
  if (bucket)
718
1089
  bucket.push(child);
@@ -720,10 +1091,18 @@ class PowqlInterface {
720
1091
  childByKey.set(k, [child]);
721
1092
  }
722
1093
  }
1094
+ // Strip the forced correlation column back off if the user excluded it,
1095
+ // so the emitted entities match their select/omit exactly.
1096
+ if (!fkProjected) {
1097
+ for (const bucket of childByKey.values()) {
1098
+ for (const child of bucket)
1099
+ delete child[childKeyField];
1100
+ }
1101
+ }
723
1102
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
724
1103
  for (const parent of parents) {
725
- const k = parent[parentKeyField];
726
- const matches = childByKey.get(k) ?? [];
1104
+ const k = this.joinKey(parent[parentKeyField]);
1105
+ const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
727
1106
  parent[relName] = single ? (matches[0] ?? null) : matches;
728
1107
  }
729
1108
  }
@@ -772,7 +1151,7 @@ class PowqlInterface {
772
1151
  const params = [];
773
1152
  const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
774
1153
  const powql = `${(0, powdb_js_1.quotePowqlIdent)(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
775
- const { rows } = await this.exec(powql, params, timeout);
1154
+ const { rows } = await this.exec(powql, params, timeout, 'findMany');
776
1155
  for (const row of rows) {
777
1156
  const sv = String(row[sourceJCol]);
778
1157
  const tv = String(row[targetJCol]);
@@ -818,6 +1197,257 @@ class PowqlInterface {
818
1197
  }
819
1198
  }
820
1199
  // -------------------------------------------------------------------------
1200
+ // Nested relations: native PowQL joins (F2, opt-in via relationLoadStrategy)
1201
+ // -------------------------------------------------------------------------
1202
+ /**
1203
+ * Resolve the effective relation-load strategy: the per-query arg wins, then
1204
+ * the client config, then the PowDB default of `'batched'` (the keyed
1205
+ * loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
1206
+ * default (that would silently flip every existing PowDB user onto brand-new
1207
+ * join generation). Only a value the user actually set to `'join'` activates it.
1208
+ */
1209
+ resolveStrategy(args) {
1210
+ const s = args.relationLoadStrategy ?? this.options.relationLoadStrategy ?? 'batched';
1211
+ return s === 'join' ? 'join' : 'batched';
1212
+ }
1213
+ /**
1214
+ * Per-relation eligibility for the join path (checked before the serverJoins
1215
+ * capability). Any `false` here is a SILENT fallback to the keyed loaders (it
1216
+ * is never an error), so an off-page or nested-`with` shape still returns
1217
+ * correct rows:
1218
+ * - the parent query must not be paged (`limit`/`offset`/`take`, including the
1219
+ * configured `defaultLimit`): a parent-filter join under a page would scan
1220
+ * children of off-page parents, where the loaders are strictly better;
1221
+ * - the relation must not request a nested `with` (its subtree stays on the
1222
+ * loaders this round) or a `distinct`;
1223
+ * - single-column relation keys only (a composite key falls to the loader,
1224
+ * which throws the same E017 as today);
1225
+ * - the PARENT-SIDE correlation column must be a single-column PK or unique
1226
+ * column, or the INNER join would re-emit one child copy per matching
1227
+ * parent row (a non-unique correlation key produces duplicate children the
1228
+ * loader never would). For hasMany/hasOne/m2m that column is the relation's
1229
+ * `referenceKey` on THIS (fetched) table; for belongsTo it is the
1230
+ * `referenceKey` on the TARGET table (the join's non-fetched side);
1231
+ * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
1232
+ * stitch can't be reproduced by the 3-table join deterministically);
1233
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
1234
+ * does a to-many relation `limit`/`offset` when the parent set spills past
1235
+ * one loader chunk (the loader limits per chunk, the join once globally).
1236
+ */
1237
+ joinEligible(rel, opt, args, parentCount) {
1238
+ const effLimit = args.limit ?? args.take ?? this.defaultLimit;
1239
+ if (effLimit !== undefined || args.offset)
1240
+ return false;
1241
+ const options = (opt === true ? {} : opt);
1242
+ if (options.with)
1243
+ return false;
1244
+ if (options.distinct?.length)
1245
+ return false;
1246
+ if (rel.type === 'manyToMany') {
1247
+ const through = rel.through;
1248
+ if (!through)
1249
+ return false;
1250
+ if ((0, schema_js_1.normalizeKeyColumns)(through.sourceKey).length > 1 ||
1251
+ (0, schema_js_1.normalizeKeyColumns)(through.targetKey).length > 1 ||
1252
+ (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1 ||
1253
+ (this.schema.tables[rel.to]?.primaryKey.length ?? 2) > 1) {
1254
+ return false;
1255
+ }
1256
+ if (options.orderBy || options.limit !== undefined || options.offset)
1257
+ return false;
1258
+ // The parent joins on its referenceKey; a non-unique one duplicates.
1259
+ if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0]))
1260
+ return false;
1261
+ return true;
1262
+ }
1263
+ if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length > 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1) {
1264
+ return false;
1265
+ }
1266
+ // Reject a non-unique correlation key: on belongsTo the fetched side joins on
1267
+ // the target's referenceKey, otherwise the fetched side joins on its own.
1268
+ if (rel.type === 'belongsTo') {
1269
+ const targetMeta = this.schema.tables[rel.to];
1270
+ if (!targetMeta || !this.isSingleColumnUnique(targetMeta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
1271
+ return false;
1272
+ }
1273
+ }
1274
+ else if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
1275
+ return false;
1276
+ }
1277
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1278
+ if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
1279
+ return false;
1280
+ }
1281
+ return true;
1282
+ }
1283
+ /**
1284
+ * True when `col` is a single-column unique key of `tableMeta`: the sole
1285
+ * primary-key column, a single-column entry in `uniqueColumns` (where a
1286
+ * per-column `unique: true` and an introspected single-column unique constraint
1287
+ * both land), or a single-column unique index. Used by {@link joinEligible} to
1288
+ * keep the INNER-join path off relations whose parent-side correlation column
1289
+ * can repeat (which would duplicate children).
1290
+ */
1291
+ isSingleColumnUnique(tableMeta, col) {
1292
+ if (tableMeta.primaryKey.length === 1 && tableMeta.primaryKey[0] === col)
1293
+ return true;
1294
+ if (tableMeta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === col))
1295
+ return true;
1296
+ return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
1297
+ }
1298
+ /** Dispatch one eligible relation to the correct native-join loader. */
1299
+ async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout) {
1300
+ if (rel.type === 'manyToMany') {
1301
+ await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout);
1302
+ return;
1303
+ }
1304
+ const options = (opt === true ? {} : opt);
1305
+ const targetMeta = this.schema.tables[rel.to];
1306
+ if (!targetMeta)
1307
+ throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
1308
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1309
+ const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
1310
+ const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
1311
+ // Correlation math is identical to the keyed loaders, only the transport
1312
+ // (join vs in-list) changes. Always join the RELATION TARGET (alias `c`) to
1313
+ // the already-fetched side (alias `p`), correlating on the fetched side's key
1314
+ // and projecting `__tpk` from the fetched side's correlation column.
1315
+ const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
1316
+ const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
1317
+ const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
1318
+ const params = [];
1319
+ const childCols = this.joinChildCols(targetQi, options);
1320
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
1321
+ const order = targetQi.buildOrder(options.orderBy, params, 'c');
1322
+ const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
1323
+ const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
1324
+ const proj = this.joinProjection(childCols, `p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}`, 'c');
1325
+ const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
1326
+ `on c.${(0, powdb_js_1.quotePowqlIdent)(childKeyCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}` +
1327
+ `${filter}${order}${limitClause}${offsetClause} ${proj}`;
1328
+ // A READ: thread a read-shaped action through the exec seam.
1329
+ const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
1330
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1331
+ const byKey = this.bucketByTpk(targetQi, rows, native);
1332
+ for (const p of parents) {
1333
+ const key = this.joinKey(p[parentKeyField]);
1334
+ const matches = (key == null ? undefined : byKey.get(key)) ?? [];
1335
+ p[relName] = single ? (matches[0] ?? null) : matches;
1336
+ }
1337
+ }
1338
+ /**
1339
+ * manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
1340
+ * → the already-fetched side (alias `p`), correlating `__tpk` from the
1341
+ * junction's source key. Always a list, stitched exactly like the loader.
1342
+ */
1343
+ async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout) {
1344
+ const through = rel.through;
1345
+ if (!through)
1346
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
1347
+ const options = (opt === true ? {} : opt);
1348
+ const targetMeta = this.schema.tables[rel.to];
1349
+ if (!targetMeta)
1350
+ throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
1351
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1352
+ const sourceJCol = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey)[0];
1353
+ const targetJCol = (0, schema_js_1.normalizeKeyColumns)(through.targetKey)[0];
1354
+ const sourceRefCol = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
1355
+ const targetPkCol = targetMeta.primaryKey[0];
1356
+ const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
1357
+ const params = [];
1358
+ const childCols = this.joinChildCols(targetQi, options);
1359
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
1360
+ const proj = this.joinProjection(childCols, `j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)}`, 't');
1361
+ const powql = `${targetQi.qt} as t ` +
1362
+ `join ${(0, powdb_js_1.quotePowqlIdent)(through.table)} as j on t.${(0, powdb_js_1.quotePowqlIdent)(targetPkCol)} = j.${(0, powdb_js_1.quotePowqlIdent)(targetJCol)} ` +
1363
+ `join ${this.qt} as p on j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(sourceRefCol)}` +
1364
+ `${filter} ${proj}`;
1365
+ const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
1366
+ const byKey = this.bucketByTpk(targetQi, rows, native);
1367
+ for (const p of parents) {
1368
+ const key = this.joinKey(p[parentRefField]);
1369
+ p[relName] = (key == null ? undefined : byKey.get(key)) ?? [];
1370
+ }
1371
+ }
1372
+ /**
1373
+ * The target column list to project through the join (honouring select/omit),
1374
+ * with a loud guard: a real column named `__tpk` would collide with the
1375
+ * reserved correlation alias, so refuse rather than silently mis-stitch.
1376
+ */
1377
+ joinChildCols(targetQi, options) {
1378
+ const cols = targetQi.projectedColumns(options.select, options.omit);
1379
+ if (cols.includes('__tpk')) {
1380
+ throw new errors_js_1.ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
1381
+ `join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
1382
+ }
1383
+ return cols;
1384
+ }
1385
+ /**
1386
+ * `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
1387
+ * ALIASED to its bare name (a bare qualified ref `c.col` would come back named
1388
+ * `c.col`, not `col`) so the stitched rows shape identically to a flat select.
1389
+ */
1390
+ joinProjection(childCols, tpkExpr, childAlias) {
1391
+ const parts = [
1392
+ `__tpk: ${tpkExpr}`,
1393
+ ...childCols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: ${childAlias}.${(0, powdb_js_1.quotePowqlIdent)(c)}`),
1394
+ ];
1395
+ return `{ ${parts.join(', ')} }`;
1396
+ }
1397
+ /**
1398
+ * `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
1399
+ * The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
1400
+ * to literal in-lists before the base query ran); the relation where is resolved
1401
+ * on the target the same way before qualifying, so a nested relation filter in
1402
+ * the relation `where` never reaches the join unresolved. Params bind in order.
1403
+ */
1404
+ async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
1405
+ const parts = [];
1406
+ const pw = this.buildWhere(parentResolvedWhere, params, 'p');
1407
+ if (pw)
1408
+ parts.push(pw);
1409
+ const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
1410
+ const rw = targetQi.buildWhere(relResolved, params, childAlias);
1411
+ if (rw)
1412
+ parts.push(rw);
1413
+ return parts.length ? ` filter ${parts.join(' and ')}` : '';
1414
+ }
1415
+ /** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
1416
+ bucketByTpk(targetQi, rows, native) {
1417
+ const byKey = new Map();
1418
+ for (const raw of rows) {
1419
+ const tpk = this.joinKey(raw.__tpk);
1420
+ delete raw.__tpk;
1421
+ const child = targetQi.shape([raw], native)[0];
1422
+ if (tpk == null)
1423
+ continue;
1424
+ const bucket = byKey.get(tpk);
1425
+ if (bucket)
1426
+ bucket.push(child);
1427
+ else
1428
+ byKey.set(tpk, [child]);
1429
+ }
1430
+ return byKey;
1431
+ }
1432
+ /**
1433
+ * Normalize a correlation key to a stable string map key so a parent's key
1434
+ * value (a shaped entity field) and a child row's `__tpk` cell match across
1435
+ * wires and column types. A `Date` maps to microseconds
1436
+ * (`getTime()` ms times 1000), because a datetime correlation cell arrives as
1437
+ * raw micros (bigint on the native wire, a micros string on the legacy wire),
1438
+ * never as ms. bigint / number / string all stringify to the same digits, so
1439
+ * an int key matches whether it came back typed or as text.
1440
+ */
1441
+ joinKey(v) {
1442
+ if (v == null)
1443
+ return null;
1444
+ if (v instanceof Date)
1445
+ return (BigInt(v.getTime()) * 1000n).toString();
1446
+ if (typeof v === 'bigint')
1447
+ return v.toString();
1448
+ return String(v);
1449
+ }
1450
+ // -------------------------------------------------------------------------
821
1451
  // Writes (reselect — PowDB has no RETURNING)
822
1452
  // -------------------------------------------------------------------------
823
1453
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
@@ -873,8 +1503,8 @@ class PowqlInterface {
873
1503
  .map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
874
1504
  .join(', ');
875
1505
  // `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;
1506
+ const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
1507
+ const row = rows.length ? this.shape(rows, native)[0] : null;
878
1508
  if (!row)
879
1509
  throw new errors_js_1.NotFoundError({ table: this.table, where: data });
880
1510
  return row;
@@ -891,8 +1521,8 @@ class PowqlInterface {
891
1521
  return `{ ${assigns.map((a) => `${(0, powdb_js_1.quotePowqlIdent)(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
892
1522
  });
893
1523
  // 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);
1524
+ const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
1525
+ return this.shape(rows, native);
896
1526
  });
897
1527
  }
898
1528
  async update(args) {
@@ -906,8 +1536,8 @@ class PowqlInterface {
906
1536
  this.assertCompiledWhere(where, false, 'update');
907
1537
  const setClause = this.buildUpdateAssignments(args.data, params);
908
1538
  // `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;
1539
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
1540
+ const row = rows.length ? this.shape(rows, native)[0] : null;
911
1541
  if (!row)
912
1542
  throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
913
1543
  return row;
@@ -921,7 +1551,7 @@ class PowqlInterface {
921
1551
  this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
922
1552
  const setClause = this.buildUpdateAssignments(args.data, params);
923
1553
  const filter = where ? ` filter ${where}` : '';
924
- const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
1554
+ const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
925
1555
  return { count: rowCount };
926
1556
  });
927
1557
  }
@@ -990,6 +1620,11 @@ class PowqlInterface {
990
1620
  }
991
1621
  /** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
992
1622
  async runInImplicitTx(fn) {
1623
+ // A transaction-control `begin` is a write on a read-only pool: refuse it
1624
+ // locally before checking out a connection (zero wire / pool activity), the
1625
+ // same guard the exec seam applies to plain writes.
1626
+ if (this.pool.readonly === true)
1627
+ throw this.readOnlyError('transaction (begin)');
993
1628
  // Route tx keywords through the dialect (like the SQL path) so this never
994
1629
  // drifts from `powdbDialect`; falls back to the literal lowercase keywords.
995
1630
  const d = this.options.dialect;
@@ -999,7 +1634,10 @@ class PowqlInterface {
999
1634
  await client.query(d?.beginStatement?.() ?? 'begin');
1000
1635
  began = true;
1001
1636
  const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('./client.js')));
1002
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
1637
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options,
1638
+ // Pass the PowDB pool so its read-only guard + capabilities carry into
1639
+ // the transaction-scoped proxy pool (see createTxPool).
1640
+ this.pool);
1003
1641
  const ctx = { schema: this.schema, tx: tx };
1004
1642
  // Plant the single-writer re-entrancy marker for the implicit tx's
1005
1643
  // subtree (same seam TurbineClient.$transaction uses) — user code that
@@ -1044,8 +1682,8 @@ class PowqlInterface {
1044
1682
  const where = this.buildWhere(resolvedWhere, params);
1045
1683
  this.assertCompiledWhere(where, false, 'delete');
1046
1684
  // `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;
1685
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
1686
+ const row = rows.length ? this.shape(rows, native)[0] : null;
1049
1687
  if (!row)
1050
1688
  throw new errors_js_1.NotFoundError({ table: this.table, where: args.where });
1051
1689
  return row;
@@ -1058,7 +1696,7 @@ class PowqlInterface {
1058
1696
  const where = this.buildWhere(resolvedWhere, params);
1059
1697
  this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
1060
1698
  const filter = where ? ` filter ${where}` : '';
1061
- const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
1699
+ const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
1062
1700
  return { count: rowCount };
1063
1701
  });
1064
1702
  }
@@ -1080,7 +1718,7 @@ class PowqlInterface {
1080
1718
  // (verified: "unexpected trailing token … 'returning'"), because it is one
1081
1719
  // atomic insert-or-update, not two branches. So upsert alone keeps the
1082
1720
  // 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);
1721
+ await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
1084
1722
  const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
1085
1723
  const row = await this.reselectByPk(createData[pkField], args.timeout);
1086
1724
  if (!row)
@@ -1125,7 +1763,7 @@ class PowqlInterface {
1125
1763
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1126
1764
  const where = this.buildWhere(resolvedWhere, params);
1127
1765
  const filter = where ? ` filter ${where}` : '';
1128
- const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
1766
+ const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
1129
1767
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
1130
1768
  });
1131
1769
  }
@@ -1139,7 +1777,7 @@ class PowqlInterface {
1139
1777
  const filter = where ? ` filter ${where}` : '';
1140
1778
  const scalar = async (expr) => {
1141
1779
  const params = [...filterParams];
1142
- const { rows } = await this.exec(expr, params, args.timeout);
1780
+ const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
1143
1781
  const v = rows[0]?.value;
1144
1782
  return v == null || v === 'null' ? null : Number(v);
1145
1783
  };
@@ -1171,90 +1809,196 @@ class PowqlInterface {
1171
1809
  }
1172
1810
  async groupBy(args) {
1173
1811
  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.
1812
+ // DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
1177
1813
  if (args.distinctOn) {
1178
1814
  throw new errors_js_1.UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
1179
1815
  }
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
- }
1816
+ // JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
1817
+ const usesJson = args.by.some((e) => typeof e !== 'string') ||
1818
+ ['_sum', '_avg', '_min', '_max'].some((fn) => {
1819
+ const spec = args[fn];
1820
+ return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
1821
+ });
1822
+ if (usesJson) {
1823
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
1194
1824
  }
1825
+ // `emitNative` decides whether the query GENERATION needs the legacy-wire
1826
+ // `json_type` discriminator (pool-level capability). The DECODE side reads
1827
+ // the wire that ACTUALLY served the result (`resultNative`, from exec), so
1828
+ // a per-call legacy fallback on a native-capable pool still decodes right.
1829
+ const emitNative = Boolean(this.capabilities.nativeRaw);
1195
1830
  const params = [];
1196
1831
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1197
1832
  const where = this.buildWhere(resolvedWhere, params);
1198
1833
  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' });
1834
+ // Result-key namespace, mirroring the SQL builder's `claimResultKey`
1835
+ // (query/builder.ts): a group-key / aggregate output-name collision (with
1836
+ // `_count`, another key, or an aggregate output) throws E003.
1837
+ const usedKeys = new Set();
1838
+ const claim = (key, what) => {
1839
+ if (key === '_count' || usedKeys.has(key)) {
1840
+ throw new errors_js_1.ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1841
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1842
+ }
1843
+ usedKeys.add(key);
1844
+ };
1845
+ const groupExprs = [];
1846
+ const proj = [];
1847
+ const byOrderExprs = new Map();
1848
+ const byReaders = [];
1849
+ let gkN = 0;
1850
+ let gtN = 0;
1851
+ for (const entry of args.by) {
1852
+ if (typeof entry === 'string') {
1853
+ const col = this.column(entry);
1854
+ claim(entry, `column "${col.name}"`);
1855
+ if (col.name !== entry)
1856
+ claim(col.name, `column "${col.name}"`);
1857
+ groupExprs.push(`.${col.name}`);
1858
+ proj.push(`.${col.name}`);
1859
+ byOrderExprs.set(entry, `.${col.name}`);
1860
+ byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
1861
+ }
1862
+ else {
1863
+ const col = this.column(entry.field);
1864
+ if (!(0, powdb_js_1.isJsonColumn)(col)) {
1865
+ throw new errors_js_1.ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
1866
+ }
1867
+ this.assertJsonPath('group key', entry.field, entry.path);
1868
+ const pathExpr = this.jsonPathExpr(col, entry.path, params);
1869
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1870
+ claim(alias, `JSON path on "${entry.field}"`);
1871
+ const gkAlias = `gk_${gkN++}`;
1872
+ groupExprs.push(pathExpr);
1873
+ proj.push(`${gkAlias}: ${pathExpr}`);
1874
+ byOrderExprs.set(alias, `.${gkAlias}`);
1875
+ let discrim;
1876
+ if (!emitNative) {
1877
+ // Legacy wire renders a missing value, JSON null, AND the string
1878
+ // "null" all as the cell "null". `min(json_type(path))` over the
1879
+ // group is "string" ONLY for the string-"null" group and "null"
1880
+ // otherwise (a bare `json_type` projection is not group-correlated).
1881
+ discrim = `gt_${gtN++}`;
1882
+ proj.push(`${discrim}: min(json_type(${pathExpr}))`);
1883
+ }
1884
+ byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
1885
+ }
1886
+ }
1887
+ // Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
1888
+ // aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
1889
+ // expression by user key; `aggOrderExprs` lets orderBy reference the alias.
1890
+ const aggReaders = [];
1891
+ const aggOrderExprs = new Map();
1892
+ const aggInner = new Map();
1893
+ let aggN = 0;
1894
+ // Parity with the SQL builder (query/builder.ts): `_count` is selected by
1895
+ // DEFAULT unless the caller explicitly opts out with `_count: false`, so
1896
+ // every groupBy row carries `_count` and `orderBy: { _count }` works
1897
+ // without requesting it (the alias is seeded into `aggOrderExprs`).
1898
+ const countSelected = args._count === true || args._count === undefined;
1899
+ if (countSelected) {
1900
+ const alias = `agg_${aggN++}`;
1901
+ proj.push(`${alias}: count(*)`);
1902
+ aggReaders.push({ alias, outKey: '_count', numeric: true });
1903
+ aggOrderExprs.set('_count', `.${alias}`);
1206
1904
  }
1207
1905
  for (const fn of ['_sum', '_avg', '_min', '_max']) {
1208
1906
  const spec = args[fn];
1209
1907
  if (!spec)
1210
1908
  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}"`);
1909
+ const powfn = fn.slice(1); // sum/avg/min/max
1910
+ for (const [key, target] of Object.entries(spec)) {
1911
+ if (!target)
1912
+ continue;
1913
+ const alias = `agg_${aggN++}`;
1914
+ if (target === true) {
1915
+ const col = this.column(key);
1916
+ claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
1917
+ const inner = `.${col.name}`;
1918
+ proj.push(`${alias}: ${powfn}(${inner})`);
1919
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
1920
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1921
+ aggInner.set(key, inner);
1922
+ }
1923
+ else {
1924
+ const col = this.column(target.field);
1925
+ if (!(0, powdb_js_1.isJsonColumn)(col)) {
1926
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
1927
+ }
1928
+ this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
1929
+ const alwaysNumeric = fn === '_sum' || fn === '_avg';
1930
+ if (alwaysNumeric && target.type === 'text') {
1931
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
1932
+ `${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1933
+ }
1934
+ const numeric = alwaysNumeric || target.type === 'numeric';
1935
+ claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
1936
+ const pathExpr = this.jsonPathExpr(col, target.path, params);
1937
+ const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
1938
+ proj.push(`${alias}: ${powfn}(${inner})`);
1939
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
1940
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1941
+ aggInner.set(key, inner);
1227
1942
  }
1228
1943
  }
1229
1944
  }
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}}.
1945
+ const having = this.buildHaving(args.having, params, aggInner);
1946
+ const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
1947
+ const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1948
+ const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
1949
+ // Reshape: group keys → user fields (coerced / null-disambiguated),
1950
+ // aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
1951
+ // Group-key cells go through the SAME coercion policy `rowToEntity` uses,
1952
+ // by the wire that actually served this result, so a native-wire int cell
1953
+ // (bigint) or datetime cell (micros) never leaks into the result: it
1954
+ // becomes the same number / Date / PG-text-parity string an embedded /
1955
+ // legacy / SQL groupBy returns for the identical query.
1234
1956
  return rows.map((raw) => {
1235
1957
  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];
1958
+ for (const r of byReaders) {
1959
+ if (r.kind === 'plain') {
1960
+ const cell = raw[r.rowKey];
1961
+ out[r.resultKey] = resultNative
1962
+ ? (0, powdb_js_1.coerceNativeValue)(cell, r.col)
1963
+ : typeof cell === 'string'
1964
+ ? coerceScalar(cell, r.col.tsType)
1965
+ : cell;
1966
+ }
1967
+ else {
1968
+ out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
1969
+ }
1240
1970
  }
1241
- for (const a of aliasMap) {
1242
- const val = raw[a.alias];
1243
- const num = val == null || val === 'null' ? null : Number(val);
1971
+ for (const a of aggReaders) {
1972
+ const cell = raw[a.alias];
1973
+ const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
1244
1974
  if (a.outKey === '_count')
1245
- out._count = num ?? 0;
1975
+ out._count = v ?? 0;
1246
1976
  else {
1247
1977
  const [bucket, field] = a.outKey.split(':');
1248
1978
  out[bucket] ??= {};
1249
- out[bucket][field] = num;
1979
+ out[bucket][field] = v;
1250
1980
  }
1251
1981
  }
1252
1982
  return out;
1253
1983
  });
1254
1984
  });
1255
1985
  }
1256
- /** `having <expr>` over group aggregates (count/sum/avg/min/max). */
1257
- buildHaving(having, params) {
1986
+ /** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
1987
+ assertJsonPath(context, field, path) {
1988
+ if (!Array.isArray(path) ||
1989
+ path.length === 0 ||
1990
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
1991
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
1992
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
1993
+ }
1994
+ }
1995
+ /**
1996
+ * `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
1997
+ * with the projection); a per-field aggregate re-emits its inner expression
1998
+ * (from `aggInner` when the field is a requested aggregate, so a JSON-path
1999
+ * aggregate reuses its bound placeholders, else `.field` for a plain column).
2000
+ */
2001
+ buildHaving(having, params, aggInner) {
1258
2002
  if (!having)
1259
2003
  return '';
1260
2004
  const conds = [];
@@ -1272,18 +2016,88 @@ class PowqlInterface {
1272
2016
  if (spec == null)
1273
2017
  continue;
1274
2018
  if (key === '_count') {
1275
- conds.push(cmp(`count(.${this.meta.primaryKey[0]})`, spec));
2019
+ conds.push(cmp('count(*)', spec));
1276
2020
  }
1277
2021
  else {
1278
2022
  for (const [fn, filter] of Object.entries(spec)) {
1279
2023
  if (filter == null)
1280
2024
  continue;
1281
- conds.push(cmp(`${fn.slice(1)}(${this.ref(key)})`, filter));
2025
+ const inner = aggInner.get(key) ?? this.ref(key);
2026
+ conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
1282
2027
  }
1283
2028
  }
1284
2029
  }
1285
2030
  return conds.length ? ` having ${conds.join(' and ')}` : '';
1286
2031
  }
2032
+ /**
2033
+ * Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
2034
+ * columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
2035
+ * cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
2036
+ * order by a projection alias on a grouped query (probed), so each key maps to
2037
+ * its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
2038
+ * surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
2039
+ * requested in this call, or an unknown by-key, throws E003 listing the valid
2040
+ * keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
2041
+ */
2042
+ buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
2043
+ if (!orderBy)
2044
+ return '';
2045
+ const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
2046
+ const validKeys = () => {
2047
+ const keys = [...byOrderExprs.keys()];
2048
+ for (const k of aggOrderExprs.keys())
2049
+ keys.push(k.includes(':') ? k.replace(':', '.') : k);
2050
+ return keys.join(', ') || '(none)';
2051
+ };
2052
+ const parts = [];
2053
+ for (const [key, value] of Object.entries(orderBy)) {
2054
+ if (value === undefined)
2055
+ continue;
2056
+ if (aggBlocks.has(key)) {
2057
+ if (key === '_count') {
2058
+ const expr = aggOrderExprs.get('_count');
2059
+ if (!expr) {
2060
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
2061
+ `Orderable keys: ${validKeys()}.`);
2062
+ }
2063
+ parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
2064
+ continue;
2065
+ }
2066
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
2067
+ throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
2068
+ `expected a field map like { ${key}: { amount: 'desc' } }.`);
2069
+ }
2070
+ for (const [field, dirSpec] of Object.entries(value)) {
2071
+ if (dirSpec === undefined)
2072
+ continue;
2073
+ const expr = aggOrderExprs.get(`${key}:${field}`);
2074
+ if (!expr) {
2075
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
2076
+ `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
2077
+ }
2078
+ parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
2079
+ }
2080
+ continue;
2081
+ }
2082
+ const expr = byOrderExprs.get(key);
2083
+ if (!expr) {
2084
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
2085
+ }
2086
+ parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
2087
+ }
2088
+ return parts.length ? ` order ${parts.join(', ')}` : '';
2089
+ }
2090
+ /** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
2091
+ groupOrderDir(value, keyForMsg) {
2092
+ if (value !== null && typeof value === 'object') {
2093
+ const spec = value;
2094
+ if (spec.nulls === 'first') {
2095
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
2096
+ }
2097
+ return spec.sort === 'desc' ? 'desc' : 'asc';
2098
+ }
2099
+ return value === 'desc' ? 'desc' : 'asc';
2100
+ }
1287
2101
  // -------------------------------------------------------------------------
1288
2102
  // Streaming / unsupported
1289
2103
  // -------------------------------------------------------------------------
@@ -1297,12 +2111,12 @@ class PowqlInterface {
1297
2111
  /** Reselect a single row by its single-column primary key value. */
1298
2112
  async reselectByPk(pkValue, timeout) {
1299
2113
  const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
1300
- const rows = await this.runFind({
2114
+ const { rows, native } = await this.runFind({
1301
2115
  where: { [pkField]: pkValue },
1302
2116
  limit: 1,
1303
2117
  timeout,
1304
2118
  });
1305
- return rows.length ? this.shape(rows)[0] : null;
2119
+ return rows.length ? this.shape(rows, native)[0] : null;
1306
2120
  }
1307
2121
  /**
1308
2122
  * Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
@@ -1338,3 +2152,53 @@ function coerceScalar(raw, tsType) {
1338
2152
  return new Date(Number(raw) / 1000);
1339
2153
  return raw;
1340
2154
  }
2155
+ /**
2156
+ * Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
2157
+ * normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
2158
+ *
2159
+ * On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
2160
+ * `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
2161
+ * would diverge from every other transport: the embedded / legacy / SQL wire
2162
+ * all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
2163
+ * on `JSON.stringify`. So a native scalar cell is rendered to its text form
2164
+ * ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
2165
+ * `"null"` stays the string (the wart the native wire was adopted to fix).
2166
+ *
2167
+ * On the legacy string wire a missing value, JSON null, AND the string `"null"`
2168
+ * all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
2169
+ * `"string"` ONLY for the string-`"null"` group, so the cell is the string
2170
+ * `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
2171
+ * pass through as the extracted string.
2172
+ */
2173
+ function decodeGroupKeyCell(cell, discrim, native) {
2174
+ if (native)
2175
+ return nativeJsonKeyText(cell);
2176
+ if (cell == null)
2177
+ return null;
2178
+ if (cell === 'null')
2179
+ return discrim === 'string' ? 'null' : null;
2180
+ return cell;
2181
+ }
2182
+ /**
2183
+ * Render a native-wire JSON group-key cell to the extracted-text shape the other
2184
+ * transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
2185
+ * `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
2186
+ * string form; an object/array json sub-document is stringified (best-effort
2187
+ * parity: canonical byte-for-byte matching is not guaranteed for nested docs).
2188
+ */
2189
+ function nativeJsonKeyText(cell) {
2190
+ if (cell === null || cell === undefined)
2191
+ return null;
2192
+ if (typeof cell === 'bigint')
2193
+ return cell.toString();
2194
+ if (typeof cell === 'number' || typeof cell === 'boolean')
2195
+ return String(cell);
2196
+ if (typeof cell === 'string')
2197
+ return cell;
2198
+ try {
2199
+ return JSON.stringify(cell);
2200
+ }
2201
+ catch {
2202
+ return String(cell);
2203
+ }
2204
+ }