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/cjs/powql.js CHANGED
@@ -98,6 +98,23 @@ const POWQL_READ_ACTIONS = new Set([
98
98
  'count',
99
99
  'aggregate',
100
100
  'groupBy',
101
+ 'explain',
102
+ ]);
103
+ /**
104
+ * Mutating actions the {@link PowqlInterface} readonly guard refuses locally
105
+ * (before the wire) on a read-only pool. A transaction-control `begin` is
106
+ * guarded separately in {@link PowqlInterface.runInImplicitTx}. Kept keyed on
107
+ * the per-call action string (never `this`-state) so a concurrent read can
108
+ * never be mistaken for one of these.
109
+ */
110
+ const POWQL_WRITE_ACTIONS = new Set([
111
+ 'create',
112
+ 'createMany',
113
+ 'update',
114
+ 'updateMany',
115
+ 'delete',
116
+ 'deleteMany',
117
+ 'upsert',
101
118
  ]);
102
119
  /** Operator keys recognised inside a `WhereOperator` object. */
103
120
  const OPERATOR_KEYS = new Set([
@@ -181,9 +198,19 @@ class PowqlInterface {
181
198
  }
182
199
  return col;
183
200
  }
184
- /** PowQL column reference (`.snake_name`) for a field. */
185
- ref(field) {
186
- return `.${this.column(field).name}`;
201
+ /**
202
+ * PowQL column reference for a field. Unqualified it is a dotted field
203
+ * reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
204
+ * is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
205
+ * column name is backtick-quoted if it is a reserved word (a qualified
206
+ * `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
207
+ */
208
+ ref(field, alias) {
209
+ return this.colRefName(this.column(field).name, alias);
210
+ }
211
+ /** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
212
+ colRefName(name, alias) {
213
+ return alias ? `${alias}.${(0, powdb_js_1.quotePowqlIdent)(name)}` : `.${name}`;
187
214
  }
188
215
  /**
189
216
  * Push a value into the param array and return its `$N` placeholder. When the
@@ -252,8 +279,15 @@ class PowqlInterface {
252
279
  /**
253
280
  * Compile a {@link WhereClause} into a PowQL filter expression, pushing every
254
281
  * value as a positional `$N` param. Returns `''` when there are no conditions.
282
+ *
283
+ * When `alias` is supplied (the F2 native-join path) every field reference is
284
+ * qualified with it (`.col` → `alias.col`, JSON path bases too); params bind
285
+ * exactly as in the unqualified path. The caller only ever passes an alias for
286
+ * an already-RESOLVED where (relation filters pre-resolved to literal in-lists
287
+ * by {@link resolveRelationFilters}): the relation-key branch below still
288
+ * throws, so an unresolved relation filter can never leak into a join.
255
289
  */
256
- buildWhere(where, params) {
290
+ buildWhere(where, params, alias) {
257
291
  if (!where)
258
292
  return '';
259
293
  const parts = [];
@@ -261,17 +295,17 @@ class PowqlInterface {
261
295
  if (value === undefined)
262
296
  continue;
263
297
  if (key === 'AND') {
264
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
298
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
265
299
  if (sub.length)
266
300
  parts.push(`(${sub.join(' and ')})`);
267
301
  }
268
302
  else if (key === 'OR') {
269
- const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
303
+ const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
270
304
  if (sub.length)
271
305
  parts.push(`(${sub.join(' or ')})`);
272
306
  }
273
307
  else if (key === 'NOT') {
274
- const sub = this.buildWhere(value, params);
308
+ const sub = this.buildWhere(value, params, alias);
275
309
  if (sub)
276
310
  parts.push(`not (${sub})`);
277
311
  }
@@ -284,7 +318,7 @@ class PowqlInterface {
284
318
  else {
285
319
  // A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
286
320
  // empty results so buildWhere never emits a dangling ` and `.
287
- const cond = this.buildFieldCondition(key, value, params);
321
+ const cond = this.buildFieldCondition(key, value, params, alias);
288
322
  if (cond)
289
323
  parts.push(cond);
290
324
  }
@@ -292,9 +326,9 @@ class PowqlInterface {
292
326
  return parts.join(' and ');
293
327
  }
294
328
  /** Build a single `field: value | operator` condition. */
295
- buildFieldCondition(field, value, params) {
329
+ buildFieldCondition(field, value, params, alias) {
296
330
  const colMeta = this.column(field);
297
- const ref = this.ref(field);
331
+ const ref = this.ref(field, alias);
298
332
  if (value === null)
299
333
  return `${ref} is null`;
300
334
  if (value instanceof Date || typeof value !== 'object') {
@@ -307,7 +341,7 @@ class PowqlInterface {
307
341
  // path below (e.g. `equals` stays a plain equality), exactly like SQL.
308
342
  if ((0, powdb_js_1.isJsonColumn)(colMeta) && (0, filters_js_1.isJsonFilter)(value)) {
309
343
  (0, powdb_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path filters');
310
- return this.buildJsonPathCondition(colMeta, value, params);
344
+ return this.buildJsonPathCondition(colMeta, value, params, alias);
311
345
  }
312
346
  rejectUnsupportedFilter(op, field);
313
347
  if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
@@ -377,8 +411,8 @@ class PowqlInterface {
377
411
  * (a wrong result, not an error). Same object-key-`'0'` caveat SQL accepts: a
378
412
  * json object whose key is literally `"0"` is addressed as an array index.
379
413
  */
380
- jsonPathExpr(col, path, params) {
381
- let expr = `.${col.name}`;
414
+ jsonPathExpr(col, path, params, alias) {
415
+ let expr = this.colRefName(col.name, alias);
382
416
  for (const seg of path) {
383
417
  const bound = typeof seg === 'string' && /^\d+$/.test(seg) ? Number(seg) : seg;
384
418
  expr += `->${this.param(bound, params)}`;
@@ -404,13 +438,13 @@ class PowqlInterface {
404
438
  * with SQL), so a mutation whose only `where` is a bare `{ path }` is refused
405
439
  * by the empty-where guard.
406
440
  */
407
- buildJsonPathCondition(col, filter, params) {
441
+ buildJsonPathCondition(col, filter, params, alias) {
408
442
  const conds = [];
409
443
  // Bind the path segments at most once and reuse the expression string across
410
444
  // equals + range comparisons (they share the same `path`).
411
445
  let pathExpr = null;
412
446
  const pathP = () => {
413
- pathExpr ??= this.jsonPathExpr(col, filter.path, params);
447
+ pathExpr ??= this.jsonPathExpr(col, filter.path, params, alias);
414
448
  return pathExpr;
415
449
  };
416
450
  if (filter.contains !== undefined) {
@@ -424,7 +458,7 @@ class PowqlInterface {
424
458
  }
425
459
  if (filter.hasKey !== undefined) {
426
460
  // Top-level key existence, independent of `path` (mirrors PG `col ? key`).
427
- conds.push(`json_type(.${col.name}->${this.param(filter.hasKey, params)}) is not null`);
461
+ conds.push(`json_type(${this.colRefName(col.name, alias)}->${this.param(filter.hasKey, params)}) is not null`);
428
462
  }
429
463
  // Range comparisons on the extracted path, in the fixed gt/gte/lt/lte order.
430
464
  // Same validation as SQL `jsonRangeEntries`: `path` required, value a finite
@@ -656,7 +690,7 @@ class PowqlInterface {
656
690
  * contract): for identical cross-engine results pass `nulls: 'last'`
657
691
  * explicitly on Postgres, which defaults nulls-first for `desc`.
658
692
  */
659
- buildOrder(orderBy, params) {
693
+ buildOrder(orderBy, params, alias) {
660
694
  if (!orderBy)
661
695
  return '';
662
696
  const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
@@ -667,7 +701,7 @@ class PowqlInterface {
667
701
  const o = dir;
668
702
  // JSON-path ordering on a json column.
669
703
  if (Array.isArray(o.path)) {
670
- return this.buildJsonPathOrder(field, dir, params);
704
+ return this.buildJsonPathOrder(field, dir, params, alias);
671
705
  }
672
706
  // OrderBySpec { sort, nulls }: accept nulls-last (PowDB default), refuse
673
707
  // nulls-first (no placement grammar). Distinct from vector/pick/_count.
@@ -676,7 +710,7 @@ class PowqlInterface {
676
710
  if (spec.nulls === 'first') {
677
711
  throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
678
712
  }
679
- return `${this.ref(field)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
713
+ return `${this.ref(field, alias)} ${spec.sort === 'desc' ? 'desc' : 'asc'}`;
680
714
  }
681
715
  // Name the actual feature in the refusal — a pick-row ordering
682
716
  // reported as "vector / distance ordering" sends users hunting for
@@ -692,12 +726,12 @@ class PowqlInterface {
692
726
  : 'object-valued ordering';
693
727
  throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
694
728
  }
695
- return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
729
+ return `${this.ref(field, alias)} ${dir === 'desc' ? 'desc' : 'asc'}`;
696
730
  });
697
731
  return ` order ${parts.join(', ')}`;
698
732
  }
699
733
  /** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
700
- buildJsonPathOrder(field, spec, params) {
734
+ buildJsonPathOrder(field, spec, params, alias) {
701
735
  const col = this.column(field);
702
736
  if (!(0, powdb_js_1.isJsonColumn)(col)) {
703
737
  throw new errors_js_1.UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
@@ -706,7 +740,7 @@ class PowqlInterface {
706
740
  if (spec.nulls === 'first') {
707
741
  throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders missing / JSON-null keys LAST in both directions`);
708
742
  }
709
- const pathExpr = this.jsonPathExpr(col, spec.path, params);
743
+ const pathExpr = this.jsonPathExpr(col, spec.path, params, alias);
710
744
  // `type: 'numeric'` casts a JSON STRING number for numeric ordering; native
711
745
  // JSON numbers already order numerically without a cast.
712
746
  const expr = spec.type === 'numeric' ? `cast(${pathExpr}, "float")` : pathExpr;
@@ -726,6 +760,13 @@ class PowqlInterface {
726
760
  async exec(powql, params, timeout, action = 'raw') {
727
761
  return this.execOnce(powql, params, timeout, action, false);
728
762
  }
763
+ /** Build the E018 refusal for a write / `begin` on a read-only pool. */
764
+ readOnlyError(operation) {
765
+ // Pass a clean detail: the ReadOnlyError constructor owns both the
766
+ // `[turbine] ` prefix and the "Route writes to a writable primary." hint,
767
+ // so adding either here would double them.
768
+ return new errors_js_1.ReadOnlyError(`${operation} on "${this.table}" refused: this PowDB connection is read-only.`);
769
+ }
729
770
  /**
730
771
  * Execute one statement, with the opt-in single stale-frame READ replay. When
731
772
  * `retryStaleReads` is on and a first-statement READ fails with the stale-wire
@@ -740,6 +781,14 @@ class PowqlInterface {
740
781
  * write into a retryable read.
741
782
  */
742
783
  async execOnce(powql, params, timeout, action, isRetry) {
784
+ // Read-only pool guard: refuse a write action locally, before the wire, so a
785
+ // read-only target never even attempts the mutation (the engine refusal, if
786
+ // any, is only the backstop for raw/injected paths). `action` is per-call,
787
+ // so a concurrent read is never mistaken for a write. Reads (incl. explain)
788
+ // and non-classified `raw` fall through unchanged.
789
+ if (this.pool.readonly === true && POWQL_WRITE_ACTIONS.has(action)) {
790
+ throw this.readOnlyError(action);
791
+ }
743
792
  const start = performance.now();
744
793
  const run = this.pool.query(powql, params);
745
794
  try {
@@ -825,19 +874,27 @@ class PowqlInterface {
825
874
  // -------------------------------------------------------------------------
826
875
  async findMany(args = {}) {
827
876
  return this.withMiddleware('findMany', args, async () => {
828
- const { rows, native } = await this.runFind(args, 'findMany');
877
+ const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
829
878
  const entities = this.shape(rows, native);
830
- if (args.with)
831
- await this.loadRelations(entities, args.with, args.timeout);
879
+ if (args.with) {
880
+ await this.loadRelations(entities, args.with, args.timeout, 0, {
881
+ args,
882
+ resolvedWhere,
883
+ });
884
+ }
832
885
  return entities;
833
886
  });
834
887
  }
835
- /** Build + run the flat findMany select; returns raw rows + the serving wire. */
836
- async runFind(args, action = 'findMany') {
888
+ /**
889
+ * Compile the flat findMany select into PowQL (no execution), pushing values
890
+ * into `params`. Returns the query plus the RESOLVED where (relation filters
891
+ * already collapsed to literal in-lists) so the F2 join path can re-emit the
892
+ * exact parent predicate alias-qualified, and so {@link explain} can wrap it.
893
+ */
894
+ async buildFind(args, params) {
837
895
  if (args.cursor) {
838
896
  throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
839
897
  }
840
- const params = [];
841
898
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
842
899
  const where = this.buildWhere(resolvedWhere, params);
843
900
  const cols = this.projectedColumns(args.select, args.omit);
@@ -852,8 +909,38 @@ class PowqlInterface {
852
909
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
853
910
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
854
911
  const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
912
+ return { powql, resolvedWhere };
913
+ }
914
+ /** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
915
+ async runFind(args, action = 'findMany') {
916
+ const params = [];
917
+ const { powql, resolvedWhere } = await this.buildFind(args, params);
855
918
  const { rows, native } = await this.exec(powql, params, args.timeout, action);
856
- return { rows, native };
919
+ return { rows, native, resolvedWhere };
920
+ }
921
+ /**
922
+ * Diagnostic surface: compile the same PowQL {@link findMany} would run for
923
+ * `args` (no cache) and return the engine's plan as one string per line.
924
+ *
925
+ * Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
926
+ * eligible for the stale-read replay. The line content is engine-owned and is
927
+ * NOT covered by semver (match plan node names / tree shape, never exact
928
+ * bytes; mirrors PowDB's own `explain` contract).
929
+ *
930
+ * Does NOT run through the middleware chain: plan text is a diagnostic, not
931
+ * entity rows, and `QueryInterface.explain` deliberately bypasses middleware
932
+ * too, so both engines agree.
933
+ */
934
+ async explain(args = {}) {
935
+ const params = [];
936
+ const { powql } = await this.buildFind(args, params);
937
+ const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
938
+ return rows
939
+ .map((r) => {
940
+ const line = r.plan ?? Object.values(r)[0];
941
+ return line == null ? '' : String(line);
942
+ })
943
+ .filter((line) => line.length > 0);
857
944
  }
858
945
  async findUnique(args) {
859
946
  return this.withMiddleware('findUnique', args, async () => {
@@ -892,19 +979,48 @@ class PowqlInterface {
892
979
  // -------------------------------------------------------------------------
893
980
  // Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
894
981
  // -------------------------------------------------------------------------
895
- /** Load each requested relation for `parents` and attach it onto each row. */
896
- async loadRelations(parents, withClause, timeout, depth = 0) {
982
+ /**
983
+ * Load each requested relation for `parents` and attach it onto each row.
984
+ *
985
+ * `parent` is supplied ONLY by the top-level {@link findMany} (its args +
986
+ * resolved where). When the effective `relationLoadStrategy` resolves to an
987
+ * explicit `'join'` and the pool advertises `serverJoins`, an eligible
988
+ * top-level relation is loaded with a native PowQL join instead of the keyed
989
+ * loaders (F2); everything else (nested `with` levels, ineligible shapes, and
990
+ * the default `'batched'` strategy) keeps the loaders. Output is byte-equal
991
+ * either way (the join reuses the same stitch / shape helpers).
992
+ */
993
+ async loadRelations(parents, withClause, timeout, depth = 0, parent) {
897
994
  if (depth >= 10) {
898
995
  throw new errors_js_1.ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
899
996
  }
900
997
  if (!parents.length)
901
998
  return;
999
+ // The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
1000
+ // or a client config the user set). The serverJoins capability is consulted
1001
+ // PER RELATION below, AFTER joinEligible, so a relation that would have
1002
+ // fallen back to the loaders anyway (paged parent, nested `with`, composite
1003
+ // key, …) never triggers the capability's E017.
1004
+ const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
902
1005
  for (const [relName, opt] of Object.entries(withClause)) {
903
1006
  if (!opt)
904
1007
  continue;
905
1008
  const rel = this.meta.relations[relName];
906
1009
  if (!rel)
907
1010
  throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
1011
+ if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
1012
+ if (this.capabilities.serverJoins) {
1013
+ await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout);
1014
+ continue;
1015
+ }
1016
+ // An otherwise-eligible relation the engine cannot join: a PER-QUERY
1017
+ // `relationLoadStrategy: 'join'` is an explicit request, so throw a typed
1018
+ // E017; a CLIENT-LEVEL default silently falls back to the keyed loaders
1019
+ // (so pointing an existing app at an older engine keeps working).
1020
+ if (parent.args.relationLoadStrategy === 'join') {
1021
+ (0, powdb_js_1.requireCapability)(this.capabilities, 'serverJoins', 'native PowQL relation joins');
1022
+ }
1023
+ }
908
1024
  if (rel.type === 'manyToMany') {
909
1025
  await this.loadManyToMany(parents, rel, relName, opt, timeout);
910
1026
  continue;
@@ -925,23 +1041,49 @@ class PowqlInterface {
925
1041
  const keys = [
926
1042
  ...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
927
1043
  ];
928
- const childByKey = new Map();
1044
+ // The loader buckets children by their correlation column, so that column
1045
+ // MUST be in the fetched projection even when the user's select/omit drops
1046
+ // it. Force it into the fetch here and strip it back off the entities after
1047
+ // stitching (the join path already gets this for free via `__tpk`).
1048
+ const userSelect = options.select;
1049
+ const userOmit = options.omit;
1050
+ const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
1051
+ let fetchOptions = options;
1052
+ if (!fkProjected) {
1053
+ if (userSelect) {
1054
+ fetchOptions = {
1055
+ ...options,
1056
+ select: { ...userSelect, [childKeyField]: true },
1057
+ };
1058
+ }
1059
+ else if (userOmit) {
1060
+ const omitWithoutFk = { ...userOmit };
1061
+ delete omitWithoutFk[childKeyField];
1062
+ fetchOptions = { ...options, omit: omitWithoutFk };
1063
+ }
1064
+ }
929
1065
  // Chunk the key set so a single `in (…)` never exceeds PowDB's
930
- // per-statement param / row limits; merge each chunk's children.
1066
+ // per-statement param / row limits; merge each chunk's children. Keys are
1067
+ // normalized through joinKey (a Date maps to micros, matching the child
1068
+ // cell) so a datetime correlation column stitches instead of silently
1069
+ // returning [].
1070
+ const childByKey = new Map();
931
1071
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
932
1072
  const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
933
1073
  const childWhere = {
934
- ...options.where,
1074
+ ...fetchOptions.where,
935
1075
  [childKeyField]: { in: chunk },
936
1076
  };
937
1077
  const children = (await targetQi.findMany({
938
- ...options,
1078
+ ...fetchOptions,
939
1079
  where: childWhere,
940
1080
  with: options.with,
941
1081
  timeout: options.timeout ?? timeout,
942
1082
  }));
943
1083
  for (const child of children) {
944
- const k = child[childKeyField];
1084
+ const k = this.joinKey(child[childKeyField]);
1085
+ if (k == null)
1086
+ continue;
945
1087
  const bucket = childByKey.get(k);
946
1088
  if (bucket)
947
1089
  bucket.push(child);
@@ -949,10 +1091,18 @@ class PowqlInterface {
949
1091
  childByKey.set(k, [child]);
950
1092
  }
951
1093
  }
1094
+ // Strip the forced correlation column back off if the user excluded it,
1095
+ // so the emitted entities match their select/omit exactly.
1096
+ if (!fkProjected) {
1097
+ for (const bucket of childByKey.values()) {
1098
+ for (const child of bucket)
1099
+ delete child[childKeyField];
1100
+ }
1101
+ }
952
1102
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
953
1103
  for (const parent of parents) {
954
- const k = parent[parentKeyField];
955
- const matches = childByKey.get(k) ?? [];
1104
+ const k = this.joinKey(parent[parentKeyField]);
1105
+ const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
956
1106
  parent[relName] = single ? (matches[0] ?? null) : matches;
957
1107
  }
958
1108
  }
@@ -1047,6 +1197,257 @@ class PowqlInterface {
1047
1197
  }
1048
1198
  }
1049
1199
  // -------------------------------------------------------------------------
1200
+ // Nested relations: native PowQL joins (F2, opt-in via relationLoadStrategy)
1201
+ // -------------------------------------------------------------------------
1202
+ /**
1203
+ * Resolve the effective relation-load strategy: the per-query arg wins, then
1204
+ * the client config, then the PowDB default of `'batched'` (the keyed
1205
+ * loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
1206
+ * default (that would silently flip every existing PowDB user onto brand-new
1207
+ * join generation). Only a value the user actually set to `'join'` activates it.
1208
+ */
1209
+ resolveStrategy(args) {
1210
+ const s = args.relationLoadStrategy ?? this.options.relationLoadStrategy ?? 'batched';
1211
+ return s === 'join' ? 'join' : 'batched';
1212
+ }
1213
+ /**
1214
+ * Per-relation eligibility for the join path (checked before the serverJoins
1215
+ * capability). Any `false` here is a SILENT fallback to the keyed loaders (it
1216
+ * is never an error), so an off-page or nested-`with` shape still returns
1217
+ * correct rows:
1218
+ * - the parent query must not be paged (`limit`/`offset`/`take`, including the
1219
+ * configured `defaultLimit`): a parent-filter join under a page would scan
1220
+ * children of off-page parents, where the loaders are strictly better;
1221
+ * - the relation must not request a nested `with` (its subtree stays on the
1222
+ * loaders this round) or a `distinct`;
1223
+ * - single-column relation keys only (a composite key falls to the loader,
1224
+ * which throws the same E017 as today);
1225
+ * - the PARENT-SIDE correlation column must be a single-column PK or unique
1226
+ * column, or the INNER join would re-emit one child copy per matching
1227
+ * parent row (a non-unique correlation key produces duplicate children the
1228
+ * loader never would). For hasMany/hasOne/m2m that column is the relation's
1229
+ * `referenceKey` on THIS (fetched) table; for belongsTo it is the
1230
+ * `referenceKey` on the TARGET table (the join's non-fetched side);
1231
+ * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
1232
+ * stitch can't be reproduced by the 3-table join deterministically);
1233
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
1234
+ * does a to-many relation `limit`/`offset` when the parent set spills past
1235
+ * one loader chunk (the loader limits per chunk, the join once globally).
1236
+ */
1237
+ joinEligible(rel, opt, args, parentCount) {
1238
+ const effLimit = args.limit ?? args.take ?? this.defaultLimit;
1239
+ if (effLimit !== undefined || args.offset)
1240
+ return false;
1241
+ const options = (opt === true ? {} : opt);
1242
+ if (options.with)
1243
+ return false;
1244
+ if (options.distinct?.length)
1245
+ return false;
1246
+ if (rel.type === 'manyToMany') {
1247
+ const through = rel.through;
1248
+ if (!through)
1249
+ return false;
1250
+ if ((0, schema_js_1.normalizeKeyColumns)(through.sourceKey).length > 1 ||
1251
+ (0, schema_js_1.normalizeKeyColumns)(through.targetKey).length > 1 ||
1252
+ (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1 ||
1253
+ (this.schema.tables[rel.to]?.primaryKey.length ?? 2) > 1) {
1254
+ return false;
1255
+ }
1256
+ if (options.orderBy || options.limit !== undefined || options.offset)
1257
+ return false;
1258
+ // The parent joins on its referenceKey; a non-unique one duplicates.
1259
+ if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0]))
1260
+ return false;
1261
+ return true;
1262
+ }
1263
+ if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length > 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1) {
1264
+ return false;
1265
+ }
1266
+ // Reject a non-unique correlation key: on belongsTo the fetched side joins on
1267
+ // the target's referenceKey, otherwise the fetched side joins on its own.
1268
+ if (rel.type === 'belongsTo') {
1269
+ const targetMeta = this.schema.tables[rel.to];
1270
+ if (!targetMeta || !this.isSingleColumnUnique(targetMeta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
1271
+ return false;
1272
+ }
1273
+ }
1274
+ else if (!this.isSingleColumnUnique(this.meta, (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0])) {
1275
+ return false;
1276
+ }
1277
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1278
+ if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
1279
+ return false;
1280
+ }
1281
+ return true;
1282
+ }
1283
+ /**
1284
+ * True when `col` is a single-column unique key of `tableMeta`: the sole
1285
+ * primary-key column, a single-column entry in `uniqueColumns` (where a
1286
+ * per-column `unique: true` and an introspected single-column unique constraint
1287
+ * both land), or a single-column unique index. Used by {@link joinEligible} to
1288
+ * keep the INNER-join path off relations whose parent-side correlation column
1289
+ * can repeat (which would duplicate children).
1290
+ */
1291
+ isSingleColumnUnique(tableMeta, col) {
1292
+ if (tableMeta.primaryKey.length === 1 && tableMeta.primaryKey[0] === col)
1293
+ return true;
1294
+ if (tableMeta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === col))
1295
+ return true;
1296
+ return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
1297
+ }
1298
+ /** Dispatch one eligible relation to the correct native-join loader. */
1299
+ async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout) {
1300
+ if (rel.type === 'manyToMany') {
1301
+ await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout);
1302
+ return;
1303
+ }
1304
+ const options = (opt === true ? {} : opt);
1305
+ const targetMeta = this.schema.tables[rel.to];
1306
+ if (!targetMeta)
1307
+ throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
1308
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1309
+ const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
1310
+ const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
1311
+ // Correlation math is identical to the keyed loaders, only the transport
1312
+ // (join vs in-list) changes. Always join the RELATION TARGET (alias `c`) to
1313
+ // the already-fetched side (alias `p`), correlating on the fetched side's key
1314
+ // and projecting `__tpk` from the fetched side's correlation column.
1315
+ const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
1316
+ const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
1317
+ const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
1318
+ const params = [];
1319
+ const childCols = this.joinChildCols(targetQi, options);
1320
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
1321
+ const order = targetQi.buildOrder(options.orderBy, params, 'c');
1322
+ const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
1323
+ const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
1324
+ const proj = this.joinProjection(childCols, `p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}`, 'c');
1325
+ const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
1326
+ `on c.${(0, powdb_js_1.quotePowqlIdent)(childKeyCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}` +
1327
+ `${filter}${order}${limitClause}${offsetClause} ${proj}`;
1328
+ // A READ: thread a read-shaped action through the exec seam.
1329
+ const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
1330
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1331
+ const byKey = this.bucketByTpk(targetQi, rows, native);
1332
+ for (const p of parents) {
1333
+ const key = this.joinKey(p[parentKeyField]);
1334
+ const matches = (key == null ? undefined : byKey.get(key)) ?? [];
1335
+ p[relName] = single ? (matches[0] ?? null) : matches;
1336
+ }
1337
+ }
1338
+ /**
1339
+ * manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
1340
+ * → the already-fetched side (alias `p`), correlating `__tpk` from the
1341
+ * junction's source key. Always a list, stitched exactly like the loader.
1342
+ */
1343
+ async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout) {
1344
+ const through = rel.through;
1345
+ if (!through)
1346
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
1347
+ const options = (opt === true ? {} : opt);
1348
+ const targetMeta = this.schema.tables[rel.to];
1349
+ if (!targetMeta)
1350
+ throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
1351
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1352
+ const sourceJCol = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey)[0];
1353
+ const targetJCol = (0, schema_js_1.normalizeKeyColumns)(through.targetKey)[0];
1354
+ const sourceRefCol = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
1355
+ const targetPkCol = targetMeta.primaryKey[0];
1356
+ const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
1357
+ const params = [];
1358
+ const childCols = this.joinChildCols(targetQi, options);
1359
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
1360
+ const proj = this.joinProjection(childCols, `j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)}`, 't');
1361
+ const powql = `${targetQi.qt} as t ` +
1362
+ `join ${(0, powdb_js_1.quotePowqlIdent)(through.table)} as j on t.${(0, powdb_js_1.quotePowqlIdent)(targetPkCol)} = j.${(0, powdb_js_1.quotePowqlIdent)(targetJCol)} ` +
1363
+ `join ${this.qt} as p on j.${(0, powdb_js_1.quotePowqlIdent)(sourceJCol)} = p.${(0, powdb_js_1.quotePowqlIdent)(sourceRefCol)}` +
1364
+ `${filter} ${proj}`;
1365
+ const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
1366
+ const byKey = this.bucketByTpk(targetQi, rows, native);
1367
+ for (const p of parents) {
1368
+ const key = this.joinKey(p[parentRefField]);
1369
+ p[relName] = (key == null ? undefined : byKey.get(key)) ?? [];
1370
+ }
1371
+ }
1372
+ /**
1373
+ * The target column list to project through the join (honouring select/omit),
1374
+ * with a loud guard: a real column named `__tpk` would collide with the
1375
+ * reserved correlation alias, so refuse rather than silently mis-stitch.
1376
+ */
1377
+ joinChildCols(targetQi, options) {
1378
+ const cols = targetQi.projectedColumns(options.select, options.omit);
1379
+ if (cols.includes('__tpk')) {
1380
+ throw new errors_js_1.ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
1381
+ `join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
1382
+ }
1383
+ return cols;
1384
+ }
1385
+ /**
1386
+ * `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
1387
+ * ALIASED to its bare name (a bare qualified ref `c.col` would come back named
1388
+ * `c.col`, not `col`) so the stitched rows shape identically to a flat select.
1389
+ */
1390
+ joinProjection(childCols, tpkExpr, childAlias) {
1391
+ const parts = [
1392
+ `__tpk: ${tpkExpr}`,
1393
+ ...childCols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: ${childAlias}.${(0, powdb_js_1.quotePowqlIdent)(c)}`),
1394
+ ];
1395
+ return `{ ${parts.join(', ')} }`;
1396
+ }
1397
+ /**
1398
+ * `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
1399
+ * The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
1400
+ * to literal in-lists before the base query ran); the relation where is resolved
1401
+ * on the target the same way before qualifying, so a nested relation filter in
1402
+ * the relation `where` never reaches the join unresolved. Params bind in order.
1403
+ */
1404
+ async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
1405
+ const parts = [];
1406
+ const pw = this.buildWhere(parentResolvedWhere, params, 'p');
1407
+ if (pw)
1408
+ parts.push(pw);
1409
+ const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
1410
+ const rw = targetQi.buildWhere(relResolved, params, childAlias);
1411
+ if (rw)
1412
+ parts.push(rw);
1413
+ return parts.length ? ` filter ${parts.join(' and ')}` : '';
1414
+ }
1415
+ /** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
1416
+ bucketByTpk(targetQi, rows, native) {
1417
+ const byKey = new Map();
1418
+ for (const raw of rows) {
1419
+ const tpk = this.joinKey(raw.__tpk);
1420
+ delete raw.__tpk;
1421
+ const child = targetQi.shape([raw], native)[0];
1422
+ if (tpk == null)
1423
+ continue;
1424
+ const bucket = byKey.get(tpk);
1425
+ if (bucket)
1426
+ bucket.push(child);
1427
+ else
1428
+ byKey.set(tpk, [child]);
1429
+ }
1430
+ return byKey;
1431
+ }
1432
+ /**
1433
+ * Normalize a correlation key to a stable string map key so a parent's key
1434
+ * value (a shaped entity field) and a child row's `__tpk` cell match across
1435
+ * wires and column types. A `Date` maps to microseconds
1436
+ * (`getTime()` ms times 1000), because a datetime correlation cell arrives as
1437
+ * raw micros (bigint on the native wire, a micros string on the legacy wire),
1438
+ * never as ms. bigint / number / string all stringify to the same digits, so
1439
+ * an int key matches whether it came back typed or as text.
1440
+ */
1441
+ joinKey(v) {
1442
+ if (v == null)
1443
+ return null;
1444
+ if (v instanceof Date)
1445
+ return (BigInt(v.getTime()) * 1000n).toString();
1446
+ if (typeof v === 'bigint')
1447
+ return v.toString();
1448
+ return String(v);
1449
+ }
1450
+ // -------------------------------------------------------------------------
1050
1451
  // Writes (reselect — PowDB has no RETURNING)
1051
1452
  // -------------------------------------------------------------------------
1052
1453
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
@@ -1219,6 +1620,11 @@ class PowqlInterface {
1219
1620
  }
1220
1621
  /** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
1221
1622
  async runInImplicitTx(fn) {
1623
+ // A transaction-control `begin` is a write on a read-only pool: refuse it
1624
+ // locally before checking out a connection (zero wire / pool activity), the
1625
+ // same guard the exec seam applies to plain writes.
1626
+ if (this.pool.readonly === true)
1627
+ throw this.readOnlyError('transaction (begin)');
1222
1628
  // Route tx keywords through the dialect (like the SQL path) so this never
1223
1629
  // drifts from `powdbDialect`; falls back to the literal lowercase keywords.
1224
1630
  const d = this.options.dialect;
@@ -1228,7 +1634,10 @@ class PowqlInterface {
1228
1634
  await client.query(d?.beginStatement?.() ?? 'begin');
1229
1635
  began = true;
1230
1636
  const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('./client.js')));
1231
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
1637
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options,
1638
+ // Pass the PowDB pool so its read-only guard + capabilities carry into
1639
+ // the transaction-scoped proxy pool (see createTxPool).
1640
+ this.pool);
1232
1641
  const ctx = { schema: this.schema, tx: tx };
1233
1642
  // Plant the single-writer re-entrancy marker for the implicit tx's
1234
1643
  // subtree (same seam TurbineClient.$transaction uses) — user code that