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/powdb.js CHANGED
@@ -55,7 +55,7 @@
55
55
  import { AsyncLocalStorage } from 'node:async_hooks';
56
56
  import { TurbineClient, } from './client.js';
57
57
  import { postgresDialect } from './dialect.js';
58
- import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
58
+ import { ConnectionError, NotNullViolationError, ReadOnlyError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
59
59
  import importOptionalPeer from './optional-peer-import.cjs';
60
60
  /**
61
61
  * Capability descriptor for PowDB. PowQL generation is owned by
@@ -195,6 +195,7 @@ const POWDB_FEATURE_MIN_VERSION = {
195
195
  introspection: '0.10',
196
196
  jsonDocs: '0.12',
197
197
  docFieldIndexes: '0.13',
198
+ serverJoins: '0.13',
198
199
  };
199
200
  /**
200
201
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -209,6 +210,7 @@ export const ALL_POWDB_CAPABILITIES = {
209
210
  jsonDocs: true,
210
211
  docFieldIndexes: true,
211
212
  introspection: true,
213
+ serverJoins: true,
212
214
  nativeRaw: false,
213
215
  };
214
216
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -236,6 +238,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
236
238
  jsonDocs: false,
237
239
  docFieldIndexes: false,
238
240
  introspection: false,
241
+ serverJoins: false,
239
242
  nativeRaw: false,
240
243
  };
241
244
  }
@@ -244,6 +247,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
244
247
  introspection: atLeastVersion(sem, 0, 10),
245
248
  jsonDocs: atLeastVersion(sem, 0, 12),
246
249
  docFieldIndexes: atLeastVersion(sem, 0, 13),
250
+ serverJoins: atLeastVersion(sem, 0, 13),
247
251
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
248
252
  };
249
253
  }
@@ -688,9 +692,10 @@ export function wrapPowdbError(err) {
688
692
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
689
693
  return new NotNullViolationError({ column: m?.[1], cause: err });
690
694
  }
691
- // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
692
- // no .code classify by message so both transports surface E004.
693
- if (/pool closed|pool acquire timeout/i.test(msg)) {
695
+ // Driver pool lifecycle errors (acquire after close, acquire timeout, or a
696
+ // statement reaching an already-closed embedded handle) carry no .code:
697
+ // classify by message so both transports surface E004.
698
+ if (/pool closed|pool acquire timeout|database is closed/i.test(msg)) {
694
699
  return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
695
700
  }
696
701
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
@@ -712,6 +717,54 @@ export function wrapPowdbError(err) {
712
717
  /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
713
718
  return new ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
714
719
  }
720
+ // Read-only refusal → ReadOnlyError (E018). Two engine shapes, both mapped by
721
+ // substring (the networked transport prefixes the message with `query failed:
722
+ // `, so never anchor on the start): an embedded database opened read-only for
723
+ // snapshot serving (`readonly mode: statement requires a writer …`), and a
724
+ // networked read-only role (`permission denied: role '<role>' cannot execute
725
+ // write statements`). These run BEFORE the generic validation regex below so a
726
+ // read-only write is surfaced as the routing signal E018, not a query defect.
727
+ // The driver spec (0.15) distinguishes them via `reason`: snapshot mode
728
+ // means "nothing can write here; route writes to the primary", RBAC means
729
+ // "this connection's role may not write here".
730
+ if (/readonly mode: statement requires a writer/i.test(msg)) {
731
+ return new ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
732
+ cause: err,
733
+ reason: 'snapshot',
734
+ });
735
+ }
736
+ if (/permission denied: role/i.test(msg)) {
737
+ return new ReadOnlyError(`PowDB refused a write for a read-only role: ${msg}.`, { cause: err, reason: 'rbac' });
738
+ }
739
+ // Open-time read-only failure: a read-only handle over a directory whose WAL
740
+ // still has uncommitted frames is refused (`cannot open read-only: the WAL is
741
+ // not empty …`). It is a connection failure (E004), not a query defect, the
742
+ // fix is to recover the directory with a writable open first.
743
+ if (/cannot open read-only: the WAL is not empty/i.test(msg)) {
744
+ return new ConnectionError(`[turbine] PowDB could not open the directory read-only: ${msg}. Open it once with a writable handle to ` +
745
+ 'flush the WAL (recover the directory), then reopen it read-only for snapshot serving.', { cause: err });
746
+ }
747
+ // Per-query deadline → TimeoutError (E002). Message-path so it fires on the
748
+ // embedded transport too (code is always 'GenericFailure' there); retryable.
749
+ // Pass the engine prose through the message override (same pattern as the
750
+ // transaction-gate timeout below) so the real "query timeout after <n>ms"
751
+ // survives instead of rendering the placeholder "timed out after 0ms".
752
+ if (/query timeout after/i.test(msg)) {
753
+ return new TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
754
+ }
755
+ // Client-initiated cancellation → ConnectionError (E004). This is FINAL: the
756
+ // issuing client disconnected, so the query was a clean early return, never
757
+ // auto-retry it (the opt-in stale-read retry only replays stale-FRAME reads).
758
+ if (/query cancelled by client disconnect/i.test(msg)) {
759
+ return new ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
760
+ }
761
+ // Bounded join rejection → ValidationError (E003). The engine rejects a pure
762
+ // nested-loop join whose candidate-pair count (or result row count) exceeds
763
+ // the safety bound BEFORE executing, and names the fix in the message, keep
764
+ // that fix-hint intact so the caller knows how to make the join eligible.
765
+ if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
766
+ return new ValidationError(`[turbine] PowDB join rejected: ${msg}`);
767
+ }
715
768
  // Type mismatch / parse / execution / storage / unexpected(token) / row too
716
769
  // large → validation (E003). On the embedded transport these are the only
717
770
  // signal we get (code is always 'GenericFailure'); on the networked path they
@@ -1052,12 +1105,20 @@ export class PowdbPool {
1052
1105
  capabilities;
1053
1106
  /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1054
1107
  retryStaleReads;
1108
+ /**
1109
+ * True when the caller marked this pool read-only (`readonly: true`). Read by
1110
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1111
+ * wire; the engine's own read-only-role refusal (mapped by
1112
+ * {@link wrapPowdbError}) is the backstop for raw / injected paths.
1113
+ */
1114
+ readonly;
1055
1115
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
1056
1116
  this.pool = pool;
1057
1117
  this.toParam = toParam;
1058
1118
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1059
1119
  this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1060
1120
  this.retryStaleReads = options.retryStaleReads ?? false;
1121
+ this.readonly = options.readonly ?? false;
1061
1122
  }
