turbine-orm 0.38.1 → 0.39.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/README.md CHANGED
@@ -895,7 +895,7 @@ Everything is honest about what ports and what doesn't. Features marked **PG-onl
895
895
 
896
896
  **Engine notes:** SQLite uses `RETURNING` (≥ 3.35) just like Postgres. MySQL has no `RETURNING`, so writes re-`SELECT` the affected row and **`createMany` returns `[]`** (the rows ARE inserted — re-query if you need them). SQL Server returns rows via `OUTPUT`/`MERGE`; `DISTINCT ON` is Postgres-only. Only Postgres streams via a true cursor (constant memory); the other engines' `findManyStream` materializes the result then yields it in batches. Optimistic locking throws `OptimisticLockError` on all engines (on MySQL the conflict is detected from the version-checked UPDATE's affected-row count). The `turbine` CLI (`generate`, `migrate`) is currently PostgreSQL-only — point the engine factories at a hand-written or programmatically introspected `SCHEMA`.
897
897
 
898
- **PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. Writes use a trailing **`returning`** keyword (upsert reselects by PK). PKs are server-assigned `auto` ints **or** client UUIDs. Nested relations load client-side (N+1, including many-to-many via the junction no `json_agg`). Nested writes cover hasMany/hasOne/belongsTo; many-to-many nested writes are not supported. Transactions are single-writer: concurrent `$transaction` calls queue FIFO (bounded by `transactionQueueTimeoutMs`); nested/re-entrant transactions throw typed errors (no savepoints). Schema is code-first via `defineSchema` — `schemaDefToMetadata()` bridges it to any engine that needs runtime metadata, and a programmatic `describe`-based introspector exists since 0.34 (relations excluded). JSON documents are first-class on engine 0.12+: `JsonFilter` where-filters, JSON-path `orderBy`/`groupBy`, doc-field expression indexes, and a lossless native wire (0.13+) that keeps JSON `null`, missing fields, and the string `"null"` distinct. Embedded `syncMode: 'normal'` moves fsync off the commit path; the networked transport runs the same data over a socket. Cursor streaming and the Postgres-only trio (pgvector / LISTEN/NOTIFY / RLS session GUCs) throw `UnsupportedFeatureError`. Full details: **[turbineorm.dev/engines#powdb](https://turbineorm.dev/engines#powdb)**.
898
+ **PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. Writes use a trailing **`returning`** keyword (upsert reselects by PK). PKs are server-assigned `auto` ints **or** client UUIDs. Nested relations run as **one statement** on engine 0.18+ (PowQL nested projections — per-parent order/limit, childless parents kept, the same single-query shape as Postgres `json_agg`); older engines and ineligible shapes (many-to-many via the junction) load client-side with identical output. Nested writes cover hasMany/hasOne/belongsTo; many-to-many nested writes are not supported. Transactions are single-writer: concurrent `$transaction` calls queue FIFO (bounded by `transactionQueueTimeoutMs`); nested/re-entrant transactions throw typed errors (no savepoints). Schema is code-first via `defineSchema` — `schemaDefToMetadata()` bridges it to any engine that needs runtime metadata, and a programmatic `describe`-based introspector exists since 0.34 (relations excluded). JSON documents are first-class on engine 0.12+: `JsonFilter` where-filters, JSON-path `orderBy`/`groupBy`, doc-field expression indexes, and a lossless native wire (0.13+) that keeps JSON `null`, missing fields, and the string `"null"` distinct. Embedded `syncMode: 'normal'` moves fsync off the commit path; the networked transport runs the same data over a socket. Cursor streaming and the Postgres-only trio (pgvector / LISTEN/NOTIFY / RLS session GUCs) throw `UnsupportedFeatureError`. Full details: **[turbineorm.dev/engines#powdb](https://turbineorm.dev/engines#powdb)**.
899
899
 
900
900
  Full setup, signatures, and the complete support matrix: **[turbineorm.dev/engines](https://turbineorm.dev/engines)**.
901
901
 
package/dist/cjs/powdb.js CHANGED
@@ -253,6 +253,7 @@ const POWDB_FEATURE_MIN_VERSION = {
253
253
  jsonDocs: '0.12',
254
254
  docFieldIndexes: '0.13',
255
255
  serverJoins: '0.13',
256
+ nestedProjections: '0.18',
256
257
  };
257
258
  /**
258
259
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -260,7 +261,9 @@ const POWDB_FEATURE_MIN_VERSION = {
260
261
  * did not go through {@link turbinePowDB}'s version probe (e.g. an injected
261
262
  * pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
262
263
  * actual wire path and must only be enabled after a real server-version probe,
263
- * never inferred from a bare construction.
264
+ * never inferred from a bare construction. `nestedProjections` stays OFF for
265
+ * the same reason: it changes the generated PowQL for every `with` query, and
266
+ * an unprobed engine below 0.18 would reject the syntax outright.
264
267
  */
265
268
  exports.ALL_POWDB_CAPABILITIES = {
266
269
  engineVersion: null,
@@ -268,6 +271,7 @@ exports.ALL_POWDB_CAPABILITIES = {
268
271
  docFieldIndexes: true,
269
272
  introspection: true,
270
273
  serverJoins: true,
274
+ nestedProjections: false,
271
275
  nativeRaw: false,
272
276
  };
273
277
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -296,6 +300,7 @@ function capabilitiesFromVersion(version, opts = {}) {
296
300
  docFieldIndexes: false,
297
301
  introspection: false,
298
302
  serverJoins: false,
303
+ nestedProjections: false,
299
304
  nativeRaw: false,
300
305
  };
301
306
  }
@@ -305,6 +310,7 @@ function capabilitiesFromVersion(version, opts = {}) {
305
310
  jsonDocs: atLeastVersion(sem, 0, 12),
306
311
  docFieldIndexes: atLeastVersion(sem, 0, 13),
307
312
  serverJoins: atLeastVersion(sem, 0, 13),
313
+ nestedProjections: atLeastVersion(sem, 0, 18),
308
314
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
309
315
  };
310
316
  }
@@ -731,7 +737,10 @@ function rowToEntity(raw, meta, native = false) {
731
737
  * `Execution("type mismatch …")`, `Parse(…)`, `StorageError(…)`).
732
738
  *
733
739
  * So we always run the unique-constraint and message-shape checks first (they
734
- * fire for both transports), then fall through to the networked `.code` switch.
740
+ * fire for both transports and extract detail like constraint / column names),
741
+ * then classify by the typed wire error class (`.wireErrorClass`, networked
742
+ * server >= 0.17 — accurate even when the server sanitized the message text),
743
+ * then fall through to the networked `.code` switch.
735
744
  */
736
745
  function wrapPowdbError(err) {
737
746
  if (!err || typeof err !== 'object')
@@ -822,6 +831,46 @@ function wrapPowdbError(err) {
822
831
  if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
823
832
  return new errors_js_1.ValidationError(`[turbine] PowDB join rejected: ${msg}`);
824
833
  }
834
+ // Typed wire error class (networked, server >= 0.17): the client surfaces the
835
+ // stable one-byte class from the error frame as `.wireErrorClass`. Classify by
836
+ // it BEFORE the generic message regexes: the server sanitizes non-allowlisted
837
+ // messages to the generic "query execution error" text, but the class is
838
+ // derived from the typed error at response-build time, so it stays accurate
839
+ // when the message no longer is. The specific message families above still run
840
+ // first because they extract richer detail (constraint / column names) that
841
+ // the class byte cannot carry. Class values are append-only wire contract
842
+ // (docs/errors.md upstream); unknown / absent classes fall through to the
843
+ // pre-0.17 message + `.code` behavior.
844
+ const wireClass = err.wireErrorClass;
845
+ if (typeof wireClass === 'number') {
846
+ switch (wireClass) {
847
+ case 3: // timeout (per-query budget, gate wait, idle timeout) — retryable
848
+ return new errors_js_1.TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
849
+ case 4: // limit_exceeded (memory / size budget) — a query-shape defect
850
+ return new errors_js_1.ValidationError(`[turbine] PowDB resource limit exceeded: ${msg}`);
851
+ case 5: // readonly_refused — the snapshot-serving routing signal
852
+ return new errors_js_1.ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
853
+ cause: err,
854
+ reason: 'snapshot',
855
+ });
856
+ case 6: // auth_failed at CONNECT
857
+ return new errors_js_1.ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
858
+ case 7: // rate_limited (repeated bad auth) — connection-establishment class
859
+ return new errors_js_1.ConnectionError(`[turbine] PowDB rate-limited this address after repeated failed authentication: ${msg}. Wait before retrying.`, { cause: err });
860
+ case 8: {
861
+ // constraint_violation — today that is always a unique index
862
+ const m = /on\s+\S+\.(\w+)/i.exec(msg);
863
+ return new errors_js_1.UniqueConstraintError({ constraint: m?.[1], cause: err });
864
+ }
865
+ case 9: // cancelled (issuing client disconnected) — final, never retry
866
+ return new errors_js_1.ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
867
+ case 1: // parse
868
+ case 2: // execution
869
+ return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
870
+ default: // internal (0) or an unknown future class: fall through
871
+ break;
872
+ }
873
+ }
825
874
  // Type mismatch / parse / execution / storage / unexpected(token) / row too
