turbine-orm 0.78.0-next.4bd3c8f → 0.78.0-next.b792a6a

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.
@@ -155,6 +155,34 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
155
155
  * the two can never disagree about which limit is in force.
156
156
  */
157
157
  private effectiveLimit;
158
+ /**
159
+ * Options for the child-side interfaces the relation loaders and the
160
+ * relation-filter resolvers build: the client's, with `defaultLimit` cleared
161
+ * and the unlimited warning off. `defaultLimit` bounds the caller's PAGE; on
162
+ * an internal fetch that serves that page (a relation's children, a filter's
163
+ * key set) it would cap the whole page's children, or the key set, at the
164
+ * page size: a silent wrong answer by another route than the one
165
+ * {@link pageOf} closes. The SQL batched loader clears it the same way
166
+ * (`batchedChildOptions` in query/builder.ts).
167
+ */
168
+ private childOptions?;
169
+ private loaderChildOptions;
170
+ /**
171
+ * ONE parent's window of its stitched children: the relation's `offset` and
172
+ * `limit` applied to that parent's own list.
173
+ *
174
+ * A relation `limit` means "at most N on EACH parent". The loaders used to
175
+ * spread it onto the flat child fetch (`post filter .author_id in (...)
176
+ * limit 5`), which caps the TOTAL across every parent in the chunk, so ten
177
+ * parents shared five posts and most got none, with no error. It shipped
178
+ * for the loaders' whole life because the one test of the shape compared
179
+ * the join path against the loader path and both were wrong the same way;
180
+ * the cross-engine benchmark found it, where the wrong answer was the fast
181
+ * one. Every loader now fetches its children unbounded, WITH the relation
182
+ * `orderBy` (which decides which rows the window keeps), and applies this
183
+ * at stitch time: the rule the SQL engines' batched loader has always used.
184
+ */
185
+ private static pageOf;
158
186
  /**
159
187
  * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
160
188
  * casts both with `as usize` at execution, so below engine 0.20 a negative
@@ -623,9 +651,10 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
623
651
  * `referenceKey` on the TARGET table (the join's non-fetched side);
624
652
  * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
625
653
  * stitch can't be reproduced by the 3-table join deterministically);
626
- * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
627
- * does a to-many relation `limit`/`offset` when the parent set spills past
628
- * one loader chunk (the loader limits per chunk, the join once globally).
654
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader. A
655
+ * to-many relation `limit`/`offset` is a PER-PARENT bound on both paths,
656
+ * fetched unbounded and sliced per parent at stitch time (`pageOf`), so
657
+ * the join statement carries neither clause.
629
658
  */
630
659
  private joinEligible;