1062
1123
  /**
1063
1124
  * Run one statement on `c`, choosing the lossless native typed wire when the
@@ -1347,10 +1408,14 @@ export function materializePowql(powql, params) {
1347
1408
  }
1348
1409
  /**
1349
1410
  * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
1350
- * `Database`. The embedded addon takes **no params array** its `query(powql)`
1351
- * accepts only a string — so this pool materializes each positional `$N` into a
1352
- * PowQL literal via {@link materializePowql} before handing the text to the
1353
- * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
1411
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
1412
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
1413
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
1414
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
1415
+ * legacy string wire, which takes **no params array** (its `query(powql)`
1416
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
1417
+ * literal via {@link materializePowql} before the text is handed to the engine.
1418
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
1354
1419
  * `rollback`) are issued serially as ordinary queries.
1355
1420
  */
1356
1421
  export class PowdbEmbeddedPool {
@@ -1371,20 +1436,44 @@ export class PowdbEmbeddedPool {
1371
1436
  poolHoldRef = { hold: null };
1372
1437
  /**
1373
1438
  * Feature capabilities of the embedded engine (resolved from the addon
1374
- * package version). `nativeRaw` is always false: the embedded addon exposes
1375
- * no native typed-wire surface (its rows are `string[][]`, the legacy wire).
1439
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
1440
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
1441
+ * addon has no such method, so it stays false and the legacy string wire is
1442
+ * used.
1376
1443
  */
1377
1444
  capabilities;
1378
1445
  /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1379
1446
  retryStaleReads;
1447
+ /**
1448
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
1449
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
1450
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1451
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
1452
+ * backstop for raw / injected paths.
1453
+ */
1454
+ readonly;
1380
1455
  constructor(db, options = {}) {
1381
1456
  this.db = db;
1382
1457
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
1383
1458
  this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
1384
1459
  this.retryStaleReads = options.retryStaleReads ?? false;
1460
+ this.readonly = options.readonly ?? false;
1385
1461
  }
1386
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1462
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
1387
1463
  exec(powql, params) {
1464
+ // Native typed wire (addon ≥ 0.14): bind positional params with the SAME
1465
+ // binder the networked transport uses ({@link toPowdbParam} yields exactly
1466
+ // the NativeParam union null|bigint|number|boolean|string) and decode the
1467
+ // typed cells: a genuine str "null" survives, a json-null document stays
1468
+ // distinct from an absent value. Gated on the resolved capability AND a
1469
+ // per-call feature-detect so a heterogeneous injected handle cannot crash.
1470
+ if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
1471
+ const bound = params.map((v) => toPowdbParam(v));
1472
+ return adaptNativeResult(this.db.queryWithParams(powql, bound));
1473
+ }
1474
+ // Legacy string wire (addon < 0.14): the engine takes no params array, so
1475
+ // materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
1476
+ // live and tested as the pre-0.14 fallback.
1388
1477
  const materialized = materializePowql(powql, params);
1389
1478
  return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1390
1479
  }
@@ -1404,6 +1493,15 @@ export class PowdbEmbeddedPool {
1404
1493
  // transaction callback throws re-entrant E017 fast; independent
1405
1494
  // concurrent ones wait their FIFO turn.
1406
1495
  holdRef.hold = await this.txGate.acquire();
1496
+ // The gate may have handed us the slot AFTER disconnect() closed the
1497
+ // handle (a transaction queued behind an in-flight one, released as the
1498
+ // pool shut down). Re-check before touching the now-closed engine, and
1499
+ // release the slot we just took so the queue keeps draining.
1500
+ if (this.closed) {
1501
+ holdRef.hold.finish();
1502
+ holdRef.hold = null;
1503
+ throw new ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1504
+ }
1407
1505
  }
1408
1506
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1409
1507
  // This context never acquired the gate — its `begin` never ran (the
@@ -1479,13 +1577,15 @@ export class PowdbEmbeddedPool {
1479
1577
  async end() {
1480
1578
  if (this.closed)
1481
1579
  return;
1482
- // The addon exposes no explicit close — drop the reference and let GC /
1483
- // the engine's checkpoint flush. Marking the pool closed makes later
1484
- // queries fail with a typed ConnectionError instead of silently running
1485
- // against a handle the caller believes is gone. Caveat: durability is
1486
- // checkpoint-bound, so hold the process open long enough for the final
1487
- // WAL flush in short scripts.
1488
1580
  this.closed = true;
1581
+ // Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
1582
+ // the final WAL flush completes deterministically before the handle is
1583
+ // dropped. An older addon has no close, dropping the reference and letting
1584
+ // GC / the engine's checkpoint flush is the fallback (durability is then
1585
+ // checkpoint-bound, so a short script must hold the process open long enough
1586
+ // for the final flush). Marking the pool closed makes later queries fail
1587
+ // with a typed ConnectionError instead of running against a gone handle.
1588
+ this.db.close?.();
1489
1589
  }
1490
1590
  }
1491
1591
  // ---------------------------------------------------------------------------
@@ -1552,12 +1652,34 @@ function resolveEmbeddedVersion() {
1552
1652
  return importOptionalPeer.peerPackageVersion('@zvndev/powdb-embedded');
1553
1653
  }
1554
1654
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1555
- async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1556
- const mod = await loadPowdbEmbedded();
1557
- const { embedded: dir, syncMode, memoryLimit } = target;
1655
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
1656
+ const mod = injectedModule ?? (await loadPowdbEmbedded());
1657
+ const { embedded: dir, syncMode, memoryLimit, readonly } = target;
1658
+ // A read-only engine never writes, so a durability selector is meaningless
1659
+ // there, reject the combination loudly rather than silently ignoring one.
1660
+ if (readonly && syncMode !== undefined) {
1661
+ throw new ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
1662
+ }
1558
1663
  let db;
1559
1664
  try {
1560
- if (memoryLimit !== undefined) {
1665
+ if (readonly) {
1666
+ // Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
1667
+ // constructors; feature-detect and fail with a clear version hint if the
1668
+ // installed addon predates them.
1669
+ if (memoryLimit !== undefined) {
1670
+ if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
1671
+ throw new ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
1672
+ }
1673
+ db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
1674
+ }
1675
+ else {
1676
+ if (typeof mod.Database.openReadOnly !== 'function') {
1677
+ throw new ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
1678
+ }
1679
+ db = mod.Database.openReadOnly(dir);
1680
+ }
1681
+ }
1682
+ else if (memoryLimit !== undefined) {
1561
1683
  if (typeof mod.Database.openWithMemoryLimit !== 'function') {
1562
1684
  throw new ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
1563
1685
  }
@@ -1578,11 +1700,19 @@ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1578
1700
  }
1579
1701
  db.setSyncMode(syncMode);
1580
1702
  }
1581
- // Embedded exposes no native typed-wire surface, so nativeRaw is always false.
1703
+ // Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
1704
+ // exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
1705
+ // applies via the version); an older addon has no such method → false.
1582
1706
  const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1583
- hasNativeRaw: false,
1707
+ hasNativeRaw: typeof db.queryWithParams === 'function',
1708
+ });
1709
+ // A read-only target forces the pool's readonly flag; otherwise honor whatever
1710
+ // `poolOptions` (threaded from `options.readonly`) carried.
1711
+ return new PowdbEmbeddedPool(db, {
1712
+ ...poolOptions,
1713
+ capabilities,
1714
+ readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
1584
1715
  });