826
875
  // large → validation (E003). On the embedded transport these are the only
827
876
  // signal we get (code is always 'GenericFailure'); on the networked path they
@@ -1449,7 +1498,7 @@ function encodePowqlLiteral(value) {
1449
1498
  * the ceiling yet still routes through the string wire is refused rather than
1450
1499
  * materialized.
1451
1500
  */
1452
- exports.POWQL_LEXER_TESTED_CEILING = '0.16';
1501
+ exports.POWQL_LEXER_TESTED_CEILING = '0.18';
1453
1502
  /** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
1454
1503
  function encodePowqlString(s) {
1455
1504
  let out = '"';
package/dist/cjs/powql.js CHANGED
@@ -930,30 +930,63 @@ class PowqlInterface {
930
930
  // -------------------------------------------------------------------------
931
931
  async findMany(args = {}) {
932
932
  return this.withMiddleware('findMany', args, async () => {
933
- const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
933
+ const { rows, native, resolvedWhere, nestedPlans, residualWith } = await this.runFind(args, 'findMany');
934
934
  const entities = this.shape(rows, native);
935
- if (args.with) {
936
- await this.loadRelations(entities, args.with, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
935
+ if (nestedPlans.length)
936
+ this.attachNestedRows(entities, nestedPlans);
937
+ if (residualWith) {
938
+ await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
937
939
  }
938
940
  return entities;
939
941
  });
940
942
  }
941
943
  /**
942
- * Compile the flat findMany select into PowQL (no execution), pushing values
943
- * into `params`. Returns the query plus the RESOLVED where (relation filters
944
+ * Compile the findMany select into PowQL (no execution), pushing values into
945
+ * `params`. Returns the query plus the RESOLVED where (relation filters
944
946
  * already collapsed to literal in-lists) so the F2 join path can re-emit the
945
947
  * exact parent predicate alias-qualified, and so {@link explain} can wrap it.
948
+ *
949
+ * When the engine supports nested projections (>= 0.18) and the strategy
950
+ * does not opt out, eligible `with` relations compile INTO this statement as
951
+ * nested-projection blocks (`nestedPlans`) — one round-trip for the whole
952
+ * shape — and only the ineligible remainder (`residualWith`) goes to the
953
+ * post-execution loaders. Without nesting the emitted PowQL is byte-identical
954
+ * to the pre-0.18 output (no alias, `.col` refs).
946
955
  */
947
956
  async buildFind(args, params) {
948
957
  if (args.cursor) {
949
958
  throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
950
959
  }
951
960
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
952
- const where = this.buildWhere(resolvedWhere, params);
953
961
  const cols = this.projectedColumns(args.select, args.omit, args.includePii === true);
962
+ // Partition the `with` clause: nested-projection blocks vs loader residue.
963
+ // A parent `distinct` never nests (distinct over a row containing a JSON
964
+ // array is not a defined comparison), and a relation whose field name
965
+ // collides with a projected parent column stays on the loaders (its block
966
+ // key would duplicate the column's).
967
+ const withClause = args.with;
968
+ const nestedPlans = [];
969
+ let residualWith = withClause;
970
+ if (withClause && !args.distinct?.length && this.nestedProjectionsPreferred(args)) {
971
+ const residue = {};
972
+ for (const [relName, opt] of Object.entries(withClause)) {
973
+ if (!opt)
974
+ continue;
975
+ const rel = this.meta.relations[relName];
976
+ const plan = rel && !cols.includes(relName) ? this.planNestedRelation(relName, rel, opt, args.includePii === true) : null;
977
+ if (plan)
978
+ nestedPlans.push(plan);
979
+ else
980
+ residue[relName] = opt; // unknown relation: the loader raises its E003
981
+ }
982
+ residualWith = Object.keys(residue).length ? residue : undefined;
983
+ }
984
+ const nest = nestedPlans.length > 0;
985
+ const alias = nest ? 't0' : undefined;
986
+ const where = this.buildWhere(resolvedWhere, params, alias);
954
987
  const distinct = args.distinct?.length ? ' distinct' : '';
955
988
  const filter = where ? ` filter ${where}` : '';
956
- const order = this.buildOrder(args.orderBy, params);
989
+ const order = this.buildOrder(args.orderBy, params, alias);
957
990
  const limit = args.limit ?? args.take ?? this.defaultLimit;
958
991
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
959
992
  this.warnedUnlimited = true;
@@ -961,15 +994,29 @@ class PowqlInterface {
961
994
  }
962
995
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
963
996
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
964
- const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
965
- return { powql, resolvedWhere };
997
+ let projection;
998
+ if (nest) {
999
+ // Aliased scan: every parent column keys itself (`col: t0.col`) so the
1000
+ // result columns keep their bare snake names, then the nested blocks.
1001
+ const aliasCtr = { n: 1 };
1002
+ const parts = cols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: t0.${(0, powdb_js_1.quotePowqlIdent)(c)}`);
1003
+ for (const plan of nestedPlans) {
1004
+ parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
1005
+ }
1006
+ projection = `{ ${parts.join(', ')} }`;
1007
+ }
1008
+ else {
1009
+ projection = this.projection(cols);
1010
+ }
1011
+ const powql = `${this.qt}${nest ? ' as t0' : ''}${distinct}${filter}${order}${limitClause}${offsetClause} ${projection}`;
1012
+ return { powql, resolvedWhere, nestedPlans, residualWith };
966
1013
  }
967
- /** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
1014
+ /** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
968
1015
  async runFind(args, action = 'findMany') {
969
1016
  const params = [];
970
- const { powql, resolvedWhere } = await this.buildFind(args, params);
1017
+ const { powql, resolvedWhere, nestedPlans, residualWith } = await this.buildFind(args, params);
971
1018
  const { rows, native } = await this.exec(powql, params, args.timeout, action);
972
- return { rows, native, resolvedWhere };
1019
+ return { rows, native, resolvedWhere, nestedPlans, residualWith };
973
1020
  }
974
1021
  /**
975
1022
  * Diagnostic surface: compile the same PowQL {@link findMany} would run for
@@ -997,23 +1044,27 @@ class PowqlInterface {
997
1044
  }
998
1045
  async findUnique(args) {
999
1046
  return this.withMiddleware('findUnique', args, async () => {
1000
- const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
1047
+ const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
1001
1048
  if (!rows.length)
1002
1049
  return null;
1003
1050
  const entities = this.shape(rows, native);
1004
- if (args.with)
1005
- await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
1051
+ if (nestedPlans.length)
1052
+ this.attachNestedRows(entities, nestedPlans);
1053
+ if (residualWith)
1054
+ await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
1006
1055
  return entities[0];
1007
1056
  });
1008
1057
  }
1009
1058
  async findFirst(args = {}) {
1010
1059
  return this.withMiddleware('findFirst', args, async () => {
1011
- const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
1060
+ const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
1012
1061
  if (!rows.length)
1013
1062
  return null;
1014
1063
  const entities = this.shape(rows, native);
1015
- if (args.with)
1016
- await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
1064
+ if (nestedPlans.length)
1065
+ this.attachNestedRows(entities, nestedPlans);
1066
+ if (residualWith)
1067
+ await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
1017
1068
  return entities[0];
1018
1069
  });
1019
1070
  }
@@ -1503,6 +1554,160 @@ class PowqlInterface {
1503
1554
  return String(v);
1504
1555
  }
1505
1556
  // -------------------------------------------------------------------------
1557
+ // Nested relations: nested projections / shaped results (PowDB >= 0.18)
1558
+ // -------------------------------------------------------------------------
1559
+ /**
1560
+ * Should this query's `with` compile to nested-projection blocks? Requires
1561
+ * the engine capability (>= 0.18), and an EXPLICIT `relationLoadStrategy:
1562
+ * 'batched'` (per-query or client-level) opts back out to the keyed loaders.
1563
+ * The default and `'join'` both prefer nesting: unlike the F2 INNER join it
1564
+ * has no fan-out, keeps childless parents, works under parent paging, and
1565
+ * applies per-parent `order`/`limit`/`offset` natively, so it is the
1566
+ * strictly-better single-statement path wherever it is eligible. Ineligible
1567
+ * relations fall through to the existing strategy resolution untouched.
1568
+ */
1569
+ nestedProjectionsPreferred(args) {
1570
+ if (!this.capabilities.nestedProjections)
1571
+ return false;
1572
+ const explicit = args.relationLoadStrategy ?? this.options.relationLoadStrategy;
1573
+ return explicit !== 'batched';
1574
+ }
1575
+ /**
1576
+ * Plan one `with` relation as a nested-projection block, or return `null`
1577
+ * when the shape must stay on the loaders (ALWAYS a silent fallback with
1578
+ * identical output, never an error):
1579
+ * - m2m (the block takes exactly one child table; the junction-order
1580
+ * stitch has no nested equivalent), and composite relation keys;
1581
+ * - a to-one relation carrying `limit`/`offset` (the loaders' semantics);
1582
+ * - `distinct` inside the relation options (no nested grammar for it);
1583
+ * - a projected child column whose tsType is `bigint` or `Uint8Array`
1584
+ * (values ride a JSON array, which cannot carry them losslessly);
1585
+ * - a projection key collision (a child column named like a sub-relation);
1586
+ * - depth >= 10 (the loader path enforces the same cap by throwing, so the
1587
+ * fallback surfaces the identical E003 today's users get);
1588
+ * - any ineligible descendant (the WHOLE relation falls back, so one
1589
+ * statement never mixes nested and loader semantics mid-subtree).
1590
+ */
1591
+ planNestedRelation(relName, rel, opt, includePii, depth = 0) {
1592
+ if (depth >= 10)
1593
+ return null;
1594
+ if (rel.type === 'manyToMany')
1595
+ return null;
1596
+ if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length > 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length > 1) {
1597
+ return null;
1598
+ }
1599
+ if (!this.schema.tables[rel.to])
1600
+ return null; // the loader raises its own error
1601
+ const options = (opt === true ? {} : opt);
1602
+ if (options.distinct?.length)
1603
+ return null;
1604
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1605
+ if (single && (options.limit !== undefined || options.offset))
1606
+ return null;
1607
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1608
+ const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
1609
+ const byName = new Map(targetQi.meta.columns.map((c) => [c.name, c]));
1610
+ for (const c of cols) {
1611
+ const ts = (byName.get(c)?.tsType ?? '').replace(/\s*\|\s*null$/i, '').trim();
1612
+ if (ts === 'bigint' || ts === 'Uint8Array')
1613
+ return null;
1614
+ }
1615
+ const children = [];
1616
+ if (options.with) {
1617
+ for (const [subName, subOpt] of Object.entries(options.with)) {
1618
+ if (!subOpt)
1619
+ continue;
1620
+ const subRel = targetQi.meta.relations[subName];
1621
+ if (!subRel)
1622
+ return null; // let the loader raise its unknown-relation error
1623
+ const child = targetQi.planNestedRelation(subName, subRel, subOpt, includePii, depth + 1);
1624
+ if (!child)
1625
+ return null;
1626
+ children.push(child);
1627
+ }
1628
+ }
1629
+ // The block's object keys are the projected snake column names plus the
1630
+ // sub-relation field names; a duplicate key would mis-shape the JSON.
1631
+ const keys = new Set(cols);
1632
+ for (const child of children) {
1633
+ if (keys.has(child.relName))
1634
+ return null;
1635
+ keys.add(child.relName);
1636
+ }
1637
+ return { relName, rel, options, targetQi, single, cols, children };
1638
+ }
1639
+ /**
1640
+ * Compile one {@link NestedRelationPlan} into its projection-field block:
1641
+ * `name: Target as tN filter <correlation> [and (<child where>)] [order …]
1642
+ * [limit …] [offset …] { col: tN.col, …, <sub-blocks> }`. Params bind in
1643
+ * emission order (the projection is the statement's final clause, so nested
1644
+ * params always follow the parent's filter/order/limit/offset params).
1645
+ * Aliases share the parent statement's counter, so arbitrarily deep and
1646
+ * self-referential trees stay collision-free (the same discipline as the SQL
1647
+ * engine's json_agg subqueries).
1648
+ */
1649
+ async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout) {
1650
+ const { rel, relName, targetQi, options, single } = plan;
1651
+ const alias = `t${aliasCtr.n++}`;
1652
+ const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey)[0];
1653
+ const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
1654
+ const parentKeyCol = rel.type === 'belongsTo' ? fk : rk;
1655
+ const childKeyCol = rel.type === 'belongsTo' ? rk : fk;
1656
+ // Exactly one equi-correlation predicate (the 0.18 correlation rule); the
1657
+ // child where chains behind it as a parenthesized `and` group, which the
1658
+ // grammar accepts even when the group itself contains `or`.
1659
+ const correlation = `${alias}.${(0, powdb_js_1.quotePowqlIdent)(childKeyCol)} = ${parentAlias}.${(0, powdb_js_1.quotePowqlIdent)(parentKeyCol)}`;
1660
+ const resolved = await targetQi.resolveRelationFilters(options.where, timeout);
1661
+ const childWhere = targetQi.buildWhere(resolved, params, alias);
1662
+ const filter = childWhere ? `${correlation} and (${childWhere})` : correlation;
1663
+ const order = targetQi.buildOrder(options.orderBy, params, alias);
1664
+ const limitClause = single
1665
+ ? ' limit 1'
1666
+ : options.limit !== undefined
1667
+ ? ` limit ${this.param(options.limit, params)}`
1668
+ : '';
1669
+ const offsetClause = !single && options.offset ? ` offset ${this.param(options.offset, params)}` : '';
1670
+ const parts = plan.cols.map((c) => `${(0, powdb_js_1.quotePowqlIdent)(c)}: ${alias}.${(0, powdb_js_1.quotePowqlIdent)(c)}`);
1671
+ for (const child of plan.children) {
1672
+ parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout));
1673
+ }
1674
+ return (`${(0, powdb_js_1.quotePowqlIdent)(relName)}: ${targetQi.qt} as ${alias} ` +
1675
+ `filter ${filter}${order}${limitClause}${offsetClause} { ${parts.join(', ')} }`);
1676
+ }
1677
+ /**
1678
+ * Shape the nested JSON children back into typed entities on every parent
1679
+ * row. The nested field arrives as a decoded JSON array on the native wire
1680
+ * (or JSON text on the legacy wire — parsed here); its values are real JSON
1681
+ * types, so each child object goes through the NATIVE coercion policy
1682
+ * (`rowToEntity(…, true)`: a date column's micros number becomes a `Date`, a
1683
+ * json column's document passes through, a str `"null"` stays a string).
1684
+ * to-one relations unwrap to `[0] ?? null`, matching the loaders exactly.
1685
+ */
1686
+ attachNestedRows(entities, plans) {
1687
+ for (const entity of entities) {
1688
+ for (const plan of plans)
1689
+ this.attachOneNested(entity, plan);
1690
+ }
1691
+ }
1692
+ attachOneNested(row, plan) {
1693
+ let value = row[plan.relName];
1694
+ if (typeof value === 'string') {
1695
+ try {
1696
+ value = JSON.parse(value);
1697
+ }
1698
+ catch {
1699
+ value = [];
1700
+ }
1701
+ }
1702
+ const items = Array.isArray(value) ? value : [];
1703
+ const shaped = items.map((o) => (0, powdb_js_1.rowToEntity)(o, plan.targetQi.meta, true));
1704
+ for (const child of shaped) {
1705
+ for (const sub of plan.children)
1706
+ plan.targetQi.attachOneNested(child, sub);
1707
+ }
1708
+ row[plan.relName] = plan.single ? (shaped[0] ?? null) : shaped;
1709
+ }
1710
+ // -------------------------------------------------------------------------
1506
1711
  // Writes (reselect — PowDB has no RETURNING)
1507
1712
  // -------------------------------------------------------------------------
1508
1713
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
package/dist/powdb.d.ts CHANGED
@@ -254,18 +254,27 @@ export interface PowdbCapabilities {
254
254
  introspection: boolean;
255
255
  /** ≥ 0.13: server-side joins, hash-accelerated and bounded. */
256
256
  serverJoins: boolean;
257
+ /**
258
+ * ≥ 0.18: nested projections (shaped results) — a projection field may be a
259
+ * whole correlated child query returning a per-parent JSON array. When set,
260
+ * eligible `with` clauses compile into the parent statement instead of the
261
+ * batched loaders.
262
+ */
263
+ nestedProjections: boolean;
257
264
  /** Networked only: server ≥ 0.13 AND the client exposes `queryNativeRaw`. */
258
265
  nativeRaw: boolean;
259
266
  }
260
267
  /** The feature-gate capability keys (everything except the version/nativeRaw metadata). */
261
- type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins';
268
+ type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections';
262
269
  /**
263
270
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
264
271
  * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
265
272
  * did not go through {@link turbinePowDB}'s version probe (e.g. an injected
266
273
  * pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
267
274
  * actual wire path and must only be enabled after a real server-version probe,
268
- * never inferred from a bare construction.
275
+ * never inferred from a bare construction. `nestedProjections` stays OFF for
276
+ * the same reason: it changes the generated PowQL for every `with` query, and
277
+ * an unprobed engine below 0.18 would reject the syntax outright.
269
278
  */
270
279
  export declare const ALL_POWDB_CAPABILITIES: PowdbCapabilities;
271
280
  /**
@@ -388,7 +397,10 @@ export declare function rowToEntity(raw: Record<string, unknown>, meta: TableMet
388
397
  * `Execution("type mismatch …")`, `Parse(…)`, `StorageError(…)`).
389
398
  *
390
399
  * So we always run the unique-constraint and message-shape checks first (they
391
- * fire for both transports), then fall through to the networked `.code` switch.
400
+ * fire for both transports and extract detail like constraint / column names),
401
+ * then classify by the typed wire error class (`.wireErrorClass`, networked
402
+ * server >= 0.17 — accurate even when the server sanitized the message text),
403
+ * then fall through to the networked `.code` switch.
392
404
  */
393
405
  export declare function wrapPowdbError(err: unknown): Error;
394
406
  /**
@@ -599,7 +611,7 @@ export declare function encodePowqlLiteral(value: unknown): string;
599
611
  * the ceiling yet still routes through the string wire is refused rather than
600
612
  * materialized.
601
613
  */
602
- export declare const POWQL_LEXER_TESTED_CEILING = "0.16";
614
+ export declare const POWQL_LEXER_TESTED_CEILING = "0.18";
603
615
  /**
604
616
  * Substitute every `$N` placeholder in a generator-produced PowQL template with
605
617
  * the encoded literal of `params[N-1]`. Safe because the template is produced by
package/dist/powdb.js CHANGED
@@ -196,6 +196,7 @@ const POWDB_FEATURE_MIN_VERSION = {
196
196
  jsonDocs: '0.12',
197
197
  docFieldIndexes: '0.13',
198
198
  serverJoins: '0.13',
199
+ nestedProjections: '0.18',
199
200
  };
200
201
  /**
201
202
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -203,7 +204,9 @@ const POWDB_FEATURE_MIN_VERSION = {
203
204
  * did not go through {@link turbinePowDB}'s version probe (e.g. an injected
204
205
  * pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
205
206
  * actual wire path and must only be enabled after a real server-version probe,
206
- * never inferred from a bare construction.
207
+ * never inferred from a bare construction. `nestedProjections` stays OFF for
208
+ * the same reason: it changes the generated PowQL for every `with` query, and
209
+ * an unprobed engine below 0.18 would reject the syntax outright.
207
210
  */
208
211
  export const ALL_POWDB_CAPABILITIES = {
209
212
  engineVersion: null,
@@ -211,6 +214,7 @@ export const ALL_POWDB_CAPABILITIES = {
211
214
  docFieldIndexes: true,
212
215
  introspection: true,
213
216
  serverJoins: true,
217
+ nestedProjections: false,
214
218
  nativeRaw: false,
215
219
  };
216
220
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -239,6 +243,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
239
243
  docFieldIndexes: false,
240
244
  introspection: false,
241
245
  serverJoins: false,
246
+ nestedProjections: false,
242
247
  nativeRaw: false,
243
248
  };
244
249
  }
@@ -248,6 +253,7 @@ export function capabilitiesFromVersion(version, opts = {}) {
248
253
  jsonDocs: atLeastVersion(sem, 0, 12),
249
254
  docFieldIndexes: atLeastVersion(sem, 0, 13),
250
255
  serverJoins: atLeastVersion(sem, 0, 13),
256
+ nestedProjections: atLeastVersion(sem, 0, 18),
251
257
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
252
258
  };
253
259
  }
@@ -674,7 +680,10 @@ export function rowToEntity(raw, meta, native = false) {
674
680
  * `Execution("type mismatch …")`, `Parse(…)`, `StorageError(…)`).
675
681
  *
676
682
  * So we always run the unique-constraint and message-shape checks first (they
677
- * fire for both transports), then fall through to the networked `.code` switch.
683
+ * fire for both transports and extract detail like constraint / column names),
684
+ * then classify by the typed wire error class (`.wireErrorClass`, networked
685
+ * server >= 0.17 — accurate even when the server sanitized the message text),
686
+ * then fall through to the networked `.code` switch.
678
687
  */
679
688
  export function wrapPowdbError(err) {
680
689
  if (!err || typeof err !== 'object')
@@ -765,6 +774,46 @@ export function wrapPowdbError(err) {
765
774
  if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
766
775
  return new ValidationError(`[turbine] PowDB join rejected: ${msg}`);
767
776
  }
777
+ // Typed wire error class (networked, server >= 0.17): the client surfaces the
778
+ // stable one-byte class from the error frame as `.wireErrorClass`. Classify by
779
+ // it BEFORE the generic message regexes: the server sanitizes non-allowlisted
780
+ // messages to the generic "query execution error" text, but the class is
781
+ // derived from the typed error at response-build time, so it stays accurate
782
+ // when the message no longer is. The specific message families above still run
783
+ // first because they extract richer detail (constraint / column names) that
784
+ // the class byte cannot carry. Class values are append-only wire contract
785
+ // (docs/errors.md upstream); unknown / absent classes fall through to the
786
+ // pre-0.17 message + `.code` behavior.
787
+ const wireClass = err.wireErrorClass;
788
+ if (typeof wireClass === 'number') {
789
+ switch (wireClass) {
790
+ case 3: // timeout (per-query budget, gate wait, idle timeout) — retryable
791
+ return new TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
792
+ case 4: // limit_exceeded (memory / size budget) — a query-shape defect
793
+ return new ValidationError(`[turbine] PowDB resource limit exceeded: ${msg}`);
794
+ case 5: // readonly_refused — the snapshot-serving routing signal
795
+ return new ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
796
+ cause: err,
797
+ reason: 'snapshot',
798
+ });
799
+ case 6: // auth_failed at CONNECT
800
+ return new ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
801
+ case 7: // rate_limited (repeated bad auth) — connection-establishment class
802
+ return new ConnectionError(`[turbine] PowDB rate-limited this address after repeated failed authentication: ${msg}. Wait before retrying.`, { cause: err });
803
+ case 8: {
804
+ // constraint_violation — today that is always a unique index
805
+ const m = /on\s+\S+\.(\w+)/i.exec(msg);
806
+ return new UniqueConstraintError({ constraint: m?.[1], cause: err });
807
+ }
808
+ case 9: // cancelled (issuing client disconnected) — final, never retry
809
+ return new ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
810
+ case 1: // parse
811
+ case 2: // execution
812
+ return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
813
+ default: // internal (0) or an unknown future class: fall through
814
+ break;
815
+ }
816
+ }
768
817
  // Type mismatch / parse / execution / storage / unexpected(token) / row too
769
818
  // large → validation (E003). On the embedded transport these are the only
770
819
  // signal we get (code is always 'GenericFailure'); on the networked path they
@@ -1391,7 +1440,7 @@ export function encodePowqlLiteral(value) {
1391
1440
  * the ceiling yet still routes through the string wire is refused rather than
1392
1441
  * materialized.
1393
1442
  */
1394
- export const POWQL_LEXER_TESTED_CEILING = '0.16';
1443
+ export const POWQL_LEXER_TESTED_CEILING = '0.18';
1395
1444
  /** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
1396
1445
  function encodePowqlString(s) {
1397
1446
  let out = '"';
package/dist/powql.d.ts CHANGED
@@ -267,13 +267,20 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
267
267
  private shape;
268
268
  findMany(args?: FindManyArgs<T>): Promise<T[]>;
269
269
  /**
270
- * Compile the flat findMany select into PowQL (no execution), pushing values
271
- * into `params`. Returns the query plus the RESOLVED where (relation filters
270
+ * Compile the findMany select into PowQL (no execution), pushing values into
271
+ * `params`. Returns the query plus the RESOLVED where (relation filters
272
272
  * already collapsed to literal in-lists) so the F2 join path can re-emit the
273
273
  * exact parent predicate alias-qualified, and so {@link explain} can wrap it.
274
+ *
275
+ * When the engine supports nested projections (>= 0.18) and the strategy
276
+ * does not opt out, eligible `with` relations compile INTO this statement as
277
+ * nested-projection blocks (`nestedPlans`) — one round-trip for the whole
278
+ * shape — and only the ineligible remainder (`residualWith`) goes to the
279
+ * post-execution loaders. Without nesting the emitted PowQL is byte-identical
280
+ * to the pre-0.18 output (no alias, `.col` refs).
274
281
  */
275
282
  private buildFind;
276
- /** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
283
+ /** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
277
284
  private runFind;
278
285
  /**
279
286
  * Diagnostic surface: compile the same PowQL {@link findMany} would run for
@@ -396,6 +403,56 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
396
403
  * an int key matches whether it came back typed or as text.
397
404
  */
398
405
  private joinKey;
406
+ /**
407
+ * Should this query's `with` compile to nested-projection blocks? Requires
408
+ * the engine capability (>= 0.18), and an EXPLICIT `relationLoadStrategy:
409
+ * 'batched'` (per-query or client-level) opts back out to the keyed loaders.
410
+ * The default and `'join'` both prefer nesting: unlike the F2 INNER join it
411
+ * has no fan-out, keeps childless parents, works under parent paging, and
412
+ * applies per-parent `order`/`limit`/`offset` natively, so it is the
413
+ * strictly-better single-statement path wherever it is eligible. Ineligible
414
+ * relations fall through to the existing strategy resolution untouched.
415
+ */
416
+ private nestedProjectionsPreferred;
417
+ /**
418
+ * Plan one `with` relation as a nested-projection block, or return `null`
419
+ * when the shape must stay on the loaders (ALWAYS a silent fallback with
420
+ * identical output, never an error):
421
+ * - m2m (the block takes exactly one child table; the junction-order
422
+ * stitch has no nested equivalent), and composite relation keys;
423
+ * - a to-one relation carrying `limit`/`offset` (the loaders' semantics);
424
+ * - `distinct` inside the relation options (no nested grammar for it);
425
+ * - a projected child column whose tsType is `bigint` or `Uint8Array`
426
+ * (values ride a JSON array, which cannot carry them losslessly);
427
+ * - a projection key collision (a child column named like a sub-relation);
428
+ * - depth >= 10 (the loader path enforces the same cap by throwing, so the
429
+ * fallback surfaces the identical E003 today's users get);
430
+ * - any ineligible descendant (the WHOLE relation falls back, so one
431
+ * statement never mixes nested and loader semantics mid-subtree).
432
+ */
433
+ private planNestedRelation;
434
+ /**
435
+ * Compile one {@link NestedRelationPlan} into its projection-field block:
436
+ * `name: Target as tN filter <correlation> [and (<child where>)] [order …]
437
+ * [limit …] [offset …] { col: tN.col, …, <sub-blocks> }`. Params bind in
438
+ * emission order (the projection is the statement's final clause, so nested
439
+ * params always follow the parent's filter/order/limit/offset params).
440
+ * Aliases share the parent statement's counter, so arbitrarily deep and
441
+ * self-referential trees stay collision-free (the same discipline as the SQL
442
+ * engine's json_agg subqueries).
443
+ */
444
+ private buildNestedBlock;
445
+ /**
446
+ * Shape the nested JSON children back into typed entities on every parent
447
+ * row. The nested field arrives as a decoded JSON array on the native wire
448
+ * (or JSON text on the legacy wire — parsed here); its values are real JSON
449
+ * types, so each child object goes through the NATIVE coercion policy
450
+ * (`rowToEntity(…, true)`: a date column's micros number becomes a `Date`, a
451
+ * json column's document passes through, a str `"null"` stays a string).
452
+ * to-one relations unwrap to `[0] ?? null`, matching the loaders exactly.
453
+ */
454
+ private attachNestedRows;
455
+ private attachOneNested;
399
456
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
400
457
  private scalarData;
401
458
  /**
package/dist/powql.js CHANGED
@@ -894,30 +894,63 @@ export class PowqlInterface {
894
894
  // -------------------------------------------------------------------------
895
895
  async findMany(args = {}) {
896
896
  return this.withMiddleware('findMany', args, async () => {
897
- const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
897
+ const { rows, native, resolvedWhere, nestedPlans, residualWith } = await this.runFind(args, 'findMany');
898
898
  const entities = this.shape(rows, native);
899
- if (args.with) {
900
- await this.loadRelations(entities, args.with, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
899
+ if (nestedPlans.length)
900
+ this.attachNestedRows(entities, nestedPlans);
901
+ if (residualWith) {
902
+ await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
901
903
  }
902
904
  return entities;
903
905
  });
904
906
  }
905
907
  /**
906
- * Compile the flat findMany select into PowQL (no execution), pushing values
907
- * into `params`. Returns the query plus the RESOLVED where (relation filters
908
+ * Compile the findMany select into PowQL (no execution), pushing values into
909
+ * `params`. Returns the query plus the RESOLVED where (relation filters
908
910
  * already collapsed to literal in-lists) so the F2 join path can re-emit the
909
911
  * exact parent predicate alias-qualified, and so {@link explain} can wrap it.
912
+ *
913
+ * When the engine supports nested projections (>= 0.18) and the strategy
914
+ * does not opt out, eligible `with` relations compile INTO this statement as
915
+ * nested-projection blocks (`nestedPlans`) — one round-trip for the whole
916
+ * shape — and only the ineligible remainder (`residualWith`) goes to the
917
+ * post-execution loaders. Without nesting the emitted PowQL is byte-identical
918
+ * to the pre-0.18 output (no alias, `.col` refs).
910
919
  */
911
920
  async buildFind(args, params) {
912
921
  if (args.cursor) {
913
922
  throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
914
923
  }
915
924
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
916
- const where = this.buildWhere(resolvedWhere, params);
917
925
  const cols = this.projectedColumns(args.select, args.omit, args.includePii === true);
926
+ // Partition the `with` clause: nested-projection blocks vs loader residue.
927
+ // A parent `distinct` never nests (distinct over a row containing a JSON
928
+ // array is not a defined comparison), and a relation whose field name
929
+ // collides with a projected parent column stays on the loaders (its block
930
+ // key would duplicate the column's).
931
+ const withClause = args.with;
932
+ const nestedPlans = [];
933
+ let residualWith = withClause;
934
+ if (withClause && !args.distinct?.length && this.nestedProjectionsPreferred(args)) {
935
+ const residue = {};
936
+ for (const [relName, opt] of Object.entries(withClause)) {
937
+ if (!opt)
938
+ continue;
939
+ const rel = this.meta.relations[relName];
940
+ const plan = rel && !cols.includes(relName) ? this.planNestedRelation(relName, rel, opt, args.includePii === true) : null;
941
+ if (plan)
942
+ nestedPlans.push(plan);
943
+ else
944
+ residue[relName] = opt; // unknown relation: the loader raises its E003
945
+ }
946
+ residualWith = Object.keys(residue).length ? residue : undefined;
947
+ }
948
+ const nest = nestedPlans.length > 0;
949
+ const alias = nest ? 't0' : undefined;
950
+ const where = this.buildWhere(resolvedWhere, params, alias);
918
951
  const distinct = args.distinct?.length ? ' distinct' : '';
919
952
  const filter = where ? ` filter ${where}` : '';
920
- const order = this.buildOrder(args.orderBy, params);
953
+ const order = this.buildOrder(args.orderBy, params, alias);
921
954
  const limit = args.limit ?? args.take ?? this.defaultLimit;
922
955
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
923
956
  this.warnedUnlimited = true;
@@ -925,15 +958,29 @@ export class PowqlInterface {
925
958
  }
926
959
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
927
960
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
928
- const powql = `${this.qt}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
929
- return { powql, resolvedWhere };
961
+ let projection;
962
+ if (nest) {
963
+ // Aliased scan: every parent column keys itself (`col: t0.col`) so the
964
+ // result columns keep their bare snake names, then the nested blocks.
965
+ const aliasCtr = { n: 1 };
966
+ const parts = cols.map((c) => `${quotePowqlIdent(c)}: t0.${quotePowqlIdent(c)}`);
967
+ for (const plan of nestedPlans) {
968
+ parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
969
+ }
970
+ projection = `{ ${parts.join(', ')} }`;
971
+ }
972
+ else {
973
+ projection = this.projection(cols);
974
+ }
975
+ const powql = `${this.qt}${nest ? ' as t0' : ''}${distinct}${filter}${order}${limitClause}${offsetClause} ${projection}`;
976
+ return { powql, resolvedWhere, nestedPlans, residualWith };
930
977
  }
931
- /** Build + run the flat findMany select; returns raw rows, the serving wire, and the resolved where. */
978
+ /** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
932
979
  async runFind(args, action = 'findMany') {
933
980
  const params = [];
934
- const { powql, resolvedWhere } = await this.buildFind(args, params);
981
+ const { powql, resolvedWhere, nestedPlans, residualWith } = await this.buildFind(args, params);
935
982
  const { rows, native } = await this.exec(powql, params, args.timeout, action);
936
- return { rows, native, resolvedWhere };
983
+ return { rows, native, resolvedWhere, nestedPlans, residualWith };
937
984
  }
938
985
  /**
939
986
  * Diagnostic surface: compile the same PowQL {@link findMany} would run for
@@ -961,23 +1008,27 @@ export class PowqlInterface {
961
1008
  }
962
1009
  async findUnique(args) {
963
1010
  return this.withMiddleware('findUnique', args, async () => {
964
- const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
1011
+ const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
965
1012
  if (!rows.length)
966
1013
  return null;
967
1014
  const entities = this.shape(rows, native);
968
- if (args.with)
969
- await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
1015
+ if (nestedPlans.length)
1016
+ this.attachNestedRows(entities, nestedPlans);
1017
+ if (residualWith)
1018
+ await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
970
1019
  return entities[0];
971
1020
  });
972
1021
  }
973
1022
  async findFirst(args = {}) {
974
1023
  return this.withMiddleware('findFirst', args, async () => {
975
- const { rows, native } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
1024
+ const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
976
1025
  if (!rows.length)
977
1026
  return null;
978
1027
  const entities = this.shape(rows, native);
979
- if (args.with)
980
- await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
1028
+ if (nestedPlans.length)
1029
+ this.attachNestedRows(entities, nestedPlans);
1030
+ if (residualWith)
1031
+ await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
981
1032
  return entities[0];
982
1033
  });
983
1034
  }
@@ -1467,6 +1518,160 @@ export class PowqlInterface {
1467
1518
  return String(v);
1468
1519
  }
1469
1520
  // -------------------------------------------------------------------------
1521
+ // Nested relations: nested projections / shaped results (PowDB >= 0.18)
1522
+ // -------------------------------------------------------------------------
1523
+ /**
1524
+ * Should this query's `with` compile to nested-projection blocks? Requires
1525
+ * the engine capability (>= 0.18), and an EXPLICIT `relationLoadStrategy:
1526
+ * 'batched'` (per-query or client-level) opts back out to the keyed loaders.
1527
+ * The default and `'join'` both prefer nesting: unlike the F2 INNER join it
1528
+ * has no fan-out, keeps childless parents, works under parent paging, and
1529
+ * applies per-parent `order`/`limit`/`offset` natively, so it is the
1530
+ * strictly-better single-statement path wherever it is eligible. Ineligible
1531
+ * relations fall through to the existing strategy resolution untouched.
1532
+ */
1533
+ nestedProjectionsPreferred(args) {
1534
+ if (!this.capabilities.nestedProjections)
1535
+ return false;
1536
+ const explicit = args.relationLoadStrategy ?? this.options.relationLoadStrategy;
1537
+ return explicit !== 'batched';
1538
+ }
1539
+ /**
1540
+ * Plan one `with` relation as a nested-projection block, or return `null`
1541
+ * when the shape must stay on the loaders (ALWAYS a silent fallback with
1542
+ * identical output, never an error):
1543
+ * - m2m (the block takes exactly one child table; the junction-order
1544
+ * stitch has no nested equivalent), and composite relation keys;
1545
+ * - a to-one relation carrying `limit`/`offset` (the loaders' semantics);
1546
+ * - `distinct` inside the relation options (no nested grammar for it);
1547
+ * - a projected child column whose tsType is `bigint` or `Uint8Array`
1548
+ * (values ride a JSON array, which cannot carry them losslessly);
1549
+ * - a projection key collision (a child column named like a sub-relation);
1550
+ * - depth >= 10 (the loader path enforces the same cap by throwing, so the
1551
+ * fallback surfaces the identical E003 today's users get);
1552
+ * - any ineligible descendant (the WHOLE relation falls back, so one
1553
+ * statement never mixes nested and loader semantics mid-subtree).
1554
+ */
1555
+ planNestedRelation(relName, rel, opt, includePii, depth = 0) {
1556
+ if (depth >= 10)
1557
+ return null;
1558
+ if (rel.type === 'manyToMany')
1559
+ return null;
1560
+ if (normalizeKeyColumns(rel.foreignKey).length > 1 || normalizeKeyColumns(rel.referenceKey).length > 1) {
1561
+ return null;
1562
+ }
1563
+ if (!this.schema.tables[rel.to])
1564
+ return null; // the loader raises its own error
1565
+ const options = (opt === true ? {} : opt);
1566
+ if (options.distinct?.length)
1567
+ return null;
1568
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1569
+ if (single && (options.limit !== undefined || options.offset))
1570
+ return null;
1571
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1572
+ const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
1573
+ const byName = new Map(targetQi.meta.columns.map((c) => [c.name, c]));
1574
+ for (const c of cols) {
1575
+ const ts = (byName.get(c)?.tsType ?? '').replace(/\s*\|\s*null$/i, '').trim();
1576
+ if (ts === 'bigint' || ts === 'Uint8Array')
1577
+ return null;
1578
+ }
1579
+ const children = [];
1580
+ if (options.with) {
1581
+ for (const [subName, subOpt] of Object.entries(options.with)) {
1582
+ if (!subOpt)
1583
+ continue;
1584
+ const subRel = targetQi.meta.relations[subName];
1585
+ if (!subRel)
1586
+ return null; // let the loader raise its unknown-relation error
1587
+ const child = targetQi.planNestedRelation(subName, subRel, subOpt, includePii, depth + 1);
1588
+ if (!child)
1589
+ return null;
1590
+ children.push(child);
1591
+ }
1592
+ }
1593
+ // The block's object keys are the projected snake column names plus the
1594
+ // sub-relation field names; a duplicate key would mis-shape the JSON.
1595
+ const keys = new Set(cols);
1596
+ for (const child of children) {
1597
+ if (keys.has(child.relName))
1598
+ return null;
1599
+ keys.add(child.relName);
1600
+ }
1601
+ return { relName, rel, options, targetQi, single, cols, children };
1602
+ }
1603
+ /**
1604
+ * Compile one {@link NestedRelationPlan} into its projection-field block:
1605
+ * `name: Target as tN filter <correlation> [and (<child where>)] [order …]
1606
+ * [limit …] [offset …] { col: tN.col, …, <sub-blocks> }`. Params bind in
1607
+ * emission order (the projection is the statement's final clause, so nested
1608
+ * params always follow the parent's filter/order/limit/offset params).
1609
+ * Aliases share the parent statement's counter, so arbitrarily deep and
1610
+ * self-referential trees stay collision-free (the same discipline as the SQL
1611
+ * engine's json_agg subqueries).
1612
+ */
1613
+ async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout) {
1614
+ const { rel, relName, targetQi, options, single } = plan;
1615
+ const alias = `t${aliasCtr.n++}`;
1616
+ const fk = normalizeKeyColumns(rel.foreignKey)[0];
1617
+ const rk = normalizeKeyColumns(rel.referenceKey)[0];
1618
+ const parentKeyCol = rel.type === 'belongsTo' ? fk : rk;
1619
+ const childKeyCol = rel.type === 'belongsTo' ? rk : fk;
1620
+ // Exactly one equi-correlation predicate (the 0.18 correlation rule); the
1621
+ // child where chains behind it as a parenthesized `and` group, which the
1622
+ // grammar accepts even when the group itself contains `or`.
1623
+ const correlation = `${alias}.${quotePowqlIdent(childKeyCol)} = ${parentAlias}.${quotePowqlIdent(parentKeyCol)}`;
1624
+ const resolved = await targetQi.resolveRelationFilters(options.where, timeout);
1625
+ const childWhere = targetQi.buildWhere(resolved, params, alias);
1626
+ const filter = childWhere ? `${correlation} and (${childWhere})` : correlation;
1627
+ const order = targetQi.buildOrder(options.orderBy, params, alias);
1628
+ const limitClause = single
1629
+ ? ' limit 1'
1630
+ : options.limit !== undefined
1631
+ ? ` limit ${this.param(options.limit, params)}`
1632
+ : '';
1633
+ const offsetClause = !single && options.offset ? ` offset ${this.param(options.offset, params)}` : '';
1634
+ const parts = plan.cols.map((c) => `${quotePowqlIdent(c)}: ${alias}.${quotePowqlIdent(c)}`);
1635
+ for (const child of plan.children) {
1636
+ parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout));
1637
+ }
1638
+ return (`${quotePowqlIdent(relName)}: ${targetQi.qt} as ${alias} ` +
1639
+ `filter ${filter}${order}${limitClause}${offsetClause} { ${parts.join(', ')} }`);
1640
+ }
1641
+ /**
1642
+ * Shape the nested JSON children back into typed entities on every parent
1643
+ * row. The nested field arrives as a decoded JSON array on the native wire
1644
+ * (or JSON text on the legacy wire — parsed here); its values are real JSON
1645
+ * types, so each child object goes through the NATIVE coercion policy
1646
+ * (`rowToEntity(…, true)`: a date column's micros number becomes a `Date`, a
1647
+ * json column's document passes through, a str `"null"` stays a string).
1648
+ * to-one relations unwrap to `[0] ?? null`, matching the loaders exactly.
1649
+ */
1650
+ attachNestedRows(entities, plans) {
1651
+ for (const entity of entities) {
1652
+ for (const plan of plans)
1653
+ this.attachOneNested(entity, plan);
1654
+ }
1655
+ }
1656
+ attachOneNested(row, plan) {
1657
+ let value = row[plan.relName];
1658
+ if (typeof value === 'string') {
1659
+ try {
1660
+ value = JSON.parse(value);
1661
+ }
1662
+ catch {
1663
+ value = [];
1664
+ }
1665
+ }
1666
+ const items = Array.isArray(value) ? value : [];
1667
+ const shaped = items.map((o) => rowToEntity(o, plan.targetQi.meta, true));
1668
+ for (const child of shaped) {
1669
+ for (const sub of plan.children)
1670
+ plan.targetQi.attachOneNested(child, sub);
1671
+ }
1672
+ row[plan.relName] = plan.single ? (shaped[0] ?? null) : shaped;
1673
+ }
1674
+ // -------------------------------------------------------------------------
1470
1675
  // Writes (reselect — PowDB has no RETURNING)
1471
1676
  // -------------------------------------------------------------------------
1472
1677
  /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {
@@ -103,8 +103,8 @@
103
103
  "@size-limit/esbuild": "^12.1.0",
104
104
  "@size-limit/file": "^12.1.0",
105
105
  "@types/node": "^26.1.0",
106
- "@zvndev/powdb-client": "^0.16.0",
107
- "@zvndev/powdb-embedded": "^0.16.0",
106
+ "@zvndev/powdb-client": "^0.17.0",
107
+ "@zvndev/powdb-embedded": "^0.17.0",
108
108
  "c8": "^11.0.0",
109
109
  "husky": "^9.1.7",
110
110
  "lint-staged": "^17.0.8",