631
660
  /**
package/dist/cjs/powql.js CHANGED
@@ -393,6 +393,42 @@ class PowqlInterface {
393
393
  effectiveLimit(args) {
394
394
  return args.limit ?? this.defaultLimit;
395
395
  }
396
+ /**
397
+ * Options for the child-side interfaces the relation loaders and the
398
+ * relation-filter resolvers build: the client's, with `defaultLimit` cleared
399
+ * and the unlimited warning off. `defaultLimit` bounds the caller's PAGE; on
400
+ * an internal fetch that serves that page (a relation's children, a filter's
401
+ * key set) it would cap the whole page's children, or the key set, at the
402
+ * page size: a silent wrong answer by another route than the one
403
+ * {@link pageOf} closes. The SQL batched loader clears it the same way
404
+ * (`batchedChildOptions` in query/builder.ts).
405
+ */
406
+ childOptions;
407
+ loaderChildOptions() {
408
+ this.childOptions ??= { ...this.options, defaultLimit: undefined, warnOnUnlimited: false };
409
+ return this.childOptions;
410
+ }
411
+ /**
412
+ * ONE parent's window of its stitched children: the relation's `offset` and
413
+ * `limit` applied to that parent's own list.
414
+ *
415
+ * A relation `limit` means "at most N on EACH parent". The loaders used to
416
+ * spread it onto the flat child fetch (`post filter .author_id in (...)
417
+ * limit 5`), which caps the TOTAL across every parent in the chunk, so ten
418
+ * parents shared five posts and most got none, with no error. It shipped
419
+ * for the loaders' whole life because the one test of the shape compared
420
+ * the join path against the loader path and both were wrong the same way;
421
+ * the cross-engine benchmark found it, where the wrong answer was the fast
422
+ * one. Every loader now fetches its children unbounded, WITH the relation
423
+ * `orderBy` (which decides which rows the window keeps), and applies this
424
+ * at stitch time: the rule the SQL engines' batched loader has always used.
425
+ */
426
+ static pageOf(rows, limit, offset) {
427
+ if (limit === undefined && !offset)
428
+ return rows;
429
+ const start = offset ?? 0;
430
+ return rows.slice(start, limit === undefined ? undefined : start + limit);
431
+ }
396
432
  /**
397
433
  * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
398
434
  * casts both with `as usize` at execution, so below engine 0.20 a negative
@@ -974,7 +1010,7 @@ class PowqlInterface {
974
1010
  const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
975
1011
  const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
976
1012
  const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
977
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1013
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
978
1014
  const collect = async (w) => {
979
1015
  const rows = await targetQi.findMany({
980
1016
  where: w,
@@ -1013,7 +1049,7 @@ class PowqlInterface {
1013
1049
  const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
1014
1050
  const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
1015
1051
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
1016
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1052
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1017
1053
  const collectTargetPks = async (w) => {
1018
1054
  const rows = await targetQi.findMany({
1019
1055
  where: w,
@@ -1725,7 +1761,7 @@ class PowqlInterface {
1725
1761
  const rel = this.meta.relations[relName];
1726
1762
  if (!rel)
1727
1763
  throw new errors_js_1.ValidationError(`Unknown relation "${relName}" on "${this.table}".`);
1728
- if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
1764
+ if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args)) {
1729
1765
  if (this.capabilities.serverJoins) {
1730
1766
  await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
1731
1767
  continue;
@@ -1748,7 +1784,19 @@ class PowqlInterface {
1748
1784
  throw new errors_js_1.UnsupportedFeatureError('composite-key nested reads', 'PowDB', `relation "${relName}"`);
1749
1785
  }
1750
1786
  const options = (opt === true ? {} : opt);
1751
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1787
+ // The relation's own page, applied PER PARENT at stitch time (see pageOf),
1788
+ // never on the flat fetch below.
1789
+ const relLimit = options.limit;
1790
+ const relOffset = options.offset;
1791
+ this.assertPagination(relLimit, relOffset, `relation "${relName}"`);
1792
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1793
+ if (relLimit === 0) {
1794
+ // `[]` on every parent by construction: nothing to fetch.
1795
+ for (const parent of parents)
1796
+ parent[relName] = single ? null : [];
1797
+ continue;
1798
+ }
1799
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1752
1800
  const targetMeta = this.schema.tables[rel.to];
1753
1801
  // Local key on the parent, remote key on the target child.
1754
1802
  const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
@@ -1806,6 +1854,9 @@ class PowqlInterface {
1806
1854
  };
1807
1855
  const children = (await targetQi.findMany({
1808
1856
  ...fetchOptions,
1857
+ // Unbounded: the relation limit/offset are per parent, applied below.
1858
+ limit: undefined,
1859
+ offset: undefined,
1809
1860
  where: childWhere,
1810
1861
  with: options.with,
1811
1862
  timeout: options.timeout ?? timeout,
@@ -1832,11 +1883,12 @@ class PowqlInterface {
1832
1883
  delete child[childKeyField];
1833
1884
  }
1834
1885
  }
1835
- const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1836
1886
  for (const parent of parents) {
1837
1887
  const k = this.joinKey(parent[parentKeyField]);
1838
1888
  const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
1839
- parent[relName] = single ? (matches[0] ?? null) : matches;
1889
+ parent[relName] = single
1890
+ ? (matches[0] ?? null)
1891
+ : PowqlInterface.pageOf(matches, relLimit, relOffset);
1840
1892
  }
1841
1893
  }
1842
1894
  }
@@ -1902,7 +1954,13 @@ class PowqlInterface {
1902
1954
  }
1903
1955
  // (2) Target rows by PK, honouring the relation's own where/with/select/…
1904
1956
  const options = (opt === true ? {} : opt);
1905
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1957
+ this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
1958
+ if (options.limit === 0) {
1959
+ for (const parent of parents)
1960
+ parent[relName] = [];
1961
+ return;
1962
+ }
1963
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1906
1964
  // This loader stitches on the TARGET's own primary key, so the PK has to be
1907
1965
  // in the fetch even when the caller's select/omit excludes it, and has to
1908
1966
  // come back off afterwards. Exactly the shape `loadRelation` uses for its
@@ -1939,6 +1997,11 @@ class PowqlInterface {
1939
1997
  }
1940
1998
  }
1941
1999
  const targetByPk = new Map();
2000
+ // Position of each target in the fetch, i.e. its rank under the relation's
2001
+ // `orderBy`; the stitch below sorts a parent's children by it when an
2002
+ // orderBy was given, so `limit` keeps that parent's top-N and not its first
2003
+ // N junction rows. Across fetch CHUNKS the rank is fetch order.
2004
+ const targetRank = new Map();
1942
2005
  const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
1943
2006
  const targetChunk = this.keyChunkSize(targetMeta, targetPkCol);
1944
2007
  for (let i = 0; i < targetValList.length; i += targetChunk) {
@@ -1949,14 +2012,21 @@ class PowqlInterface {
1949
2012
  };
1950
2013
  const targets = (await targetQi.findMany({
1951
2014
  ...fetchOptions,
2015
+ // Unbounded: the relation limit/offset are per parent, applied in the stitch.
2016
+ limit: undefined,
2017
+ offset: undefined,
1952
2018
  where,
1953
2019
  with: options.with,
1954
2020
  timeout: options.timeout ?? timeout,
1955
2021
  // Public findMany, so the sentinel form. See loadRelation above.
1956
2022
  includePii: includePii ? types_js_1.UNSAFE : undefined,
1957
2023
  }));
1958
- for (const t of targets)
1959
- targetByPk.set(String(t[targetPkField]), t);
2024
+ for (const t of targets) {
2025
+ const pk = String(t[targetPkField]);
2026
+ targetByPk.set(pk, t);
2027
+ if (!targetRank.has(pk))
2028
+ targetRank.set(pk, targetRank.size);
2029
+ }
1960
2030
  }
1961
2031
  // (3) Stitch: each parent → its junction targets (m2m is always a list).
1962
2032
  for (const parent of parents) {
@@ -1968,7 +2038,11 @@ class PowqlInterface {
1968
2038
  if (child)
1969
2039
  children.push(child);
1970
2040
  }
1971
- parent[relName] = children;
2041
+ if (options.orderBy) {
2042
+ const rank = (t) => targetRank.get(String(t[targetPkField])) ?? 0;
2043
+ children.sort((a, b) => rank(a) - rank(b));
2044
+ }
2045
+ parent[relName] = PowqlInterface.pageOf(children, options.limit, options.offset);
1972
2046
  }
1973
2047
  // Stitching is done: take the forced PK back off. Iterating the map rather
1974
2048
  // than the stitched lists is deliberate, one target can be linked from many
@@ -2012,11 +2086,12 @@ class PowqlInterface {
2012
2086
  * `referenceKey` on the TARGET table (the join's non-fetched side);
2013
2087
  * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
2014
2088
  * stitch can't be reproduced by the 3-table join deterministically);
2015
- * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
2016
- * does a to-many relation `limit`/`offset` when the parent set spills past
2017
- * one loader chunk (the loader limits per chunk, the join once globally).
2089
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader. A
2090
+ * to-many relation `limit`/`offset` is a PER-PARENT bound on both paths,
2091
+ * fetched unbounded and sliced per parent at stitch time (`pageOf`), so
2092
+ * the join statement carries neither clause.
2018
2093
  */