1585
- return new PowdbEmbeddedPool(db, { ...poolOptions, capabilities });
1586
1716
  }
1587
1717
  /**
1588
1718
  * Bind Turbine to PowDB. `target` is one of:
@@ -1608,6 +1738,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1608
1738
  const poolOptions = {
1609
1739
  transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1610
1740
  retryStaleReads: options.retryStaleReads,
1741
+ readonly: options.readonly,
1611
1742
  };
1612
1743
  const max = options.connectionLimit ?? 10;
1613
1744
  if (typeof target === 'string') {
@@ -1622,7 +1753,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1622
1753
  pool = target;
1623
1754
  }
1624
1755
  else if (isEmbeddedTarget(target)) {
1625
- pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion);
1756
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
1626
1757
  owns = true;
1627
1758
  }
1628
1759
  else if (isPowdbClientPool(target)) {
@@ -1649,6 +1780,7 @@ export async function turbinePowDB(target, schema, options = {}) {
1649
1780
  logging: options.logging,
1650
1781
  defaultLimit: options.defaultLimit,
1651
1782
  warnOnUnlimited: options.warnOnUnlimited,
1783
+ relationLoadStrategy: options.relationLoadStrategy,
1652
1784
  queryInterfaceFactory,
1653
1785
  }, schema);
1654
1786
  if (owns) {
package/dist/powql.d.ts CHANGED
@@ -57,8 +57,16 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
57
57
  constructor(pool: PowdbPool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
58
58
  /** Resolve a camelCase field name (or raw snake) to its column metadata. */
