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/powql.js CHANGED
@@ -35,10 +35,10 @@
35
35
  * @module
36
36
  */
37
37
  import { randomUUID } from 'node:crypto';
38
- import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
38
+ import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
- import { PowdbFloatParam, powqlColumnType, quotePowqlIdent, rowToEntity } from './powdb.js';
41
- import { isRelationPickOrderBy } from './query/filters.js';
40
+ import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
+ import { isJsonFilter, isRelationPickOrderBy } from './query/filters.js';
42
42
  import { escapeLike } from './query/utils.js';
43
43
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
44
44
  /**
@@ -48,6 +48,38 @@ import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
48
48
  * before grouping. Mirrors the chunking the parity matrix documents.
49
49
  */
50
50
  const MAX_RELATION_KEYS = 1000;
51
+ /**
52
+ * Read-shaped actions whose statement may be transparently replayed once on a
53
+ * stale wire frame when `retryStaleReads` is enabled (see
54
+ * {@link PowqlInterface.execOnce}). Writes are deliberately absent: replaying a
55
+ * mutation after an ambiguous reply can double-execute, matching the client's
56
+ * own native-path policy.
57
+ */
58
+ const POWQL_READ_ACTIONS = new Set([
59
+ 'findMany',
60
+ 'findUnique',
61
+ 'findFirst',
62
+ 'count',
63
+ 'aggregate',
64
+ 'groupBy',
65
+ 'explain',
66
+ ]);
67
+ /**
68
+ * Mutating actions the {@link PowqlInterface} readonly guard refuses locally
69
+ * (before the wire) on a read-only pool. A transaction-control `begin` is
70
+ * guarded separately in {@link PowqlInterface.runInImplicitTx}. Kept keyed on
71
+ * the per-call action string (never `this`-state) so a concurrent read can
72
+ * never be mistaken for one of these.
73
+ */
74
+ const POWQL_WRITE_ACTIONS = new Set([
75
+ 'create',
76
+ 'createMany',
77
+ 'update',
78
+ 'updateMany',
79
+ 'delete',
80
+ 'deleteMany',
81
+ 'upsert',
82
+ ]);
51
83
  /** Operator keys recognised inside a `WhereOperator` object. */