2019
- joinEligible(rel, opt, args, parentCount) {
2094
+ joinEligible(rel, opt, args) {
2020
2095
  const effLimit = args.limit ?? this.defaultLimit;
2021
2096
  if (effLimit !== undefined || args.offset)
2022
2097
  return false;
@@ -2057,9 +2132,8 @@ class PowqlInterface {
2057
2132
  return false;
2058
2133
  }
2059
2134
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
2060
- if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
2135
+ if ((options.limit !== undefined || options.offset) && single)
2061
2136
  return false;
2062
- }
2063
2137
  // A `limit 0` relation stays off the join statement for the same reason it
2064
2138
  // stays off a nested projection: PowDB answered `limit 0` with one row below
2065
2139
  // engine 0.20. The loader resolves it client-side, correctly on every version.
@@ -2106,13 +2180,13 @@ class PowqlInterface {
2106
2180
  const { cols: childCols, forcedPk: childForcedPk } = this.joinChildCols(targetQi, options, includePii);
2107
2181
  const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
2108
2182
  const order = targetQi.buildOrder(options.orderBy, params, 'c');
2183
+ // No limit/offset clause: on the join they would bound the TOTAL child
2184
+ // count across every parent. The relation's page is per parent (pageOf).
2109
2185
  this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
2110
- const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
2111
- const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
2112
2186
  const proj = this.joinProjection(childCols, `p.${(0, powdb_shared_js_1.quotePowqlIdent)(parentKeyCol)}`, 'c');
2113
2187
  const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
2114
2188
  `on c.${(0, powdb_shared_js_1.quotePowqlIdent)(childKeyCol)} = p.${(0, powdb_shared_js_1.quotePowqlIdent)(parentKeyCol)}` +
2115
- `${filter}${order}${limitClause}${offsetClause} ${proj}`;
2189
+ `${filter}${order} ${proj}`;
2116
2190
  // A READ: thread a read-shaped action through the exec seam.
2117
2191
  const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
2118
2192
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
@@ -2120,7 +2194,9 @@ class PowqlInterface {
2120
2194
  for (const p of parents) {
2121
2195
  const key = this.joinKey(p[parentKeyField]);
2122
2196
  const matches = (key == null ? undefined : byKey.get(key)) ?? [];
2123
- p[relName] = single ? (matches[0] ?? null) : matches;
2197
+ p[relName] = single
2198
+ ? (matches[0] ?? null)
2199
+ : PowqlInterface.pageOf(matches, options.limit, options.offset);
2124
2200
  }
2125
2201
  }
2126
2202
  /**
package/dist/powql.d.ts CHANGED
@@ -155,6 +155,34 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
155
155
  * the two can never disagree about which limit is in force.
156
156
  */
157
157
  private effectiveLimit;
158
+ /**
159
+ * Options for the child-side interfaces the relation loaders and the
160
+ * relation-filter resolvers build: the client's, with `defaultLimit` cleared
161
+ * and the unlimited warning off. `defaultLimit` bounds the caller's PAGE; on
162
+ * an internal fetch that serves that page (a relation's children, a filter's
163
+ * key set) it would cap the whole page's children, or the key set, at the
164
+ * page size: a silent wrong answer by another route than the one
165
+ * {@link pageOf} closes. The SQL batched loader clears it the same way
166
+ * (`batchedChildOptions` in query/builder.ts).
167
+ */
168
+ private childOptions?;
169
+ private loaderChildOptions;
170
+ /**
171
+ * ONE parent's window of its stitched children: the relation's `offset` and
172
+ * `limit` applied to that parent's own list.
173
+ *
174
+ * A relation `limit` means "at most N on EACH parent". The loaders used to
175
+ * spread it onto the flat child fetch (`post filter .author_id in (...)
176
+ * limit 5`), which caps the TOTAL across every parent in the chunk, so ten
177
+ * parents shared five posts and most got none, with no error. It shipped
178
+ * for the loaders' whole life because the one test of the shape compared
179
+ * the join path against the loader path and both were wrong the same way;
180
+ * the cross-engine benchmark found it, where the wrong answer was the fast
181
+ * one. Every loader now fetches its children unbounded, WITH the relation
182
+ * `orderBy` (which decides which rows the window keeps), and applies this
183
+ * at stitch time: the rule the SQL engines' batched loader has always used.
184
+ */
185
+ private static pageOf;
158
186
  /**
159
187
  * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
160
188
  * casts both with `as usize` at execution, so below engine 0.20 a negative
@@ -623,9 +651,10 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
623
651
  * `referenceKey` on the TARGET table (the join's non-fetched side);
624
652
  * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
625
653
  * stitch can't be reproduced by the 3-table join deterministically);
626
- * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
627
- * does a to-many relation `limit`/`offset` when the parent set spills past
628
- * one loader chunk (the loader limits per chunk, the join once globally).
654
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader. A
655
+ * to-many relation `limit`/`offset` is a PER-PARENT bound on both paths,
656
+ * fetched unbounded and sliced per parent at stitch time (`pageOf`), so
657
+ * the join statement carries neither clause.
629
658
  */
630
659
  private joinEligible;
631
660
  /**
package/dist/powql.js CHANGED
@@ -357,6 +357,42 @@ export class PowqlInterface {
357
357
  effectiveLimit(args) {
358
358
  return args.limit ?? this.defaultLimit;
359
359
  }
360
+ /**
361
+ * Options for the child-side interfaces the relation loaders and the
362
+ * relation-filter resolvers build: the client's, with `defaultLimit` cleared
363
+ * and the unlimited warning off. `defaultLimit` bounds the caller's PAGE; on
364
+ * an internal fetch that serves that page (a relation's children, a filter's
365
+ * key set) it would cap the whole page's children, or the key set, at the
366
+ * page size: a silent wrong answer by another route than the one
367
+ * {@link pageOf} closes. The SQL batched loader clears it the same way
368
+ * (`batchedChildOptions` in query/builder.ts).
369
+ */
370
+ childOptions;
371
+ loaderChildOptions() {
372
+ this.childOptions ??= { ...this.options, defaultLimit: undefined, warnOnUnlimited: false };
373
+ return this.childOptions;
374
+ }
375
+ /**
376
+ * ONE parent's window of its stitched children: the relation's `offset` and
377
+ * `limit` applied to that parent's own list.
378
+ *
379
+ * A relation `limit` means "at most N on EACH parent". The loaders used to
380
+ * spread it onto the flat child fetch (`post filter .author_id in (...)
381
+ * limit 5`), which caps the TOTAL across every parent in the chunk, so ten
382
+ * parents shared five posts and most got none, with no error. It shipped
383
+ * for the loaders' whole life because the one test of the shape compared
384
+ * the join path against the loader path and both were wrong the same way;
385
+ * the cross-engine benchmark found it, where the wrong answer was the fast
386
+ * one. Every loader now fetches its children unbounded, WITH the relation
387
+ * `orderBy` (which decides which rows the window keeps), and applies this
388
+ * at stitch time: the rule the SQL engines' batched loader has always used.
389
+ */
390
+ static pageOf(rows, limit, offset) {
391
+ if (limit === undefined && !offset)
392
+ return rows;
393
+ const start = offset ?? 0;
394
+ return rows.slice(start, limit === undefined ? undefined : start + limit);
395
+ }
360
396
  /**
361
397
  * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
362
398
  * casts both with `as usize` at execution, so below engine 0.20 a negative
@@ -938,7 +974,7 @@ export class PowqlInterface {
938
974
  const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
939
975
  const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
940
976
  const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
941
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
977
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
942
978
  const collect = async (w) => {
943
979
  const rows = await targetQi.findMany({
944
980
  where: w,
@@ -977,7 +1013,7 @@ export class PowqlInterface {
977
1013
  const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
978
1014
  const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
979
1015
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
980
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1016
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
981
1017
  const collectTargetPks = async (w) => {
982
1018
  const rows = await targetQi.findMany({
983
1019
  where: w,
@@ -1689,7 +1725,7 @@ export class PowqlInterface {
1689
1725
  const rel = this.meta.relations[relName];
1690
1726
  if (!rel)
1691
1727
  throw new ValidationError(`Unknown relation "${relName}" on "${this.table}".`);
1692
- if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
1728
+ if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args)) {
1693
1729
  if (this.capabilities.serverJoins) {
1694
1730
  await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
1695
1731
  continue;
@@ -1712,7 +1748,19 @@ export class PowqlInterface {
1712
1748
  throw new UnsupportedFeatureError('composite-key nested reads', 'PowDB', `relation "${relName}"`);
1713
1749
  }
1714
1750
  const options = (opt === true ? {} : opt);
1715
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1751
+ // The relation's own page, applied PER PARENT at stitch time (see pageOf),
1752
+ // never on the flat fetch below.
1753
+ const relLimit = options.limit;
1754
+ const relOffset = options.offset;
1755
+ this.assertPagination(relLimit, relOffset, `relation "${relName}"`);
1756
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1757
+ if (relLimit === 0) {
1758
+ // `[]` on every parent by construction: nothing to fetch.
1759
+ for (const parent of parents)
1760
+ parent[relName] = single ? null : [];
1761
+ continue;
1762
+ }
1763
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1716
1764
  const targetMeta = this.schema.tables[rel.to];
1717
1765
  // Local key on the parent, remote key on the target child.
1718
1766
  const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
@@ -1770,6 +1818,9 @@ export class PowqlInterface {
1770
1818
  };
1771
1819
  const children = (await targetQi.findMany({
1772
1820
  ...fetchOptions,
1821
+ // Unbounded: the relation limit/offset are per parent, applied below.
1822
+ limit: undefined,
1823
+ offset: undefined,
1773
1824
  where: childWhere,
1774
1825
  with: options.with,
1775
1826
  timeout: options.timeout ?? timeout,
@@ -1796,11 +1847,12 @@ export class PowqlInterface {
1796
1847
  delete child[childKeyField];
1797
1848
  }
1798
1849
  }
1799
- const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1800
1850
  for (const parent of parents) {
1801
1851
  const k = this.joinKey(parent[parentKeyField]);
1802
1852
  const matches = (k == null ? undefined : childByKey.get(k)) ?? [];
1803
- parent[relName] = single ? (matches[0] ?? null) : matches;
1853
+ parent[relName] = single
1854
+ ? (matches[0] ?? null)
1855
+ : PowqlInterface.pageOf(matches, relLimit, relOffset);
1804
1856
  }
1805
1857
  }
1806
1858
  }
@@ -1866,7 +1918,13 @@ export class PowqlInterface {
1866
1918
  }
1867
1919
  // (2) Target rows by PK, honouring the relation's own where/with/select/…
1868
1920
  const options = (opt === true ? {} : opt);
1869
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1921
+ this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
1922
+ if (options.limit === 0) {
1923
+ for (const parent of parents)
1924
+ parent[relName] = [];
1925
+ return;
1926
+ }
1927
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1870
1928
  // This loader stitches on the TARGET's own primary key, so the PK has to be
1871
1929
  // in the fetch even when the caller's select/omit excludes it, and has to
1872
1930
  // come back off afterwards. Exactly the shape `loadRelation` uses for its
@@ -1903,6 +1961,11 @@ export class PowqlInterface {
1903
1961
  }
1904
1962
  }
1905
1963
  const targetByPk = new Map();
1964
+ // Position of each target in the fetch, i.e. its rank under the relation's
1965
+ // `orderBy`; the stitch below sorts a parent's children by it when an
1966
+ // orderBy was given, so `limit` keeps that parent's top-N and not its first
1967
+ // N junction rows. Across fetch CHUNKS the rank is fetch order.
1968
+ const targetRank = new Map();
1906
1969
  const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
1907
1970
  const targetChunk = this.keyChunkSize(targetMeta, targetPkCol);
1908
1971
  for (let i = 0; i < targetValList.length; i += targetChunk) {
@@ -1913,14 +1976,21 @@ export class PowqlInterface {
1913
1976
  };
1914
1977
  const targets = (await targetQi.findMany({
1915
1978
  ...fetchOptions,
1979
+ // Unbounded: the relation limit/offset are per parent, applied in the stitch.
1980
+ limit: undefined,
1981
+ offset: undefined,
1916
1982
  where,
1917
1983
  with: options.with,
1918
1984
  timeout: options.timeout ?? timeout,
1919
1985
  // Public findMany, so the sentinel form. See loadRelation above.
1920
1986
  includePii: includePii ? UNSAFE : undefined,
1921
1987
  }));
1922
- for (const t of targets)
1923
- targetByPk.set(String(t[targetPkField]), t);
1988
+ for (const t of targets) {
1989
+ const pk = String(t[targetPkField]);
1990
+ targetByPk.set(pk, t);
1991
+ if (!targetRank.has(pk))
1992
+ targetRank.set(pk, targetRank.size);
1993
+ }
1924
1994
  }
1925
1995
  // (3) Stitch: each parent → its junction targets (m2m is always a list).
1926
1996
  for (const parent of parents) {
@@ -1932,7 +2002,11 @@ export class PowqlInterface {
1932
2002
  if (child)
1933
2003
  children.push(child);
1934
2004
  }
1935
- parent[relName] = children;
2005
+ if (options.orderBy) {
2006
+ const rank = (t) => targetRank.get(String(t[targetPkField])) ?? 0;
2007
+ children.sort((a, b) => rank(a) - rank(b));
2008
+ }
2009
+ parent[relName] = PowqlInterface.pageOf(children, options.limit, options.offset);
1936
2010
  }
1937
2011
  // Stitching is done: take the forced PK back off. Iterating the map rather
1938
2012
  // than the stitched lists is deliberate, one target can be linked from many
@@ -1976,11 +2050,12 @@ export class PowqlInterface {
1976
2050
  * `referenceKey` on the TARGET table (the join's non-fetched side);
1977
2051
  * - m2m keeps any `orderBy`/`limit`/`offset` on the loader (the junction-order
1978
2052
  * stitch can't be reproduced by the 3-table join deterministically);
1979
- * - a to-one relation `limit`/`offset` (meaningless) stays on the loader, as
1980
- * does a to-many relation `limit`/`offset` when the parent set spills past
1981
- * one loader chunk (the loader limits per chunk, the join once globally).
2053
+ * - a to-one relation `limit`/`offset` (meaningless) stays on the loader. A
2054
+ * to-many relation `limit`/`offset` is a PER-PARENT bound on both paths,
2055
+ * fetched unbounded and sliced per parent at stitch time (`pageOf`), so
2056
+ * the join statement carries neither clause.
1982
2057
  */
1983
- joinEligible(rel, opt, args, parentCount) {
2058
+ joinEligible(rel, opt, args) {
1984
2059
  const effLimit = args.limit ?? this.defaultLimit;
1985
2060
  if (effLimit !== undefined || args.offset)
1986
2061
  return false;
@@ -2021,9 +2096,8 @@ export class PowqlInterface {
2021
2096
  return false;
2022
2097
  }
2023
2098
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
2024
- if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
2099
+ if ((options.limit !== undefined || options.offset) && single)
2025
2100
  return false;
2026
- }
2027
2101
  // A `limit 0` relation stays off the join statement for the same reason it
2028
2102
  // stays off a nested projection: PowDB answered `limit 0` with one row below
2029
2103
  // engine 0.20. The loader resolves it client-side, correctly on every version.
@@ -2070,13 +2144,13 @@ export class PowqlInterface {
2070
2144
  const { cols: childCols, forcedPk: childForcedPk } = this.joinChildCols(targetQi, options, includePii);
2071
2145
  const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
2072
2146
  const order = targetQi.buildOrder(options.orderBy, params, 'c');
2147
+ // No limit/offset clause: on the join they would bound the TOTAL child
2148
+ // count across every parent. The relation's page is per parent (pageOf).
2073
2149
  this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
2074
- const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
2075
- const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
2076
2150
  const proj = this.joinProjection(childCols, `p.${quotePowqlIdent(parentKeyCol)}`, 'c');
2077
2151
  const powql = `${targetQi.qt} as c join ${this.qt} as p ` +
2078
2152
  `on c.${quotePowqlIdent(childKeyCol)} = p.${quotePowqlIdent(parentKeyCol)}` +
2079
- `${filter}${order}${limitClause}${offsetClause} ${proj}`;
2153
+ `${filter}${order} ${proj}`;
2080
2154
  // A READ: thread a read-shaped action through the exec seam.
2081
2155
  const { rows, native } = await targetQi.exec(powql, params, timeout, 'findMany');
2082
2156
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
@@ -2084,7 +2158,9 @@ export class PowqlInterface {
2084
2158
  for (const p of parents) {
2085
2159
  const key = this.joinKey(p[parentKeyField]);
2086
2160
  const matches = (key == null ? undefined : byKey.get(key)) ?? [];
2087
- p[relName] = single ? (matches[0] ?? null) : matches;
2161
+ p[relName] = single
2162
+ ? (matches[0] ?? null)
2163
+ : PowqlInterface.pageOf(matches, options.limit, options.offset);
2088
2164
  }
2089
2165
  }
2090
2166
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.78.0-next.4bd3c8f",
3
+ "version": "0.78.0-next.b792a6a",
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": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",