59
59
  private column;
60
- /** PowQL column reference (`.snake_name`) for a field. */
60
+ /**
61
+ * PowQL column reference for a field. Unqualified it is a dotted field
62
+ * reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
63
+ * is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
64
+ * column name is backtick-quoted if it is a reserved word (a qualified
65
+ * `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
66
+ */
61
67
  private ref;
68
+ /** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
69
+ private colRefName;
62
70
  /**
63
71
  * Push a value into the param array and return its `$N` placeholder. When the
64
72
  * value targets a `float` column it is wrapped in {@link PowdbFloatParam} so
@@ -92,6 +100,13 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
92
100
  /**
93
101
  * Compile a {@link WhereClause} into a PowQL filter expression, pushing every
94
102
  * value as a positional `$N` param. Returns `''` when there are no conditions.
103
+ *
104
+ * When `alias` is supplied (the F2 native-join path) every field reference is
105
+ * qualified with it (`.col` → `alias.col`, JSON path bases too); params bind
106
+ * exactly as in the unqualified path. The caller only ever passes an alias for
107
+ * an already-RESOLVED where (relation filters pre-resolved to literal in-lists
108
+ * by {@link resolveRelationFilters}): the relation-key branch below still
109
+ * throws, so an unresolved relation filter can never leak into a join.
95
110
  */