52
84
  const OPERATOR_KEYS = new Set([
53
85
  'equals',
@@ -93,7 +125,6 @@ export class PowqlInterface {
93
125
  defaultLimit;
94
126
  warnOnUnlimited;
95
127
  onQuery;
96
- currentAction = 'raw';
97
128
  warnedUnlimited = false;
98
129
  constructor(pool, table, schema, middlewares = [], options = {}) {
99
130
  this.pool = pool;
@@ -131,9 +162,19 @@ export class PowqlInterface {
131
162
  }
132
163
  return col;
133
164
  }
134
- /** PowQL column reference (`.snake_name`) for a field. */
135
- ref(field) {
136
- return `.${this.column(field).name}`;
165
+ /**
166
+ * PowQL column reference for a field. Unqualified it is a dotted field
167
+ * reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
168
+ * is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
169
+ * column name is backtick-quoted if it is a reserved word (a qualified
170
+ * `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
171
+ */
172
+ ref(field, alias) {
173
+ return this.colRefName(this.column(field).name, alias);
174
+ }
175
+ /** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
176
+ colRefName(name, alias) {
177
+ return alias ? `${alias}.${quotePowqlIdent(name)}` : `.${name}`;
137
178
  }
138
179
  /**
139
180
  * Push a value into the param array and return its `$N` placeholder. When the
@@ -144,7 +185,17 @@ export class PowqlInterface {
144
185
  * {@link toPowdbParam}, so the wire param is unchanged.
145
186
  */
146
187
  param(value, params, col) {
147
- const tagged = col && typeof value === 'number' && this.isFloatCol(col) ? new PowdbFloatParam(value) : value;
188
+ let tagged = value;
189
+ if (col && typeof value === 'number' && this.isFloatCol(col)) {
190
+ // Float column: force a float-form literal even for an integer value.
191
+ tagged = new PowdbFloatParam(value);
192
+ }
193
+ else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && isJsonColumn(col)) {
194
+ // json document column: a JS object/array is serialized to canonical JSON
195
+ // text and stored as a json document (a JS string passes through raw, same
196
+ // contract as pg jsonb; `null` stays `null`).
197
+ tagged = new PowdbJsonParam(value);
198
+ }
148
199
  params.push(tagged);
149
200
  return `$${params.length}`;
150
201
  }
@@ -172,6 +223,15 @@ export class PowqlInterface {
172
223
  return false;
173
224
  }
174
225
  }
226
+ /**
227
+ * The bound pool's {@link PowdbCapabilities}. Falls back to the trusted-caller
228
+ * default (all feature gates on, `nativeRaw` off) when a directly-constructed
229
+ * pool did not carry them, matching {@link PowdbPool}'s own constructor
230
+ * default so a hand-built test pool never crashes the version gates.
231
+ */
232
+ get capabilities() {
233
+ return this.pool.capabilities ?? ALL_POWDB_CAPABILITIES;
234
+ }
175
235
  /** A predicate that is always false — the empty-`in` / contradiction sentinel. */
176
236
  alwaysFalse() {
177
237
  const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
@@ -183,8 +243,15 @@ export class PowqlInterface {
183
243
  /**
184
244
  * Compile a {@link WhereClause} into a PowQL filter expression, pushing every
185
245
  * value as a positional `$N` param. Returns `''` when there are no conditions.
246
+ *
247
+ * When `alias` is supplied (the F2 native-join path) every field reference is
248
+ * qualified with it (`.col` → `alias.col`, JSON path bases too); params bind
249
+ * exactly as in the unqualified path. The caller only ever passes an alias for
250
+ * an already-RESOLVED where (relation filters pre-resolved to literal in-lists
251
+ * by {@link resolveRelationFilters}): the relation-key branch below still
252
+ * throws, so an unresolved relation filter can never leak into a join.
186
253
  */
187
- buildWhere(where, params) {
254
+ buildWhere(where, params, alias) {
188
255
  if (!where)
189
256
  return '';
190
257
  const parts = [];
@@ -192,17 +259,17 @@ export class PowqlInterface {
192
259
  if (value === undefined)
193
260
  continue;
194
261
  if (key === 'AND') {
195
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
262
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
196
263
  if (sub.length)
197
264
  parts.push(`(${sub.join(' and ')})`);
198
265
  }
199
266
  else if (key === 'OR') {
200
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
267
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
201
268
  if (sub.length)
202
269
  parts.push(`(${sub.join(' or ')})`);
203
270
  }
204
271
  else if (key === 'NOT') {
205
- const sub = this.buildWhere(value, params);
272
+ const sub = this.buildWhere(value, params, alias);
206
273
  if (sub)
207
274
  parts.push(`not (${sub})`);
208
275
  }
@@ -213,20 +280,33 @@ export class PowqlInterface {
213
280
  throw new ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
214
281
  }
215
282
  else {
216
- parts.push(this.buildFieldCondition(key, value, params));
283
+ // A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
284
+ // empty results so buildWhere never emits a dangling ` and `.
285
+ const cond = this.buildFieldCondition(key, value, params, alias);
286
+ if (cond)
287
+ parts.push(cond);
217
288
  }
218
289
  }
219
290
  return parts.join(' and ');
220
291
  }
221
292
  /** Build a single `field: value | operator` condition. */
222
- buildFieldCondition(field, value, params) {
223
- const ref = this.ref(field);
293
+ buildFieldCondition(field, value, params, alias) {
294
+ const colMeta = this.column(field);
295
+ const ref = this.ref(field, alias);
224
296
  if (value === null)
225
297
  return `${ref} is null`;
226
298
  if (value instanceof Date || typeof value !== 'object') {
227
- return `${ref} = ${this.param(value, params)}`;
299
+ return `${ref} = ${this.param(value, params, colMeta)}`;
228
300
  }
229
301
  const op = value;
302
+ // JSON path / key filters on a json document column compile to PowQL `->`
303
+ // path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
304
+ // `hasKey`; on a NON-json column those fall through to the scalar operator
305
+ // path below (e.g. `equals` stays a plain equality), exactly like SQL.
306
+ if (isJsonColumn(colMeta) && isJsonFilter(value)) {
307
+ requireCapability(this.capabilities, 'jsonDocs', 'JSON path filters');
308
+ return this.buildJsonPathCondition(colMeta, value, params, alias);
309
+ }
230
310
  rejectUnsupportedFilter(op, field);
231
311
  if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
232
312
  // A bare object that is not an operator set — equality by value.
@@ -278,6 +358,100 @@ export class PowqlInterface {
278
358
  }
279
359
  return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
280
360
  }
361
+ /**
362
+ * PowQL JSON path expression `.col->$a->$b…`, binding EVERY path segment as a
363
+ * positional param (a string segment as a `str` token, an integer index as an
364
+ * `int` token). `->` binds tighter than every operator, so no parens are
365
+ * needed around the path in a comparison. Segments are bound (never inlined)
366
+ * to keep {@link materializePowql}'s `$N`-scan invariant intact: a segment
367
+ * that literally contained `$1` would otherwise be rewritten. Shared by the
368
+ * F1 where-filter path and the F2 orderBy / groupBy path emitters.
369
+ *
370
+ * A digit-only STRING segment (`'0'`) binds as an `int` array index, matching
371
+ * the SQL engines: `JsonFilter.path` is typed `string[]`, so an array index
372
+ * can only be expressed as a digit string, and the SQL builder converts it the
373
+ * same way (`/^\d+$/ → [n]`, query/builder.ts). Without this, PowDB's typed
374
+ * `->` treats `'0'` as a string KEY and silently matches nothing on an array
375
+ * (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
376
+ * json object whose key is literally `"0"` is addressed as an array index.
377
+ */
378
+ jsonPathExpr(col, path, params, alias) {
379
+ let expr = this.colRefName(col.name, alias);
380
+ for (const seg of path) {
381
+ const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
382
+ expr += `->${this.param(bound, params)}`;
383
+ }
384
+ return expr;
385
+ }
386
+ /**
387
+ * Compile a {@link JsonFilter} on a json document column into a PowQL filter
388
+ * (≥ 0.12). Operators PowQL cannot express EXACTLY throw a per-operator E017
389
+ * (never a wrong result): containment (`contains`, and `equals` without a
390
+ * `path`) has no PowQL operator. The mapped shapes:
391
+ * - `{ path, equals: v }` → `P = $n` (typed: string→str, bool→bool,
392
+ * integral number→int, fractional→float; NOT stringified)
393
+ * - `{ path, equals: null }` → `P is null` (matches JSON null AND a missing
394
+ * key, a deliberate divergence from the PG driver, documented on
395
+ * {@link JsonFilter})
396
+ * - `{ path, gt|gte|lt|lte: v }` → `P > $n` … (range ops require `path`; the
397
+ * engine coerces int/float numerically)
398
+ * - `{ hasKey: k }` → `json_type(.col->$n) is not null` (top-level key test,
399
+ * ignoring `path`, mirroring PG `col ? key`; includes keys holding JSON
400
+ * null)
401
+ * A bare `{ path }` with no operators compiles to zero clauses (byte-parity
402
+ * with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
403
+ * by the empty-where guard.
404
+ */
405
+ buildJsonPathCondition(col, filter, params, alias) {
406
+ const conds = [];
407
+ // Bind the path segments at most once and reuse the expression string across
408
+ // equals + range comparisons (they share the same `path`).
409
+ let pathExpr = null;
410
+ const pathP = () => {
411
+ pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
412
+ return pathExpr;
413
+ };
414
+ if (filter.contains !== undefined) {
415
+ throw new UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
416
+ }
417
+ if (filter.equals !== undefined) {
418
+ if (filter.path === undefined || filter.path.length === 0) {
419
+ throw new UnsupportedFeatureError('JSON containment (equals without path)', 'PowDB', `column "${col.name}": pass a \`path\` to compare a specific json value; PowQL has no whole-document containment`);
420
+ }
421
+ conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
422
+ }
423
+ if (filter.hasKey !== undefined) {
424
+ // Top-level key existence, independent of `path` (mirrors PG `col ? key`).
425
+ conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
426
+ }
427
+ // Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
428
+ // Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
429
+ // number or a string.
430
+ for (const [op, powOp] of [
431
+ ['gt', '>'],
432
+ ['gte', '>='],
433
+ ['lt', '<'],
434
+ ['lte', '<='],
435
+ ]) {
436
+ const v = filter[op];
437
+ if (v === undefined)
438
+ continue;
439
+ if (filter.path === undefined) {
440
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a \`path\` ` +
441
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(v)} }).`);
442
+ }
443
+ if (typeof v !== 'number' && typeof v !== 'string') {
444
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a number or string, got ${JSON.stringify(v)}.`);
445
+ }
446
+ if (typeof v === 'number' && !Number.isFinite(v)) {
447
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${col.name} requires a finite number.`);
448
+ }
449
+ conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
450
+ }
451
+ if (!conds.length)
452
+ return '';
453
+ return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
454
+ }
281
455
  /** Bind a value, lowercasing for case-insensitive comparisons. */
282
456
  bind(value, params, insensitive) {
283
457
  const ph = this.param(value, params);
@@ -419,7 +593,7 @@ export class PowqlInterface {
419
593
  const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
420
594
  const params = [];
421
595
  const ph = chunk.map((v) => this.param(v, params)).join(', ');
422
- const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
596
+ const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
423
597
  for (const r of rows) {
424
598
  const v = r[sourceJCol];
425
599
  if (v != null)
@@ -467,8 +641,20 @@ export class PowqlInterface {
467
641
  projection(cols) {
468
642
  return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
469
643
  }
470
- /** `order .c1 asc, .c2 desc` clause (empty string when no orderBy). */
471
- buildOrder(orderBy) {
644
+ /**
645
+ * `order .c1 asc, .c2 desc` clause (empty string when no orderBy). Supports,
646
+ * besides a plain direction:
647
+ * - {@link JsonPathOrderBy} on a json column (≥ 0.12): `{ data: { path: […],
648
+ * type?, direction? } }` → `order .data->$n asc` (or
649
+ * `cast(.data->$n, "float")` for `type: 'numeric'`);
650
+ * - {@link OrderBySpec} `{ sort, nulls }`: `nulls: 'last'` is accepted as a
651
+ * no-op (PowDB is always nulls-last), `nulls: 'first'` throws E017.
652
+ *
653
+ * PowDB orders missing / JSON-null keys LAST in BOTH directions (an engine
654
+ * contract): for identical cross-engine results pass `nulls: 'last'`
655
+ * explicitly on Postgres, which defaults nulls-first for `desc`.
656
+ */
657
+ buildOrder(orderBy, params, alias) {
472
658
  if (!orderBy)
473
659
  return '';
474
660
  const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
@@ -476,32 +662,97 @@ export class PowqlInterface {
476
662
  return '';
477
663
  const parts = keys.map(([field, dir]) => {
478
664
  if (dir && typeof dir === 'object') {
665
+ const o = dir;
666
+ // JSON-path ordering on a json column.
667
+ if (Array.isArray(o.path)) {
668
+ return this.buildJsonPathOrder(field, dir, params, alias);
669
+ }
670
+ // OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
671
+ // nulls-first (no placement grammar). Distinct from vector/pick/_count.
672
+ if ('sort' in o && !('distance' in o) && !('_count' in o) && !isRelationPickOrderBy(dir)) {
673
+ const spec = dir;
674
+ if (spec.nulls === 'first') {
675
+ throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
676
+ }
677
+ return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
678
+ }
479
679
  // Name the actual feature in the refusal — a pick-row ordering
480
680
  // reported as "vector / distance ordering" sends users hunting for
481
- // pgvector docs. All object-valued orderings stay E017 on PowDB.
482
- const o = dir;
681
+ // pgvector docs. Everything else stays E017 on PowDB.
483
682
  const feature = isRelationPickOrderBy(dir)
484
683
  ? 'relation pick-row ordering'
485
684
  : 'distance' in o
486
685
  ? 'vector / distance ordering'
487
- : Array.isArray(o.path)
488
- ? 'JSON-path ordering'
489
- : '_count' in o
490
- ? 'relation _count ordering'
491
- : 'sort' in o || 'nulls' in o
492
- ? 'NULLS placement / sort-spec ordering'
493
- : 'object-valued ordering';
686
+ : '_count' in o
687
+ ? 'relation _count ordering'
688
+ : 'nulls' in o
689
+ ? 'NULLS placement / sort-spec ordering'
690
+ : 'object-valued ordering';
494
691
  throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
495
692
  }
496
- return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
693
+ return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
497
694
  });
498
695
  return ` order ${parts.join(', ')}`;
499
696
  }
697
+ /** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
698
+ buildJsonPathOrder(field, spec, params, alias) {
699
+ const col = this.column(field);
700
+ if (!isJsonColumn(col)) {
701
+ throw new UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
702
+ }
703
+ requireCapability(this.capabilities, 'jsonDocs', 'JSON path ordering');
704
+ if (spec.nulls === 'first') {
705
+ throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
706
+ }
707
+ const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
708
+ // `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
709
+ // JSON numbers already order numerically without a cast.
710
+ const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
711
+ return `${expr} ${spec.direction === 'desc' ? 'desc' : 'asc'}`;
712
+ }
500
713
  // -------------------------------------------------------------------------
501
714
  // Execution plumbing
502
715
  // -------------------------------------------------------------------------
503
- /** Run PowQL with optional timeout, emitting a query event either way. */
504
- async exec(powql, params, timeout) {
716
+ /**
717
+ * Run PowQL with optional timeout, emitting a query event either way. The
718
+ * `action` is passed PER CALL (never read from shared instance state) so the
719
+ * retry-eligibility and the emitted event action stay correct even when a
720
+ * concurrent operation runs on the same cached interface: a WRITE statement
721
+ * carries a write action and can therefore never be mistaken for a replayable
722
+ * read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
723
+ */
724
+ async exec(powql, params, timeout, action = 'raw') {
725
+ return this.execOnce(powql, params, timeout, action, false);
726
+ }
727
+ /** Build the E018 refusal for a write / `begin` on a read-only pool. */
728
+ readOnlyError(operation) {
729
+ // Pass a clean detail: the ReadOnlyError constructor owns both the
730
+ // `[turbine] ` prefix and the "Route writes to a writable primary." hint,
731
+ // so adding either here would double them.
732
+ return new ReadOnlyError(`${operation} on "${this.table}" refused: this PowDB connection is read-only.`);
733
+ }
734
+ /**
735
+ * Execute one statement, with the opt-in single stale-frame READ replay. When
736
+ * `retryStaleReads` is on and a first-statement READ fails with the stale-wire
737
+ * {@link isStaleFramePowdbError} ConnectionError (a socket idle-gap "received
738
+ * unexpected frame" that the client cannot recover), the statement is retried
739
+ * exactly once on a fresh pooled connection (the broken one was destroyed).
740
+ * The replay is refused for writes (an ambiguous mutation reply is unsafe to
741
+ * replay) and inside a transaction (a mid-tx statement cannot move connection),
742
+ * so only the read-shaped actions in {@link POWQL_READ_ACTIONS}, outside a
743
+ * `_txScoped` interface, are eligible. `action` is a per-call argument (never
744
+ * `this`-state), so a concurrent op flipping instance fields cannot turn a
745
+ * write into a retryable read.
746
+ */
747
+ async execOnce(powql, params, timeout, action, isRetry) {
748
+ // Read-only pool guard: refuse a write action locally, before the wire, so a
749
+ // read-only target never even attempts the mutation (the engine refusal, if
750
+ // any, is only the backstop for raw/injected paths). `action` is per-call,
751
+ // so a concurrent read is never mistaken for a write. Reads (incl. explain)
752
+ // and non-classified `raw` fall through unchanged.
753
+ if (this.pool.readonly === true && POWQL_WRITE_ACTIONS.has(action)) {
754
+ throw this.readOnlyError(action);
755
+ }
505
756
  const start = performance.now();
506
757
  const run = this.pool.query(powql, params);
507
758
  try {
@@ -511,15 +762,37 @@ export class PowqlInterface {
511
762
  new Promise((_, reject) => setTimeout(() => reject(new TimeoutError(timeout)), timeout)),
512
763
  ])
513
764
  : await run;
514
- this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
515
- return result;
765
+ this.emit(action, powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
766
+ // The pool tags each result with the wire that actually served it
767
+ // (adaptResult → false, adaptNativeResult → true). A heterogeneous
768
+ // injected pool can fall back to the legacy wire per call, so read the
769
+ // per-result flag and only fall back to the pool-level capability when a
770
+ // hand-built pool (tests) omits the tag; never coerce legacy rows with
771
+ // the native policy just because the pool reports nativeRaw.
772
+ const native = result.native ?? Boolean(this.capabilities.nativeRaw);
773
+ return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length, native };
516
774
  }
517
775
  catch (err) {
518
- this.emit(powql, params, performance.now() - start, 0, err);
776
+ if (!isRetry && this.shouldRetryStaleRead(err, action)) {
777
+ // Transparent single replay on a fresh connection; the first (swallowed)
778
+ // failure is not emitted, only the retried outcome is observed.
779
+ return this.execOnce(powql, params, timeout, action, true);
780
+ }
781
+ this.emit(action, powql, params, performance.now() - start, 0, err);
519
782
  throw err;
520
783
  }
521
784
  }
522
- emit(sql, params, duration, rows, error) {
785
+ /** Is `err` a replayable stale-frame failure for THIS (per-call) read-shaped, non-tx action? */
786
+ shouldRetryStaleRead(err, action) {
787
+ if (!this.pool.retryStaleReads)
788
+ return false;
789
+ if (this.isTxScoped())
790
+ return false;
791
+ if (!POWQL_READ_ACTIONS.has(action))
792
+ return false;
793
+ return isStaleFramePowdbError(err);
794
+ }
795
+ emit(action, sql, params, duration, rows, error) {
523
796
  if (!this.onQuery)
524
797
  return;
525
798
  try {
@@ -528,7 +801,7 @@ export class PowqlInterface {
528
801
  params,
529
802
  duration,
530
803
  model: this.table,
531
- action: this.currentAction,
804
+ action,
532
805
  rows,
533
806
  timestamp: new Date(),
534
807
  error,
@@ -540,7 +813,6 @@ export class PowqlInterface {
540
813
  }
541
814
  /** Run a method body through the middleware chain (mirrors QueryInterface). */
542
815
  async withMiddleware(action, args, executor) {
543
- this.currentAction = action;
544
816
  if (this.middlewares.length === 0)
545
817
  return executor();
546
818
  let index = 0;
@@ -551,34 +823,48 @@ export class PowqlInterface {
551
823
  };
552
824
  return next({ model: this.table, action, args: { ...args } });
553
825
  }
554
- /** Map raw rows to typed entities. */
555
- shape(rows) {
556
- return rows.map((r) => rowToEntity(r, this.meta));
826
+ /** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
827
+ * this result (threaded from {@link execOnce}, not the pool-level capability),
828
+ * so cells that arrived pre-typed over `queryNativeRaw` (F3) skip the legacy
829
+ * string coercion (a genuine str `"null"` stays `"null"` instead of collapsing
830
+ * to null) while a per-call legacy fallback on a native-capable pool still
831
+ * coerces its string cells correctly. Defaults to the pool capability for the
832
+ * rare caller with no per-result flag (hand-built test pools). */
833
+ shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
834
+ return rows.map((r) => rowToEntity(r, this.meta, native));
557
835
  }
558
836
  // -------------------------------------------------------------------------
559
837
  // Reads
560
838
  // -------------------------------------------------------------------------
561
839
  async findMany(args = {}) {
562
840
  return this.withMiddleware('findMany', args, async () => {
563
- const rows = await this.runFind(args);
564
- const entities = this.shape(rows);
565
- if (args.with)
566
- await this.loadRelations(entities, args.with, args.timeout);
841
+ const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
842
+ const entities = this.shape(rows, native);
843
+ if (args.with) {
844
+ await this.loadRelations(entities, args.with, args.timeout, 0, {
845
+ args,
846
+ resolvedWhere,
847
+ });
848
+ }
567
849
  return entities;
568
850
  });
569
851
  }
570
- /** Build + run the flat findMany select; returns raw rows. */
571
- async runFind(args) {
852
+ /**
853
+ * Compile the flat findMany select into PowQL (no execution), pushing values
854
+ * into `params`. Returns the query plus the RESOLVED where (relation filters
855
+ * already collapsed to literal in-lists) so the F2 join path can re-emit the
856
+ * exact parent predicate alias-qualified, and so {@link explain} can wrap it.
857
+ */
858
+ async buildFind(args, params) {
572
859
  if (args.cursor) {
573
860
  throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
574
861
  }
575
- const params = [];
576
862
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
577
863
  const where = this.buildWhere(resolvedWhere, params);
578
864
  const cols = this.projectedColumns(args.select, args.omit);
579
865
  const distinct = args.distinct?.length ? ' distinct' : '';
580
866
  const filter = where ? ` filter ${where}` : '';
581
- const order = this.buildOrder(args.orderBy);
867
+ const order = this.buildOrder(args.orderBy, params);
582
868
  const limit = args.limit ?? args.take ?? this.defaultLimit;
583
869
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
584
870
  this.warnedUnlimited = true;
@@ -587,15 +873,45 @@ export class PowqlInterface {
587
873
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
588
874
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
589
875
  const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
590
- const { rows } = await this.exec(powql, params, args.timeout);
591
- return rows;
876
+ return { powql, resolvedWhere };
877
+ }
878
+ /** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
879
+ async runFind(args, action = 'findMany') {
880
+ const params = [];
881
+ const { powql, resolvedWhere } = await this.buildFind(args, params);
882
+ const { rows, native } = await this.exec(powql, params, args.timeout, action);
883
+ return { rows, native, resolvedWhere };
884
+ }
885
+ /**
886
+ * Diagnostic surface: compile the same PowQL {@link findMany} would run for
887
+ * `args` (no cache) and return the engine's plan as one string per line.
888
+ *
889
+ * Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
890
+ * eligible for the stale-read replay. The line content is engine-owned and is
891
+ * NOT covered by semver (match plan node names / tree shape, never exact
892
+ * bytes; mirrors PowDB's own `explain` contract).
893
+ *
894
+ * Does NOT run through the middleware chain: plan text is a diagnostic, not
895
+ * entity rows, and `QueryInterface.explain` deliberately bypasses middleware
896
+ * too, so both engines agree.
897
+ */
898
+ async explain(args = {}) {
899
+ const params = [];
900
+ const { powql } = await this.buildFind(args, params);
901
+ const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
902
+ return rows
903
+ .map((r) => {
904
+ const line = r.plan ?? Object.values(r)[0];
905
+ return line == null ? '' : String(line);
906
+ })
907
+ .filter((line) => line.length > 0);
592
908
  }
593
909
  async findUnique(args) {
594
910
  return this.withMiddleware('findUnique', args, async () => {
595
- const rows = await this.runFind({ ...args, limit: 1 });
911
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
596
912
  if (!rows.length)
597
913
  return null;
598
- const entities = this.shape(rows);
914
+ const entities = this.shape(rows, native);
599
915
  if (args.with)
600
916
  await this.loadRelations(entities, args.with, args.timeout);
601
917
  return entities[0];
@@ -603,10 +919,10 @@ export class PowqlInterface {
603
919
  }
604
920
  async findFirst(args = {}) {
605
921
  return this.withMiddleware('findFirst', args, async () => {
606
- const rows = await this.runFind({ ...args, limit: 1 });
922
+ const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
607
923
  if (!rows.length)
608
924
  return null;
609
- const entities = this.shape(rows);
925
+ const entities = this.shape(rows, native);
610
926
  if (args.with)
611
927
  await this.loadRelations(entities, args.with, args.timeout);
612
928
  return entities[0];
@@ -627,19 +943,48 @@ export class PowqlInterface {
627
943
  // -------------------------------------------------------------------------
628
944
  // Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
629
945
  // -------------------------------------------------------------------------
630
- /** Load each requested relation for `parents` and attach it onto each row. */
631
- async loadRelations(parents, withClause, timeout, depth = 0) {
946
+ /**
947
+ * Load each requested relation for `parents` and attach it onto each row.
948
+ *
949
+ * `parent` is supplied ONLY by the top-level {@link findMany} (its args +
950
+ * resolved where). When the effective `relationLoadStrategy` resolves to an
951
+ * explicit `'join'` and the pool advertises `serverJoins`, an eligible
952
+ * top-level relation is loaded with a native PowQL join instead of the keyed
953
+ * loaders (F2); everything else (nested `with` levels, ineligible shapes, and
954
+ * the default `'batched'` strategy) keeps the loaders. Output is byte-equal
955
+ * either way (the join reuses the same stitch / shape helpers).
956
+ */
957
+ async loadRelations(parents, withClause, timeout, depth = 0, parent) {
632
958
  if (depth >= 10) {
633
959
  throw new ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
634
960
  }
635
961
  if (!parents.length)
636
962
  return;
963
+ // The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
964
+ // or a client config the user set). The serverJoins capability is consulted
965
+ // PER RELATION below, AFTER joinEligible, so a relation that would have
966
+ // fallen back to the loaders anyway (paged parent, nested `with`, composite
967
+ // key, …) never triggers the capability's E017.
968
+ const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
637
969
  for (const [relName, opt] of Object.entries(withClause)) {
638
970
  if (!opt)
639
971
  continue;
640
972
  const rel = this.meta.relations[relName];
641
973
  if (!rel)
642
974
  throw new ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
975
+ if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
976
+ if (this.capabilities.serverJoins) {
977
+ await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout);
978
+ continue;
979
+ }
980
+ // An otherwise-eligible relation the engine cannot join: a PER-QUERY
981
+ // `relationLoadStrategy: 'join'` is an explicit request, so throw a typed
982
+ // E017; a CLIENT-LEVEL default silently falls back to the keyed loaders
983
+ // (so pointing an existing app at an older engine keeps working).
984
+ if (parent.args.relationLoadStrategy === 'join') {
985
+ requireCapability(this.capabilities, 'serverJoins', 'native PowQL relation joins');
986
+ }
987
+ }
643
988
  if (rel.type === 'manyToMany') {
644
989
  await this.loadManyToMany(parents, rel, relName, opt, timeout);
645
990
  continue;
@@ -660,23 +1005,49 @@ export class PowqlInterface {
660
1005
  const keys = [
661
1006
  ...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
662
1007
  ];
663
- const childByKey = new Map();
1008
+ // The loader buckets children by their correlation column, so that column
1009
+ // MUST be in the fetched projection even when the user's select/omit drops
1010
+ // it. Force it into the fetch here and strip it back off the entities after
1011
+ // stitching (the join path already gets this for free via `__tpk`).
1012
+ const userSelect = options.select;
1013
+ const userOmit = options.omit;
1014
+ const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
1015
+ let fetchOptions = options;
1016
+ if (!fkProjected) {
1017
+ if (userSelect) {
1018
+ fetchOptions = {
1019
+ ...options,
1020
+ select: { ...userSelect, [childKeyField]: true },
1021
+ };
1022
+ }
1023
+ else if (userOmit) {
1024
+ const omitWithoutFk = { ...userOmit };
1025
+ delete omitWithoutFk[childKeyField];
1026
+ fetchOptions = { ...options, omit: omitWithoutFk };
1027
+ }
1028
+ }
664
1029
  // Chunk the key set so a single `in (…)` never exceeds PowDB's
665
- // per-statement param / row limits; merge each chunk's children.
1030
+ // per-statement param / row limits; merge each chunk's children. Keys are
1031
+ // normalized through joinKey (a Date maps to micros, matching the child
1032
+ // cell) so a datetime correlation column stitches instead of silently
1033
+ // returning [].
1034
+ const childByKey = new Map();
666
1035
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
667
1036
  const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
668
1037
  const childWhere = {
669
- ...options.where,
1038
+ ...fetchOptions.where,
670
1039
  [childKeyField]: { in: chunk },
671
1040
  };
672
1041
  const children = (await targetQi.findMany({
673
- ...options,
1042
+ ...fetchOptions,
674
1043
  where: childWhere,
675
1044
  with: options.with,
676
1045
  timeout: options.timeout ?? timeout,
677
1046
  }));
678
1047
  for (const child of children) {
679
- const k = child[childKeyField];
1048
+ const k = this.joinKey(child[childKeyField]);
1049
+ if (k == null)
1050
+ continue;
680
1051
  const bucket = childByKey.get(k);
681
1052
  if (bucket)
682
1053
  bucket.push(child);
@@ -684,10 +1055,18 @@ export class PowqlInterface {
684
1055
  childByKey.set(k, [child]);
685
1056
  }
686
1057
  }
1058
+ // Strip the forced correlation column back off if the user excluded it,
1059
+ // so the emitted entities match their select/omit exactly.
1060
+ if (!fkProjected) {
1061
+ for (const bucket of childByKey.values()) {
1062
+ for (const child of bucket)
1063
+ delete child[childKeyField];
1064
+ }
1065
+ }
687
1066
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
688
1067
  for (const parent of parents) {
689
- const k = parent[parentKeyField];
690
- const matches = childByKey.get(k) ?? [];
1068
+ const k = this.joinKey(parent[parentKeyField]);
1069
+ const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
691
1070
  parent[relName] = single ? (matches[0] ?? null) : matches;
692
1071
  }
693
1072
  }
@@ -736,7 +1115,7 @@ export class PowqlInterface {
736
1115
  const params = [];
737
1116
  const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
738
1117
  const powql = `${quotePowqlIdent(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
739
- const { rows } = await this.exec(powql, params, timeout);
1118
+ const { rows } = await this.exec(powql, params, timeout, 'findMany');
740
1119
  for (const row of rows) {
741
1120
  const sv = String(row[sourceJCol]);
742
1121
  const tv = String(row[targetJCol]);
@@ -782,6 +1161,257 @@ export class PowqlInterface {
782
1161
  }
783
1162
  }
784
1163
  // -------------------------------------------------------------------------
1164
+ // Nested relations: native PowQL joins (F2, opt-in via relationLoadStrategy)
1165
+ // -------------------------------------------------------------------------
1166
+ /**
1167
+ * Resolve the effective relation-load strategy: the per-query arg wins, then
1168
+ * the client config, then the PowDB default of `'batched'` (the keyed
1169
+ * loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
1170
+ * default (that would silently flip every existing PowDB user onto brand-new
1171
+ * join generation). Only a value the user actually set to `'join'` activates it.
1172
+ */
1173
+ resolveStrategy(args) {
1174
+ const s = args.relationLoadStrategy ?? this.options.relationLoadStrategy ?? 'batched';
1175
+ return s === 'join' ? 'join' : 'batched';
1176
+ }
1177
+ /**
1178
+ * Per-relation eligibility for the join path (checked before the serverJoins
1179
+ * capability). Any `false` here is a SILENT fallback to the keyed loaders (it
1180
+ * is never an error), so an off-page or nested-`with` shape still returns
1181
+ * correct rows:
1182
+ * - the parent query must not be paged (`limit`/`offset`/`take`, including the
1183
+ * configured `defaultLimit`): a parent-filter join under a page would scan
1184
+ * children of off-page parents, where the loaders are strictly better;
1185
+ * - the relation must not request a nested `with` (its subtree stays on the
1186
+ * loaders this round) or a `distinct`;
1187
+ * - single-column relation keys only (a composite key falls to the loader,
1188
+ * which throws the same E017 as today);
1189
+ * - the PARENT-SIDE correlation column must be a single-column PK or unique
1190
+ * column, or the INNER join would re-emit one child copy per matching
1191
+ * parent row (a non-unique correlation key produces duplicate children the
1192
+ * loader never would). For hasMany/hasOne/m2m that column is the relation's
1193
+ * `referenceKey` on THIS (fetched) table; for belongsTo it is the
1194
+ * `referenceKey` on the TARGET table (the join's non-fetched side);
1195
+ * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
1196
+ * stitch can't be reproduced by the 3-table join deterministically);
1197
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
1198
+ * does a to-many relation `limit`/`offset` when the parent set spills past
1199
+ * one loader chunk (the loader limits per chunk, the join once globally).
1200
+ */
1201
+ joinEligible(rel, opt, args, parentCount) {
1202
+ const effLimit = args.limit ?? args.take ?? this.defaultLimit;
1203
+ if (effLimit !== undefined || args.offset)
1204
+ return false;
1205
+ const options = (opt === true ? {} : opt);
1206
+ if (options.with)
1207
+ return false;
1208
+ if (options.distinct?.length)
1209
+ return false;
1210
+ if (rel.type === 'manyToMany') {
1211
+ const through = rel.through;
1212
+ if (!through)
1213
+ return false;
1214
+ if (normalizeKeyColumns(through.sourceKey).length > 1 ||
1215
+ normalizeKeyColumns(through.targetKey).length > 1 ||
1216
+ normalizeKeyColumns(rel.referenceKey).length > 1 ||
1217
+ (this.schema.tables[rel.to]?.primaryKey.length ?? 2) > 1) {
1218
+ return false;
1219
+ }
1220
+ if (options.orderBy || options.limit !== undefined || options.offset)
1221
+ return false;
1222
+ // The parent joins on its referenceKey; a non-unique one duplicates.
1223
+ if (!this.isSingleColumnUnique(this.meta, normalizeKeyColumns(rel.referenceKey)[0]))
1224
+ return false;
1225
+ return true;
1226
+ }
1227
+ if (normalizeKeyColumns(rel.foreignKey).length > 1 || normalizeKeyColumns(rel.referenceKey).length > 1) {
1228
+ return false;
1229
+ }
1230
+ // Reject a non-unique correlation key: on belongsTo the fetched side joins on
1231
+ // the target's referenceKey, otherwise the fetched side joins on its own.
1232
+ if (rel.type === 'belongsTo') {
1233
+ const targetMeta = this.schema.tables[rel.to];
1234
+ if (!targetMeta || !this.isSingleColumnUnique(targetMeta, normalizeKeyColumns(rel.referenceKey)[0])) {
1235
+ return false;
1236
+ }
1237
+ }
1238
+ else if (!this.isSingleColumnUnique(this.meta, normalizeKeyColumns(rel.referenceKey)[0])) {
1239
+ return false;
1240
+ }
1241
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1242
+ if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
1243
+ return false;
1244
+ }
1245
+ return true;
1246
+ }
1247
+ /**
1248
+ * True when `col` is a single-column unique key of `tableMeta`: the sole
1249
+ * primary-key column, a single-column entry in `uniqueColumns` (where a
1250
+ * per-column `unique: true` and an introspected single-column unique constraint
1251
+ * both land), or a single-column unique index. Used by {@link joinEligible} to
1252
+ * keep the INNER-join path off relations whose parent-side correlation column
1253
+ * can repeat (which would duplicate children).
1254
+ */
1255
+ isSingleColumnUnique(tableMeta, col) {
1256
+ if (tableMeta.primaryKey.length === 1 && tableMeta.primaryKey[0] === col)
1257
+ return true;
1258
+ if (tableMeta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === col))
1259
+ return true;
1260
+ return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
1261
+ }
1262
+ /** Dispatch one eligible relation to the correct native-join loader. */
1263
+ async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout) {
1264
+ if (rel.type === 'manyToMany') {
1265
+ await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout);
1266
+ return;
1267
+ }
1268
+ const options = (opt === true ? {} : opt);
1269
+ const targetMeta = this.schema.tables[rel.to];
1270
+ if (!targetMeta)
1271
+ throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
1272
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1273
+ const fk = normalizeKeyColumns(rel.foreignKey);
1274
+ const rk = normalizeKeyColumns(rel.referenceKey);
1275
+ // Correlation math is identical to the keyed loaders, only the transport
1276
+ // (join vs in-list) changes. Always join the RELATION TARGET (alias `c`) to
1277
+ // the already-fetched side (alias `p`), correlating on the fetched side's key
1278
+ // and projecting `__tpk` from the fetched side's correlation column.
1279
+ const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
1280
+ const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
1281
+ const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
1282
+ const params = [];
1283
+ const childCols = this.joinChildCols(targetQi, options);
1284
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
1285
+ const order = targetQi.buildOrder(options.orderBy, params, 'c');
1286
+ const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
1287
+ const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
1288
+ const proj = this.joinProjection(childCols, `p.${quotePowqlIdent(parentKeyCol)}`, 'c');
1289
+ const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
1290
+ `on c.${quotePowqlIdent(childKeyCol)} = p.${quotePowqlIdent(parentKeyCol)}` +
1291
+ `${filter}${order}${limitClause}${offsetClause} ${proj}`;
1292
+ // A READ: thread a read-shaped action through the exec seam.
1293
+ const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
1294
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1295
+ const byKey = this.bucketByTpk(targetQi, rows, native);
1296
+ for (const p of parents) {
1297
+ const key = this.joinKey(p[parentKeyField]);
1298
+ const matches = (key == null ? undefined : byKey.get(key)) ?? [];
1299
+ p[relName] = single ? (matches[0] ?? null) : matches;
1300
+ }
1301
+ }
1302
+ /**
1303
+ * manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
1304
+ * → the already-fetched side (alias `p`), correlating `__tpk` from the
1305
+ * junction's source key. Always a list, stitched exactly like the loader.
1306
+ */
1307
+ async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout) {
1308
+ const through = rel.through;
1309
+ if (!through)
1310
+ throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
1311
+ const options = (opt === true ? {} : opt);
1312
+ const targetMeta = this.schema.tables[rel.to];
1313
+ if (!targetMeta)
1314
+ throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
1315
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1316
+ const sourceJCol = normalizeKeyColumns(through.sourceKey)[0];
1317
+ const targetJCol = normalizeKeyColumns(through.targetKey)[0];
1318
+ const sourceRefCol = normalizeKeyColumns(rel.referenceKey)[0];
1319
+ const targetPkCol = targetMeta.primaryKey[0];
1320
+ const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
1321
+ const params = [];
1322
+ const childCols = this.joinChildCols(targetQi, options);
1323
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
1324
+ const proj = this.joinProjection(childCols, `j.${quotePowqlIdent(sourceJCol)}`, 't');
1325
+ const powql = `${targetQi.qt} as t ` +
1326
+ `join ${quotePowqlIdent(through.table)} as j on t.${quotePowqlIdent(targetPkCol)} = j.${quotePowqlIdent(targetJCol)} ` +
1327
+ `join ${this.qt} as p on j.${quotePowqlIdent(sourceJCol)} = p.${quotePowqlIdent(sourceRefCol)}` +
1328
+ `${filter} ${proj}`;
1329
+ const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
1330
+ const byKey = this.bucketByTpk(targetQi, rows, native);
1331
+ for (const p of parents) {
1332
+ const key = this.joinKey(p[parentRefField]);
1333
+ p[relName] = (key == null ? undefined : byKey.get(key)) ?? [];
1334
+ }
1335
+ }
1336
+ /**
1337
+ * The target column list to project through the join (honouring select/omit),
1338
+ * with a loud guard: a real column named `__tpk` would collide with the
1339
+ * reserved correlation alias, so refuse rather than silently mis-stitch.
1340
+ */
1341
+ joinChildCols(targetQi, options) {
1342
+ const cols = targetQi.projectedColumns(options.select, options.omit);
1343
+ if (cols.includes('__tpk')) {
1344
+ throw new ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
1345
+ `join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
1346
+ }
1347
+ return cols;
1348
+ }
1349
+ /**
1350
+ * `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
1351
+ * ALIASED to its bare name (a bare qualified ref `c.col` would come back named
1352
+ * `c.col`, not `col`) so the stitched rows shape identically to a flat select.
1353
+ */
1354
+ joinProjection(childCols, tpkExpr, childAlias) {
1355
+ const parts = [
1356
+ `__tpk: ${tpkExpr}`,
1357
+ ...childCols.map((c) => `${quotePowqlIdent(c)}: ${childAlias}.${quotePowqlIdent(c)}`),
1358
+ ];
1359
+ return `{ ${parts.join(', ')} }`;
1360
+ }
1361
+ /**
1362
+ * `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
1363
+ * The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
1364
+ * to literal in-lists before the base query ran); the relation where is resolved
1365
+ * on the target the same way before qualifying, so a nested relation filter in
1366
+ * the relation `where` never reaches the join unresolved. Params bind in order.
1367
+ */
1368
+ async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
1369
+ const parts = [];
1370
+ const pw = this.buildWhere(parentResolvedWhere, params, 'p');
1371
+ if (pw)
1372
+ parts.push(pw);
1373
+ const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
1374
+ const rw = targetQi.buildWhere(relResolved, params, childAlias);
1375
+ if (rw)
1376
+ parts.push(rw);
1377
+ return parts.length ? ` filter ${parts.join(' and ')}` : '';
1378
+ }
1379
+ /** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
1380
+ bucketByTpk(targetQi, rows, native) {
1381
+ const byKey = new Map();
1382
+ for (const raw of rows) {
1383
+ const tpk = this.joinKey(raw.__tpk);
1384
+ delete raw.__tpk;
1385
+ const child = targetQi.shape([raw], native)[0];
1386
+ if (tpk == null)
1387
+ continue;
1388
+ const bucket = byKey.get(tpk);
1389
+ if (bucket)
1390
+ bucket.push(child);
1391
+ else
1392
+ byKey.set(tpk, [child]);
1393
+ }
1394
+ return byKey;
1395
+ }
1396
+ /**
1397
+ * Normalize a correlation key to a stable string map key so a parent's key
1398
+ * value (a shaped entity field) and a child row's `__tpk` cell match across
1399
+ * wires and column types. A `Date` maps to microseconds
1400
+ * (`getTime()` ms times 1000), because a datetime correlation cell arrives as
1401
+ * raw micros (bigint on the native wire, a micros string on the legacy wire),
1402
+ * never as ms. bigint / number / string all stringify to the same digits, so
1403
+ * an int key matches whether it came back typed or as text.
1404
+ */
1405
+ joinKey(v) {
1406
+ if (v == null)
1407
+ return null;
1408
+ if (v instanceof Date)
1409
+ return (BigInt(v.getTime()) * 1000n).toString();
1410
+ if (typeof v === 'bigint')
1411
+ return v.toString();
1412
+ return String(v);
1413
+ }
1414
+ // -------------------------------------------------------------------------
785
1415
  // Writes (reselect — PowDB has no RETURNING)
786
1416
  // -------------------------------------------------------------------------
787
1417
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
@@ -837,8 +1467,8 @@ export class PowqlInterface {
837
1467
  .map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`)
838
1468
  .join(', ');
839
1469
  // `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
840
- const { rows } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout);
841
- const row = rows.length ? this.shape(rows)[0] : null;
1470
+ const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
1471
+ const row = rows.length ? this.shape(rows, native)[0] : null;
842
1472
  if (!row)
843
1473
  throw new NotFoundError({ table: this.table, where: data });
844
1474
  return row;
@@ -855,8 +1485,8 @@ export class PowqlInterface {
855
1485
  return `{ ${assigns.map((a) => `${quotePowqlIdent(a.col.name)} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
856
1486
  });
857
1487
  // Multi-row insert with `returning` hands back every inserted row in one round-trip.
858
- const { rows } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout);
859
- return this.shape(rows);
1488
+ const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
1489
+ return this.shape(rows, native);
860
1490
  });
861
1491
  }
862
1492
  async update(args) {
@@ -870,8 +1500,8 @@ export class PowqlInterface {
870
1500
  this.assertCompiledWhere(where, false, 'update');
871
1501
  const setClause = this.buildUpdateAssignments(args.data, params);
872
1502
  // `returning` hands back the post-update row(s); take the first (single-row contract).
873
- const { rows } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout);
874
- const row = rows.length ? this.shape(rows)[0] : null;
1503
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
1504
+ const row = rows.length ? this.shape(rows, native)[0] : null;
875
1505
  if (!row)
876
1506
  throw new NotFoundError({ table: this.table, where: args.where });
877
1507
  return row;
@@ -885,7 +1515,7 @@ export class PowqlInterface {
885
1515
  this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
886
1516
  const setClause = this.buildUpdateAssignments(args.data, params);
887
1517
  const filter = where ? ` filter ${where}` : '';
888
- const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout);
1518
+ const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
889
1519
  return { count: rowCount };
890
1520
  });
891
1521
  }
@@ -954,6 +1584,11 @@ export class PowqlInterface {
954
1584
  }
955
1585
  /** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
956
1586
  async runInImplicitTx(fn) {
1587
+ // A transaction-control `begin` is a write on a read-only pool: refuse it
1588
+ // locally before checking out a connection (zero wire / pool activity), the
1589
+ // same guard the exec seam applies to plain writes.
1590
+ if (this.pool.readonly === true)
1591
+ throw this.readOnlyError('transaction (begin)');
957
1592
  // Route tx keywords through the dialect (like the SQL path) so this never
958
1593
  // drifts from `powdbDialect`; falls back to the literal lowercase keywords.
959
1594
  const d = this.options.dialect;
@@ -963,7 +1598,10 @@ export class PowqlInterface {
963
1598
  await client.query(d?.beginStatement?.() ?? 'begin');
964
1599
  began = true;
965
1600
  const { TransactionClient } = await import('./client.js');
966
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
1601
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options,
1602
+ // Pass the PowDB pool so its read-only guard + capabilities carry into
1603
+ // the transaction-scoped proxy pool (see createTxPool).
1604
+ this.pool);
967
1605
  const ctx = { schema: this.schema, tx: tx };
968
1606
  // Plant the single-writer re-entrancy marker for the implicit tx's
969
1607
  // subtree (same seam TurbineClient.$transaction uses) — user code that
@@ -1008,8 +1646,8 @@ export class PowqlInterface {
1008
1646
  const where = this.buildWhere(resolvedWhere, params);
1009
1647
  this.assertCompiledWhere(where, false, 'delete');
1010
1648
  // `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
1011
- const { rows } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout);
1012
- const row = rows.length ? this.shape(rows)[0] : null;
1649
+ const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
1650
+ const row = rows.length ? this.shape(rows, native)[0] : null;
1013
1651
  if (!row)
1014
1652
  throw new NotFoundError({ table: this.table, where: args.where });
1015
1653
  return row;
@@ -1022,7 +1660,7 @@ export class PowqlInterface {
1022
1660
  const where = this.buildWhere(resolvedWhere, params);
1023
1661
  this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
1024
1662
  const filter = where ? ` filter ${where}` : '';
1025
- const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout);
1663
+ const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
1026
1664
  return { count: rowCount };
1027
1665
  });
1028
1666
  }
@@ -1044,7 +1682,7 @@ export class PowqlInterface {
1044
1682
  // (verified: "unexpected trailing token … 'returning'"), because it is one
1045
1683
  // atomic insert-or-update, not two branches. So upsert alone keeps the
1046
1684
  // reselect-by-PK fetch; create/update/delete all use `returning`.
1047
- await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
1685
+ await this.exec(`upsert ${this.qt} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout, 'upsert');
1048
1686
  const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
1049
1687
  const row = await this.reselectByPk(createData[pkField], args.timeout);
1050
1688
  if (!row)
@@ -1089,7 +1727,7 @@ export class PowqlInterface {
1089
1727
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1090
1728
  const where = this.buildWhere(resolvedWhere, params);
1091
1729
  const filter = where ? ` filter ${where}` : '';
1092
- const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout);
1730
+ const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
1093
1731
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
1094
1732
  });
1095
1733
  }
@@ -1103,7 +1741,7 @@ export class PowqlInterface {
1103
1741
  const filter = where ? ` filter ${where}` : '';
1104
1742
  const scalar = async (expr) => {
1105
1743
  const params = [...filterParams];
1106
- const { rows } = await this.exec(expr, params, args.timeout);
1744
+ const { rows } = await this.exec(expr, params, args.timeout, 'aggregate');
1107
1745
  const v = rows[0]?.value;
1108
1746
  return v == null || v === 'null' ? null : Number(v);
1109
1747
  };
@@ -1135,90 +1773,196 @@ export class PowqlInterface {
1135
1773
  }
1136
1774
  async groupBy(args) {
1137
1775
  return this.withMiddleware('groupBy', args, async () => {
1138
- // The SQL-only groupBy extensions (DISTINCT ON row source, JSON-path
1139
- // group keys / aggregate targets) have no PowQL equivalent: refuse
1140
- // clearly instead of emitting broken PowQL.
1776
+ // DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
1141
1777
  if (args.distinctOn) {
1142
1778
  throw new UnsupportedFeatureError('groupBy distinctOn row source', 'PowDB');
1143
1779
  }
1144
- for (const entry of args.by) {
1145
- if (typeof entry !== 'string') {
1146
- throw new UnsupportedFeatureError('JSON-path groupBy keys', 'PowDB');
1147
- }
1148
- }
1149
- for (const fn of ['_sum', '_avg', '_min', '_max']) {
1150
- const spec = args[fn];
1151
- if (!spec)
1152
- continue;
1153
- for (const value of Object.values(spec)) {
1154
- if (value !== undefined && typeof value !== 'boolean') {
1155
- throw new UnsupportedFeatureError(`JSON-path ${fn} aggregate targets`, 'PowDB');
1156
- }
1157
- }
1780
+ // JSON-path group keys / aggregate targets (≥ 0.12) are gated once here.
1781
+ const usesJson = args.by.some((e) => typeof e !== 'string') ||
1782
+ ['_sum', '_avg', '_min', '_max'].some((fn) => {
1783
+ const spec = args[fn];
1784
+ return spec !== undefined && Object.values(spec).some((v) => v != null && typeof v === 'object');
1785
+ });
1786
+ if (usesJson) {
1787
+ requireCapability(this.capabilities, 'jsonDocs', 'JSON-path groupBy keys / aggregate targets');
1158
1788
  }
1789
+ // `emitNative` decides whether the query GENERATION needs the legacy-wire
1790
+ // `json_type` discriminator (pool-level capability). The DECODE side reads
1791
+ // the wire that ACTUALLY served the result (`resultNative`, from exec), so
1792
+ // a per-call legacy fallback on a native-capable pool still decodes right.
1793
+ const emitNative = Boolean(this.capabilities.nativeRaw);
1159
1794
  const params = [];
1160
1795
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1161
1796
  const where = this.buildWhere(resolvedWhere, params);
1162
1797
  const filter = where ? ` filter ${where}` : '';
1163
- const groupKeys = args.by.map((f) => this.ref(f));
1164
- // Safe aliases PowQL rejects reserved-word aliases (e.g. `count:`).
1165
- const aliasMap = [];
1166
- let n = 0;
1167
- const proj = args.by.map((f) => this.ref(f));
1168
- if (args._count) {
1169
- aliasMap.push({ alias: `agg_${n++}`, fn: 'count', field: null, outKey: '_count' });
1798
+ // Result-key namespace, mirroring the SQL builder's `claimResultKey`
1799
+ // (query/builder.ts): a group-key / aggregate output-name collision (with
1800
+ // `_count`, another key, or an aggregate output) throws E003.
1801
+ const usedKeys = new Set();
1802
+ const claim = (key, what) => {
1803
+ if (key === '_count' || usedKeys.has(key)) {
1804
+ throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1805
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1806
+ }
1807
+ usedKeys.add(key);
1808
+ };
1809
+ const groupExprs = [];
1810
+ const proj = [];
1811
+ const byOrderExprs = new Map();
1812
+ const byReaders = [];
1813
+ let gkN = 0;
1814
+ let gtN = 0;
1815
+ for (const entry of args.by) {
1816
+ if (typeof entry === 'string') {
1817
+ const col = this.column(entry);
1818
+ claim(entry, `column "${col.name}"`);
1819
+ if (col.name !== entry)
1820
+ claim(col.name, `column "${col.name}"`);
1821
+ groupExprs.push(`.${col.name}`);
1822
+ proj.push(`.${col.name}`);
1823
+ byOrderExprs.set(entry, `.${col.name}`);
1824
+ byReaders.push({ kind: 'plain', resultKey: entry, rowKey: col.name, col });
1825
+ }
1826
+ else {
1827
+ const col = this.column(entry.field);
1828
+ if (!isJsonColumn(col)) {
1829
+ throw new ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
1830
+ }
1831
+ this.assertJsonPath('group key', entry.field, entry.path);
1832
+ const pathExpr = this.jsonPathExpr(col, entry.path, params);
1833
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1834
+ claim(alias, `JSON path on "${entry.field}"`);
1835
+ const gkAlias = `gk_${gkN++}`;
1836
+ groupExprs.push(pathExpr);
1837
+ proj.push(`${gkAlias}: ${pathExpr}`);
1838
+ byOrderExprs.set(alias, `.${gkAlias}`);
1839
+ let discrim;
1840
+ if (!emitNative) {
1841
+ // Legacy wire renders a missing value, JSON null, AND the string
1842
+ // "null" all as the cell "null". `min(json_type(path))` over the
1843
+ // group is "string" ONLY for the string-"null" group and "null"
1844
+ // otherwise (a bare `json_type` projection is not group-correlated).
1845
+ discrim = `gt_${gtN++}`;
1846
+ proj.push(`${discrim}: min(json_type(${pathExpr}))`);
1847
+ }
1848
+ byReaders.push({ kind: 'json', resultKey: alias, rowKey: gkAlias, discrim });
1849
+ }
1850
+ }
1851
+ // Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
1852
+ // aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
1853
+ // expression by user key; `aggOrderExprs` lets orderBy reference the alias.
1854
+ const aggReaders = [];
1855
+ const aggOrderExprs = new Map();
1856
+ const aggInner = new Map();
1857
+ let aggN = 0;
1858
+ // Parity with the SQL builder (query/builder.ts): `_count` is selected by
1859
+ // DEFAULT unless the caller explicitly opts out with `_count: false`, so
1860
+ // every groupBy row carries `_count` and `orderBy: { _count }` works
1861
+ // without requesting it (the alias is seeded into `aggOrderExprs`).
1862
+ const countSelected = args._count === true || args._count === undefined;
1863
+ if (countSelected) {
1864
+ const alias = `agg_${aggN++}`;
1865
+ proj.push(`${alias}: count(*)`);
1866
+ aggReaders.push({ alias, outKey: '_count', numeric: true });
1867
+ aggOrderExprs.set('_count', `.${alias}`);
1170
1868
  }
1171
1869
  for (const fn of ['_sum', '_avg', '_min', '_max']) {
1172
1870
  const spec = args[fn];
1173
1871
  if (!spec)
1174
1872
  continue;
1175
- for (const field of Object.keys(spec).filter((f) => spec[f])) {
1176
- aliasMap.push({ alias: `agg_${n++}`, fn: fn.slice(1), field, outKey: `${fn}:${field}` });
1177
- }
1178
- }
1179
- for (const a of aliasMap) {
1180
- proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
1181
- }
1182
- const having = this.buildHaving(args.having, params);
1183
- // groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
1184
- // equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
1185
- // a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
1186
- // Refuse those keys explicitly; plain by-field ordering still flows through.
1187
- if (args.orderBy) {
1188
- for (const key of Object.keys(args.orderBy)) {
1189
- if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
1190
- throw new UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
1873
+ const powfn = fn.slice(1); // sum/avg/min/max
1874
+ for (const [key, target] of Object.entries(spec)) {
1875
+ if (!target)
1876
+ continue;
1877
+ const alias = `agg_${aggN++}`;
1878
+ if (target === true) {
1879
+ const col = this.column(key);
1880
+ claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
1881
+ const inner = `.${col.name}`;
1882
+ proj.push(`${alias}: ${powfn}(${inner})`);
1883
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric: true });
1884
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1885
+ aggInner.set(key, inner);
1886
+ }
1887
+ else {
1888
+ const col = this.column(target.field);
1889
+ if (!isJsonColumn(col)) {
1890
+ throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
1891
+ }
1892
+ this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
1893
+ const alwaysNumeric = fn === '_sum' || fn === '_avg';
1894
+ if (alwaysNumeric && target.type === 'text') {
1895
+ throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on table "${this.table}": ` +
1896
+ `${fn} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1897
+ }
1898
+ const numeric = alwaysNumeric || target.type === 'numeric';
1899
+ claim(`${fn}_${key}`, `${fn} JSON target "${key}"`);
1900
+ const pathExpr = this.jsonPathExpr(col, target.path, params);
1901
+ const inner = numeric ? `cast(${pathExpr}, "float")` : pathExpr;
1902
+ proj.push(`${alias}: ${powfn}(${inner})`);
1903
+ aggReaders.push({ alias, outKey: `${fn}:${key}`, numeric });
1904
+ aggOrderExprs.set(`${fn}:${key}`, `.${alias}`);
1905
+ aggInner.set(key, inner);
1191
1906
  }
1192
1907
  }
1193
1908
  }
1194
- const order = this.buildOrder(args.orderBy);
1195
- const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1196
- const { rows } = await this.exec(powql, params, args.timeout);
1197
- // Reshape: group keys camel fields + coerced; aggregates nested {_sum:{field}}.
1909
+ const having = this.buildHaving(args.having, params, aggInner);
1910
+ const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
1911
+ const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1912
+ const { rows, native: resultNative } = await this.exec(powql, params, args.timeout, 'groupBy');
1913
+ // Reshape: group keys → user fields (coerced / null-disambiguated),
1914
+ // aggregates → nested `{ _sum: { field } }`; discriminators are stripped.
1915
+ // Group-key cells go through the SAME coercion policy `rowToEntity` uses,
1916
+ // by the wire that actually served this result, so a native-wire int cell
1917
+ // (bigint) or datetime cell (micros) never leaks into the result: it
1918
+ // becomes the same number / Date / PG-text-parity string an embedded /
1919
+ // legacy / SQL groupBy returns for the identical query.
1198
1920
  return rows.map((raw) => {
1199
1921
  const out = {};
1200
- for (const f of args.by) {
1201
- const col = this.column(f);
1202
- out[f] =
1203
- typeof raw[col.name] === 'string' ? coerceScalar(raw[col.name], col.tsType) : raw[col.name];
1922
+ for (const r of byReaders) {
1923
+ if (r.kind === 'plain') {
1924
+ const cell = raw[r.rowKey];
1925
+ out[r.resultKey] = resultNative
1926
+ ? coerceNativeValue(cell, r.col)
1927
+ : typeof cell === 'string'
1928
+ ? coerceScalar(cell, r.col.tsType)
1929
+ : cell;
1930
+ }
1931
+ else {
1932
+ out[r.resultKey] = decodeGroupKeyCell(raw[r.rowKey], r.discrim ? raw[r.discrim] : undefined, resultNative);
1933
+ }
1204
1934
  }
1205
- for (const a of aliasMap) {
1206
- const val = raw[a.alias];
1207
- const num = val == null || val === 'null' ? null : Number(val);
1935
+ for (const a of aggReaders) {
1936
+ const cell = raw[a.alias];
1937
+ const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
1208
1938
  if (a.outKey === '_count')
1209
- out._count = num ?? 0;
1939
+ out._count = v ?? 0;
1210
1940
  else {
1211
1941
  const [bucket, field] = a.outKey.split(':');
1212
1942
  out[bucket] ??= {};
1213
- out[bucket][field] = num;
1943
+ out[bucket][field] = v;
1214
1944
  }
1215
1945
  }
1216
1946
  return out;
1217
1947
  });
1218
1948
  });
1219
1949
  }
1220
- /** `having <expr>` over group aggregates (count/sum/avg/min/max). */
1221
- buildHaving(having, params) {
1950
+ /** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */
1951
+ assertJsonPath(context, field, path) {
1952
+ if (!Array.isArray(path) ||
1953
+ path.length === 0 ||
1954
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
1955
+ throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
1956
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
1957
+ }
1958
+ }
1959
+ /**
1960
+ * `having <expr>` over group aggregates. `_count` compares `count(*)` (parity
1961
+ * with the projection); a per-field aggregate re-emits its inner expression
1962
+ * (from `aggInner` when the field is a requested aggregate, so a JSON-path
1963
+ * aggregate reuses its bound placeholders, else `.field` for a plain column).
1964
+ */
1965
+ buildHaving(having, params, aggInner) {
1222
1966
  if (!having)
1223
1967
  return '';
1224
1968
  const conds = [];
@@ -1236,18 +1980,88 @@ export class PowqlInterface {
1236
1980
  if (spec == null)
1237
1981
  continue;
1238
1982
  if (key === '_count') {
1239
- conds.push(cmp(`count(.${this.meta.primaryKey[0]})`, spec));
1983
+ conds.push(cmp('count(*)', spec));
1240
1984
  }
1241
1985
  else {
1242
1986
  for (const [fn, filter] of Object.entries(spec)) {
1243
1987
  if (filter == null)
1244
1988
  continue;
1245
- conds.push(cmp(`${fn.slice(1)}(${this.ref(key)})`, filter));
1989
+ const inner = aggInner.get(key) ?? this.ref(key);
1990
+ conds.push(cmp(`${fn.slice(1)}(${inner})`, filter));
1246
1991
  }
1247
1992
  }
1248
1993
  }
1249
1994
  return conds.length ? ` having ${conds.join(' and ')}` : '';
1250
1995
  }
1996
+ /**
1997
+ * Compile a groupBy `orderBy` into a PowQL `order` body over the group RESULT
1998
+ * columns (by-fields, JSON group-key aliases, and requested aggregates). PowQL
1999
+ * cannot re-emit an aggregate EXPRESSION in `order` (engine error), but CAN
2000
+ * order by a projection alias on a grouped query (probed), so each key maps to
2001
+ * its projected alias (`.agg_N` / `.gk_N` / `.col`). Semantics and error
2002
+ * surface mirror the SQL `buildGroupByOrderBy` (0.32.2 R3-1): an aggregate not
2003
+ * requested in this call, or an unknown by-key, throws E003 listing the valid
2004
+ * keys. `nulls: 'first'` stays E017 (PowDB has no NULLS placement grammar).
2005
+ */
2006
+ buildGroupOrder(orderBy, byOrderExprs, aggOrderExprs) {
2007
+ if (!orderBy)
2008
+ return '';
2009
+ const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
2010
+ const validKeys = () => {
2011
+ const keys = [...byOrderExprs.keys()];
2012
+ for (const k of aggOrderExprs.keys())
2013
+ keys.push(k.includes(':') ? k.replace(':', '.') : k);
2014
+ return keys.join(', ') || '(none)';
2015
+ };
2016
+ const parts = [];
2017
+ for (const [key, value] of Object.entries(orderBy)) {
2018
+ if (value === undefined)
2019
+ continue;
2020
+ if (aggBlocks.has(key)) {
2021
+ if (key === '_count') {
2022
+ const expr = aggOrderExprs.get('_count');
2023
+ if (!expr) {
2024
+ throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
2025
+ `Orderable keys: ${validKeys()}.`);
2026
+ }
2027
+ parts.push(`${expr} ${this.groupOrderDir(value, '_count')}`);
2028
+ continue;
2029
+ }
2030
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
2031
+ throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
2032
+ `expected a field map like { ${key}: { amount: 'desc' } }.`);
2033
+ }
2034
+ for (const [field, dirSpec] of Object.entries(value)) {
2035
+ if (dirSpec === undefined)
2036
+ continue;
2037
+ const expr = aggOrderExprs.get(`${key}:${field}`);
2038
+ if (!expr) {
2039
+ throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
2040
+ `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
2041
+ }
2042
+ parts.push(`${expr} ${this.groupOrderDir(dirSpec, `${key}.${field}`)}`);
2043
+ }
2044
+ continue;
2045
+ }
2046
+ const expr = byOrderExprs.get(key);
2047
+ if (!expr) {
2048
+ throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
2049
+ }
2050
+ parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
2051
+ }
2052
+ return parts.length ? ` order ${parts.join(', ')}` : '';
2053
+ }
2054
+ /** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
2055
+ groupOrderDir(value, keyForMsg) {
2056
+ if (value !== null && typeof value === 'object') {
2057
+ const spec = value;
2058
+ if (spec.nulls === 'first') {
2059
+ throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `groupBy orderBy "${keyForMsg}": PowDB orders NULLs / missing keys LAST in both directions`);
2060
+ }
2061
+ return spec.sort === 'desc' ? 'desc' : 'asc';
2062
+ }
2063
+ return value === 'desc' ? 'desc' : 'asc';
2064
+ }
1251
2065
  // -------------------------------------------------------------------------
1252
2066
  // Streaming / unsupported
1253
2067
  // -------------------------------------------------------------------------
@@ -1261,12 +2075,12 @@ export class PowqlInterface {
1261
2075
  /** Reselect a single row by its single-column primary key value. */
1262
2076
  async reselectByPk(pkValue, timeout) {
1263
2077
  const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
1264
- const rows = await this.runFind({
2078
+ const { rows, native } = await this.runFind({
1265
2079
  where: { [pkField]: pkValue },
1266
2080
  limit: 1,
1267
2081
  timeout,
1268
2082
  });
1269
- return rows.length ? this.shape(rows)[0] : null;
2083
+ return rows.length ? this.shape(rows, native)[0] : null;
1270
2084
  }
1271
2085
  /**
1272
2086
  * Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
@@ -1301,3 +2115,53 @@ function coerceScalar(raw, tsType) {
1301
2115
  return new Date(Number(raw) / 1000);
1302
2116
  return raw;
1303
2117
  }
2118
+ /**
2119
+ * Decode a JSON group-key cell, resolving the legacy-wire `null` ambiguity AND
2120
+ * normalizing the native typed wire to the SAME PG-`#>>`-text-parity shape.
2121
+ *
2122
+ * On the native typed wire (`native`) a cell arrives pre-typed (a JSON int as a
2123
+ * `bigint`, a bool as `boolean`, an unset value as `null`). Returned as-is that
2124
+ * would diverge from every other transport: the embedded / legacy / SQL wire
2125
+ * all yield the extracted TEXT (`'7'`, `'true'`), and a raw `bigint` even throws
2126
+ * on `JSON.stringify`. So a native scalar cell is rendered to its text form
2127
+ * ({@link nativeJsonKeyText}); `null`/`empty` stays `null`, and a genuine string
2128
+ * `"null"` stays the string (the wart the native wire was adopted to fix).
2129
+ *
2130
+ * On the legacy string wire a missing value, JSON null, AND the string `"null"`
2131
+ * all render the cell `"null"`; the group's `min(json_type(…))` discriminator is
2132
+ * `"string"` ONLY for the string-`"null"` group, so the cell is the string
2133
+ * `"null"` iff the discriminator is `"string"`, else `null`. Other cell values
2134
+ * pass through as the extracted string.
2135
+ */
2136
+ function decodeGroupKeyCell(cell, discrim, native) {
2137
+ if (native)
2138
+ return nativeJsonKeyText(cell);
2139
+ if (cell == null)
2140
+ return null;
2141
+ if (cell === 'null')
2142
+ return discrim === 'string' ? 'null' : null;
2143
+ return cell;
2144
+ }
2145
+ /**
2146
+ * Render a native-wire JSON group-key cell to the extracted-text shape the other
2147
+ * transports return (PG `#>>` / embedded legacy / SQL all give text keys). Keeps
2148
+ * `null` as `null`; a scalar (`bigint`/`number`/`boolean`/`string`) becomes its
2149
+ * string form; an object/array json sub-document is stringified (best-effort
2150
+ * parity: canonical byte-for-byte matching is not guaranteed for nested docs).
2151
+ */
2152
+ function nativeJsonKeyText(cell) {
2153
+ if (cell === null || cell === undefined)
2154
+ return null;
2155
+ if (typeof cell === 'bigint')
2156
+ return cell.toString();
2157
+ if (typeof cell === 'number' || typeof cell === 'boolean')
2158
+ return String(cell);
2159
+ if (typeof cell === 'string')
2160
+ return cell;
2161
+ try {
2162
+ return JSON.stringify(cell);
2163
+ }
2164
+ catch {
2165
+ return String(cell);
2166
+ }
2167
+ }