turbine-orm 0.34.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.
package/dist/powql.js CHANGED
@@ -35,7 +35,7 @@
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
40
  import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
41
  import { isJsonFilter, isRelationPickOrderBy } from './query/filters.js';
@@ -62,6 +62,23 @@ const POWQL_READ_ACTIONS = new Set([
62
62
  'count',
63
63
  'aggregate',
64
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',
65
82
  ]);
66
83
  /** Operator keys recognised inside a `WhereOperator` object. */
67
84
  const OPERATOR_KEYS = new Set([
@@ -145,9 +162,19 @@ export class PowqlInterface {
145
162
  }
146
163
  return col;
147
164
  }
148
- /** PowQL column reference (`.snake_name`) for a field. */
149
- ref(field) {
150
- 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}`;
151
178
  }
152
179
  /**
153
180
  * Push a value into the param array and return its `$N` placeholder. When the
@@ -216,8 +243,15 @@ export class PowqlInterface {
216
243
  /**
217
244
  * Compile a {@link WhereClause} into a PowQL filter expression, pushing every
218
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.
219
253
  */
220
- buildWhere(where, params) {
254
+ buildWhere(where, params, alias) {
221
255
  if (!where)
222
256
  return '';
223
257
  const parts = [];
@@ -225,17 +259,17 @@ export class PowqlInterface {
225
259
  if (value === undefined)
226
260
  continue;
227
261
  if (key === 'AND') {
228
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
262
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
229
263
  if (sub.length)
230
264
  parts.push(`(${sub.join(' and ')})`);
231
265
  }
232
266
  else if (key === 'OR') {
233
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
267
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
234
268
  if (sub.length)
235
269
  parts.push(`(${sub.join(' or ')})`);
236
270
  }
237
271
  else if (key === 'NOT') {
238
- const sub = this.buildWhere(value, params);
272
+ const sub = this.buildWhere(value, params, alias);
239
273
  if (sub)
240
274
  parts.push(`not (${sub})`);
241
275
  }
@@ -248,7 +282,7 @@ export class PowqlInterface {
248
282
  else {
249
283
  // A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
250
284
  // empty results so buildWhere never emits a dangling ` and `.
251
- const cond = this.buildFieldCondition(key, value, params);
285
+ const cond = this.buildFieldCondition(key, value, params, alias);
252
286
  if (cond)
253
287
  parts.push(cond);
254
288
  }
@@ -256,9 +290,9 @@ export class PowqlInterface {
256
290
  return parts.join(' and ');
257
291
  }
258
292
  /** Build a single `field: value | operator` condition. */
259
- buildFieldCondition(field, value, params) {
293
+ buildFieldCondition(field, value, params, alias) {
260
294
  const colMeta = this.column(field);
261
- const ref = this.ref(field);
295
+ const ref = this.ref(field, alias);
262
296
  if (value === null)
263
297
  return `${ref} is null`;
264
298
  if (value instanceof Date || typeof value !== 'object') {
@@ -271,7 +305,7 @@ export class PowqlInterface {
271
305
  // path below (e.g. `equals` stays a plain equality), exactly like SQL.
272
306
  if (isJsonColumn(colMeta) && isJsonFilter(value)) {
273
307
  requireCapability(this.capabilities, 'jsonDocs', 'JSON path filters');
274
- return this.buildJsonPathCondition(colMeta, value, params);
308
+ return this.buildJsonPathCondition(colMeta, value, params, alias);
275
309
  }
276
310
  rejectUnsupportedFilter(op, field);
277
311
  if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
@@ -341,8 +375,8 @@ export class PowqlInterface {
341
375
  * (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
342
376
  * json object whose key is literally `"0"` is addressed as an array index.
343
377
  */
344
- jsonPathExpr(col, path, params) {
345
- let expr = `.${col.name}`;
378
+ jsonPathExpr(col, path, params, alias) {
379
+ let expr = this.colRefName(col.name, alias);
346
380
  for (const seg of path) {
347
381
  const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
348
382
  expr += `->${this.param(bound, params)}`;
@@ -368,13 +402,13 @@ export class PowqlInterface {
368
402
  * with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
369
403
  * by the empty-where guard.
370
404
  */
371
- buildJsonPathCondition(col, filter, params) {
405
+ buildJsonPathCondition(col, filter, params, alias) {
372
406
  const conds = [];
373
407
  // Bind the path segments at most once and reuse the expression string across
374
408
  // equals + range comparisons (they share the same `path`).
375
409
  let pathExpr = null;
376
410
  const pathP = () => {
377
- pathExpr ??= this.jsonPathExpr(col, filter.path, params);
411
+ pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
378
412
  return pathExpr;
379
413
  };
380
414
  if (filter.contains !== undefined) {
@@ -388,7 +422,7 @@ export class PowqlInterface {
388
422
  }
389
423
  if (filter.hasKey !== undefined) {
390
424
  // Top-level key existence, independent of `path` (mirrors PG `col ? key`).
391
- conds.push(`json_type(.${col.name}->${this.param(filter.hasKey, params)}) is not null`);
425
+ conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
392
426
  }
393
427
  // Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
394
428
  // Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
@@ -620,7 +654,7 @@ export class PowqlInterface {
620
654
  * contract): for identical cross-engine results pass `nulls: 'last'`
621
655
  * explicitly on Postgres, which defaults nulls-first for `desc`.
622
656
  */
623
- buildOrder(orderBy, params) {
657
+ buildOrder(orderBy, params, alias) {
624
658
  if (!orderBy)
625
659
  return '';
626
660
  const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
@@ -631,7 +665,7 @@ export class PowqlInterface {
631
665
  const o = dir;
632
666
  // JSON-path ordering on a json column.
633
667
  if (Array.isArray(o.path)) {
634
- return this.buildJsonPathOrder(field, dir, params);
668
+ return this.buildJsonPathOrder(field, dir, params, alias);
635
669
  }
636
670
  // OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
637
671
  // nulls-first (no placement grammar). Distinct from vector/pick/_count.
@@ -640,7 +674,7 @@ export class PowqlInterface {
640
674
  if (spec.nulls === 'first') {
641
675
  throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
642
676
  }
643
- return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
677
+ return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
644
678
  }
645
679
  // Name the actual feature in the refusal — a pick-row ordering
646
680
  // reported as "vector / distance ordering" sends users hunting for
@@ -656,12 +690,12 @@ export class PowqlInterface {
656
690
  : 'object-valued ordering';
657
691
  throw new UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
658
692
  }
659
- return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
693
+ return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
660
694
  });
661
695
  return ` order ${parts.join(', ')}`;
662
696
  }
663
697
  /** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
664
- buildJsonPathOrder(field, spec, params) {
698
+ buildJsonPathOrder(field, spec, params, alias) {
665
699
  const col = this.column(field);
666
700
  if (!isJsonColumn(col)) {
667
701
  throw new UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
@@ -670,7 +704,7 @@ export class PowqlInterface {
670
704
  if (spec.nulls === 'first') {
671
705
  throw new UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
672
706
  }
673
- const pathExpr = this.jsonPathExpr(col, spec.path, params);
707
+ const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
674
708
  // `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
675
709
  // JSON numbers already order numerically without a cast.
676
710
  const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
@@ -690,6 +724,13 @@ export class PowqlInterface {
690
724
  async exec(powql, params, timeout, action = 'raw') {
691
725
  return this.execOnce(powql, params, timeout, action, false);
692
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
+ }
693
734
  /**
694
735
  * Execute one statement, with the opt-in single stale-frame READ replay. When
695
736
  * `retryStaleReads` is on and a first-statement READ fails with the stale-wire
@@ -704,6 +745,14 @@ export class PowqlInterface {
704
745
  * write into a retryable read.
705
746
  */
706
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
+ }
707
756
  const start = performance.now();
708
757
  const run = this.pool.query(powql, params);
709
758
  try {
@@ -789,19 +838,27 @@ export class PowqlInterface {
789
838
  // -------------------------------------------------------------------------
790
839
  async findMany(args = {}) {
791
840
  return this.withMiddleware('findMany', args, async () => {
792
- const { rows, native } = await this.runFind(args, 'findMany');
841
+ const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
793
842
  const entities = this.shape(rows, native);
794
- if (args.with)
795
- await this.loadRelations(entities, args.with, args.timeout);
843
+ if (args.with) {
844
+ await this.loadRelations(entities, args.with, args.timeout, 0, {
845
+ args,
846
+ resolvedWhere,
847
+ });
848
+ }
796
849
  return entities;
797
850
  });
798
851
  }
799
- /** Build + run the flat findMany select; returns raw rows + the serving wire. */
800
- async runFind(args, action = 'findMany') {
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) {
801
859
  if (args.cursor) {
802
860
  throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
803
861
  }
804
- const params = [];
805
862
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
806
863
  const where = this.buildWhere(resolvedWhere, params);
807
864
  const cols = this.projectedColumns(args.select, args.omit);
@@ -816,8 +873,38 @@ export class PowqlInterface {
816
873
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
817
874
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
818
875
  const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
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);
819
882
  const { rows, native } = await this.exec(powql, params, args.timeout, action);
820
- return { rows, native };
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);
821
908
  }
822
909
  async findUnique(args) {
823
910
  return this.withMiddleware('findUnique', args, async () => {
@@ -856,19 +943,48 @@ export class PowqlInterface {
856
943
  // -------------------------------------------------------------------------
857
944
  // Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
858
945
  // -------------------------------------------------------------------------
859
- /** Load each requested relation for `parents` and attach it onto each row. */
860
- 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) {
861
958
  if (depth >= 10) {
862
959
  throw new ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
863
960
  }
864
961
  if (!parents.length)
865
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;
866
969
  for (const [relName, opt] of Object.entries(withClause)) {
867
970
  if (!opt)
868
971
  continue;
869
972
  const rel = this.meta.relations[relName];
870
973
  if (!rel)
871
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
+ }
872
988
  if (rel.type === 'manyToMany') {
873
989
  await this.loadManyToMany(parents, rel, relName, opt, timeout);
874
990
  continue;
@@ -889,23 +1005,49 @@ export class PowqlInterface {
889
1005
  const keys = [
890
1006
  ...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
891
1007
  ];
892
- 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
+ }
893
1029
  // Chunk the key set so a single `in (…)` never exceeds PowDB's
894
- // 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();
895
1035
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
896
1036
  const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
897
1037
  const childWhere = {
898
- ...options.where,
1038
+ ...fetchOptions.where,
899
1039
  [childKeyField]: { in: chunk },
900
1040
  };
901
1041
  const children = (await targetQi.findMany({
902
- ...options,
1042
+ ...fetchOptions,
903
1043
  where: childWhere,
904
1044
  with: options.with,
905
1045
  timeout: options.timeout ?? timeout,
906
1046
  }));
907
1047
  for (const child of children) {
908
- const k = child[childKeyField];
1048
+ const k = this.joinKey(child[childKeyField]);
1049
+ if (k == null)
1050
+ continue;
909
1051
  const bucket = childByKey.get(k);
910
1052
  if (bucket)
911
1053
  bucket.push(child);
@@ -913,10 +1055,18 @@ export class PowqlInterface {
913
1055
  childByKey.set(k, [child]);
914
1056
  }
915
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
+ }
916
1066
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
917
1067
  for (const parent of parents) {
918
- const k = parent[parentKeyField];
919
- const matches = childByKey.get(k) ?? [];
1068
+ const k = this.joinKey(parent[parentKeyField]);
1069
+ const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
920
1070
  parent[relName] = single ? (matches[0] ?? null) : matches;
921
1071
  }
922
1072
  }
@@ -1011,6 +1161,257 @@ export class PowqlInterface {
1011
1161
  }
1012
1162
  }
1013
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
+ // -------------------------------------------------------------------------
1014
1415
  // Writes (reselect — PowDB has no RETURNING)
1015
1416
  // -------------------------------------------------------------------------
1016
1417
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
@@ -1183,6 +1584,11 @@ export class PowqlInterface {
1183
1584
  }
1184
1585
  /** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
1185
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)');
1186
1592
  // Route tx keywords through the dialect (like the SQL path) so this never
1187
1593
  // drifts from `powdbDialect`; falls back to the literal lowercase keywords.
1188
1594
  const d = this.options.dialect;
@@ -1192,7 +1598,10 @@ export class PowqlInterface {
1192
1598
  await client.query(d?.beginStatement?.() ?? 'begin');
1193
1599
  began = true;
1194
1600
  const { TransactionClient } = await import('./client.js');
1195
- 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);
1196
1605
  const ctx = { schema: this.schema, tx: tx };
1197
1606
  // Plant the single-writer re-entrancy marker for the implicit tx's
1198
1607
  // subtree (same seam TurbineClient.$transaction uses) — user code that