96
111
  private buildWhere;
97
112
  /** Build a single `field: value | operator` condition. */
@@ -189,6 +204,8 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
189
204
  * read. Read statements pass a read-shaped action from {@link POWQL_READ_ACTIONS}.
190
205
  */
191
206
  private exec;
207
+ /** Build the E018 refusal for a write / `begin` on a read-only pool. */
208
+ private readOnlyError;
192
209
  /**
193
210
  * Execute one statement, with the opt-in single stale-frame READ replay. When
194
211
  * `retryStaleReads` is on and a first-statement READ fails with the stale-wire
@@ -217,13 +234,44 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
217
234
  * rare caller with no per-result flag (hand-built test pools). */
218
235
  private shape;
219
236
  findMany(args?: FindManyArgs<T>): Promise<T[]>;
220
- /** Build + run the flat findMany select; returns raw rows + the serving wire. */
237
+ /**
238
+ * Compile the flat findMany select into PowQL (no execution), pushing values
239
+ * into `params`. Returns the query plus the RESOLVED where (relation filters
240
+ * already collapsed to literal in-lists) so the F2 join path can re-emit the
241
+ * exact parent predicate alias-qualified, and so {@link explain} can wrap it.
242
+ */
243
+ private buildFind;
244
+ /** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
221
245
  private runFind;
246
+ /**
247
+ * Diagnostic surface: compile the same PowQL {@link findMany} would run for
248
+ * `args` (no cache) and return the engine's plan as one string per line.
249
+ *
250
+ * Runs as a READ (`explain <query>`), so it is safe on a read-only pool and
251
+ * eligible for the stale-read replay. The line content is engine-owned and is
252
+ * NOT covered by semver (match plan node names / tree shape, never exact
253
+ * bytes; mirrors PowDB's own `explain` contract).
254
+ *
255
+ * Does NOT run through the middleware chain: plan text is a diagnostic, not
256
+ * entity rows, and `QueryInterface.explain` deliberately bypasses middleware
257
+ * too, so both engines agree.
258
+ */
259
+ explain(args?: FindManyArgs<T>): Promise<string[]>;
222
260
  findUnique(args: FindUniqueArgs<T>): Promise<T | null>;
223
261
  findFirst(args?: FindManyArgs<T>): Promise<T | null>;
224
262
  findUniqueOrThrow(args: FindUniqueArgs<T>): Promise<T>;
225
263
  findFirstOrThrow(args?: FindManyArgs<T>): Promise<T>;
226
- /** Load each requested relation for `parents` and attach it onto each row. */
264
+ /**
265
+ * Load each requested relation for `parents` and attach it onto each row.
266
+ *
267
+ * `parent` is supplied ONLY by the top-level {@link findMany} (its args +
268
+ * resolved where). When the effective `relationLoadStrategy` resolves to an
269
+ * explicit `'join'` and the pool advertises `serverJoins`, an eligible
270
+ * top-level relation is loaded with a native PowQL join instead of the keyed
271
+ * loaders (F2); everything else (nested `with` levels, ineligible shapes, and
272
+ * the default `'batched'` strategy) keeps the loaders. Output is byte-equal
273
+ * either way (the join reuses the same stitch / shape helpers).
274
+ */
227
275
  private loadRelations;
228
276
  /**
229
277
  * manyToMany nested read — a three-hop batched loader (no `json_agg`/join
@@ -234,6 +282,88 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
234
282
  * (composite junction keys would need PowQL tuple-`in`, which it lacks).
235
283
  */
236
284
  private loadManyToMany;
285
+ /**
286
+ * Resolve the effective relation-load strategy: the per-query arg wins, then
287
+ * the client config, then the PowDB default of `'batched'` (the keyed
288
+ * loaders). PowDB deliberately does NOT inherit the SQL-side implicit `'join'`
289
+ * default (that would silently flip every existing PowDB user onto brand-new
290
+ * join generation). Only a value the user actually set to `'join'` activates it.
291
+ */
292
+ private resolveStrategy;
293
+ /**
294
+ * Per-relation eligibility for the join path (checked before the serverJoins
295
+ * capability). Any `false` here is a SILENT fallback to the keyed loaders (it
296
+ * is never an error), so an off-page or nested-`with` shape still returns
297
+ * correct rows:
298
+ * - the parent query must not be paged (`limit`/`offset`/`take`, including the
299
+ * configured `defaultLimit`): a parent-filter join under a page would scan
300
+ * children of off-page parents, where the loaders are strictly better;
301
+ * - the relation must not request a nested `with` (its subtree stays on the
302
+ * loaders this round) or a `distinct`;
303
+ * - single-column relation keys only (a composite key falls to the loader,
304
+ * which throws the same E017 as today);
305
+ * - the PARENT-SIDE correlation column must be a single-column PK or unique
306
+ * column, or the INNER join would re-emit one child copy per matching
307
+ * parent row (a non-unique correlation key produces duplicate children the
308
+ * loader never would). For hasMany/hasOne/m2m that column is the relation's
309
+ * `referenceKey` on THIS (fetched) table; for belongsTo it is the
310
+ * `referenceKey` on the TARGET table (the join's non-fetched side);
311
+ * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
312
+ * stitch can't be reproduced by the 3-table join deterministically);
313
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
314
+ * does a to-many relation `limit`/`offset` when the parent set spills past
315
+ * one loader chunk (the loader limits per chunk, the join once globally).
316
+ */
317
+ private joinEligible;
318
+ /**
319
+ * True when `col` is a single-column unique key of `tableMeta`: the sole
320
+ * primary-key column, a single-column entry in `uniqueColumns` (where a
321
+ * per-column `unique: true` and an introspected single-column unique constraint
322
+ * both land), or a single-column unique index. Used by {@link joinEligible} to
323
+ * keep the INNER-join path off relations whose parent-side correlation column
324
+ * can repeat (which would duplicate children).
325
+ */
326
+ private isSingleColumnUnique;
327
+ /** Dispatch one eligible relation to the correct native-join loader. */
328
+ private loadRelationViaJoin;
329
+ /**
330
+ * manyToMany via chained joins: the target (alias `t`) → junction (alias `j`)
331
+ * → the already-fetched side (alias `p`), correlating `__tpk` from the
332
+ * junction's source key. Always a list, stitched exactly like the loader.
333
+ */
334
+ private loadManyToManyViaJoin;
335
+ /**
336
+ * The target column list to project through the join (honouring select/omit),
337
+ * with a loud guard: a real column named `__tpk` would collide with the
338
+ * reserved correlation alias, so refuse rather than silently mis-stitch.
339
+ */
340
+ private joinChildCols;
341
+ /**
342
+ * `{ __tpk: <tpkExpr>, <col>: <childAlias>.<col>, … }`. Each child column is
343
+ * ALIASED to its bare name (a bare qualified ref `c.col` would come back named
344
+ * `c.col`, not `col`) so the stitched rows shape identically to a flat select.
345
+ */
346
+ private joinProjection;
347
+ /**
348
+ * `filter <parentWhere qualified p> [and <relationWhere qualified childAlias>]`.
349
+ * The parent where is the ALREADY-RESOLVED predicate (relation filters collapsed
350
+ * to literal in-lists before the base query ran); the relation where is resolved
351
+ * on the target the same way before qualifying, so a nested relation filter in
352
+ * the relation `where` never reaches the join unresolved. Params bind in order.
353
+ */
354
+ private joinFilter;
355
+ /** Group join rows by their (normalized) `__tpk`, stripping it and shaping each child. */
356
+ private bucketByTpk;
357
+ /**
358
+ * Normalize a correlation key to a stable string map key so a parent's key
359
+ * value (a shaped entity field) and a child row's `__tpk` cell match across
360
+ * wires and column types. A `Date` maps to microseconds
361
+ * (`getTime()` ms times 1000), because a datetime correlation cell arrives as
362
+ * raw micros (bigint on the native wire, a micros string on the legacy wire),
363
+ * never as ms. bigint / number / string all stringify to the same digits, so
364
+ * an int key matches whether it came back typed or as text.
365
+ */
366
+ private joinKey;
237
367
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
238
368
  private scalarData;
239
369
  /**