turbine-orm 0.32.1 → 0.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/powql.js CHANGED
@@ -1216,6 +1216,17 @@ class PowqlInterface {
1216
1216
  proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
1217
1217
  }
1218
1218
  const having = this.buildHaving(args.having, params);
1219
+ // groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
1220
+ // equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
1221
+ // a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
1222
+ // Refuse those keys explicitly; plain by-field ordering still flows through.
1223
+ if (args.orderBy) {
1224
+ for (const key of Object.keys(args.orderBy)) {
1225
+ if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
1226
+ throw new errors_js_1.UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
1227
+ }
1228
+ }
1229
+ }
1219
1230
  const order = this.buildOrder(args.orderBy);
1220
1231
  const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1221
1232
  const { rows } = await this.exec(powql, params, args.timeout);
@@ -1854,6 +1854,15 @@ class QueryInterface {
1854
1854
  const selectExprs = [];
1855
1855
  /** by entries in order: how to read each group key off the result row. */
1856
1856
  const byReaders = [];
1857
+ // ORDER BY registries: map each key the groupBy RESULT actually contains to
1858
+ // the exact SELECT expression that produced it, so `orderBy` re-emits that
1859
+ // expression (never a SELECT alias, since not every dialect accepts alias
1860
+ // references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
1861
+ // `byOrderExprs`: plain by-field name / JSON group-key alias → column or
1862
+ // extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
1863
+ // expression (including any already-bound JSON-path placeholder, reused
1864
+ // exactly like HAVING since ORDER BY is appended after all other params).
1865
+ const byOrderExprs = new Map();
1857
1866
  const usedResultKeys = new Set();
1858
1867
  const claimResultKey = (key, what) => {
1859
1868
  if (key === '_count' || usedResultKeys.has(key)) {
@@ -1874,6 +1883,7 @@ class QueryInterface {
1874
1883
  groupExprs.push(this.q(col));
1875
1884
  selectExprs.push(this.q(col));
1876
1885
  byReaders.push({ resultKey: entry, rowKey: col, raw: false });
1886
+ byOrderExprs.set(entry, this.q(col));
1877
1887
  }
1878
1888
  else {
1879
1889
  const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
@@ -1885,13 +1895,24 @@ class QueryInterface {
1885
1895
  selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
1886
1896
  groupExprs.push(extract);
1887
1897
  byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
1898
+ // ORDER BY by this JSON alias re-emits the extract expression (with its
1899
+ // already-bound $n): the same reuse HAVING does for JSON aggregates.
1900
+ byOrderExprs.set(alias, extract);
1888
1901
  }
1889
1902
  }
1890
1903
  // _count
1891
- if (args._count === true || args._count === undefined) {
1904
+ const countSelected = args._count === true || args._count === undefined;
1905
+ if (countSelected) {
1892
1906
  // default: always include count
1893
1907
  selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1894
1908
  }
1909
+ // ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
1910
+ // `_count`). Populated alongside the SELECT list below so `orderBy` can only
1911
+ // reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
1912
+ // the ordering expression (the SELECT cast is only for the returned value).
1913
+ const aggOrderExprs = new Map();
1914
+ if (countSelected)
1915
+ aggOrderExprs.set('_count', 'COUNT(*)');
1895
1916
  // _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
1896
1917
  // {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
1897
1918
  // as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
@@ -1914,6 +1935,7 @@ class QueryInterface {
1914
1935
  const inner = `${sqlFn}(${this.q(col)})`;
1915
1936
  const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1916
1937
  selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
1938
+ aggOrderExprs.set(`${aggKey}:${key}`, expr);
1917
1939
  continue;
1918
1940
  }
1919
1941
  const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
@@ -1931,6 +1953,7 @@ class QueryInterface {
1931
1953
  selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
1932
1954
  jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
1933
1955
  jsonAggExprs.set(`${key}:${aggKey}`, expr);
1956
+ aggOrderExprs.set(`${aggKey}:${key}`, expr);
1934
1957
  }
1935
1958
  };
1936
1959
  buildAggregates('_sum', 'SUM', args._sum);
@@ -1947,9 +1970,12 @@ class QueryInterface {
1947
1970
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
1948
1971
  }
1949
1972
  }
1950
- // ORDER BY
1973
+ // ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
1974
+ // requested aggregates), not the table's physical columns.
1951
1975
  if (args.orderBy) {
1952
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
1976
+ const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
1977
+ if (orderSql)
1978
+ sql += ` ORDER BY ${orderSql}`;
1953
1979
  }
1954
1980
  return {
1955
1981
  sql,
@@ -2014,6 +2040,74 @@ class QueryInterface {
2014
2040
  tag: `${this.table}.groupBy`,
2015
2041
  };
2016
2042
  }
2043
+ /**
2044
+ * Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
2045
+ * ({@link buildOrderBy}, which validates keys against the table's physical
2046
+ * columns), groupBy ordering targets the columns the RESULT actually
2047
+ * contains: plain by-fields, JSON group-key aliases, and requested aggregates
2048
+ * (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
2049
+ * SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
2050
+ * mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
2051
+ * to accept a SELECT-alias reference in ORDER BY, and any already-bound
2052
+ * JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
2053
+ * no `$n` renumbering). An aggregate key that was not requested, or an unknown
2054
+ * by-key, throws {@link ValidationError} E003 listing the valid keys.
2055
+ */
2056
+ buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
2057
+ const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
2058
+ /** Human-readable list of every key this call can order by (for E003). */
2059
+ const validKeys = () => {
2060
+ const keys = [...byOrderExprs.keys()];
2061
+ for (const k of aggOrderExprs.keys()) {
2062
+ keys.push(k.includes(':') ? k.replace(':', '.') : k);
2063
+ }
2064
+ return keys.join(', ') || '(none)';
2065
+ };
2066
+ const parts = [];
2067
+ for (const [key, value] of Object.entries(orderBy)) {
2068
+ if (value === undefined)
2069
+ continue;
2070
+ // Aggregate ordering blocks.
2071
+ if (aggBlocks.has(key)) {
2072
+ if (key === '_count') {
2073
+ const expr = aggOrderExprs.get('_count');
2074
+ if (!expr) {
2075
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
2076
+ `Orderable keys: ${validKeys()}.`);
2077
+ }
2078
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
2079
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2080
+ continue;
2081
+ }
2082
+ // `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
2083
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
2084
+ throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
2085
+ `expected a field map like { ${key}: { amount: 'desc' } }.`);
2086
+ }
2087
+ for (const [field, dirSpec] of Object.entries(value)) {
2088
+ if (dirSpec === undefined)
2089
+ continue;
2090
+ const expr = aggOrderExprs.get(`${key}:${field}`);
2091
+ if (!expr) {
2092
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
2093
+ `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
2094
+ }
2095
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirSpec);
2096
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2097
+ }
2098
+ continue;
2099
+ }
2100
+ // Plain by-field name or JSON group-key alias.
2101
+ const expr = byOrderExprs.get(key);
2102
+ if (!expr) {
2103
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
2104
+ `Orderable keys: ${validKeys()}.`);
2105
+ }
2106
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
2107
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2108
+ }
2109
+ return parts.join(', ');
2110
+ }
2017
2111
  /**
2018
2112
  * Validate a JSON-path target (group key or aggregate target) in groupBy:
2019
2113
  * the field must resolve to a real json/jsonb column and the path must be a
package/dist/powql.js CHANGED
@@ -1180,6 +1180,17 @@ export class PowqlInterface {
1180
1180
  proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
1181
1181
  }
1182
1182
  const having = this.buildHaving(args.having, params);
1183
+ // groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
1184
+ // equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
1185
+ // a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
1186
+ // Refuse those keys explicitly; plain by-field ordering still flows through.
1187
+ if (args.orderBy) {
1188
+ for (const key of Object.keys(args.orderBy)) {
1189
+ if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
1190
+ throw new UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
1191
+ }
1192
+ }
1193
+ }
1183
1194
  const order = this.buildOrder(args.orderBy);
1184
1195
  const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1185
1196
  const { rows } = await this.exec(powql, params, args.timeout);
@@ -382,6 +382,20 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
382
382
  buildCount(args?: CountArgs<T>): DeferredQuery<number>;
383
383
  groupBy(args: GroupByArgs<T>): Promise<Record<string, unknown>[]>;
384
384
  buildGroupBy(args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
385
+ /**
386
+ * Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
387
+ * ({@link buildOrderBy}, which validates keys against the table's physical
388
+ * columns), groupBy ordering targets the columns the RESULT actually
389
+ * contains: plain by-fields, JSON group-key aliases, and requested aggregates
390
+ * (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
391
+ * SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
392
+ * mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
393
+ * to accept a SELECT-alias reference in ORDER BY, and any already-bound
394
+ * JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
395
+ * no `$n` renumbering). An aggregate key that was not requested, or an unknown
396
+ * by-key, throws {@link ValidationError} E003 listing the valid keys.
397
+ */
398
+ private buildGroupByOrderBy;
385
399
  /**
386
400
  * Validate a JSON-path target (group key or aggregate target) in groupBy:
387
401
  * the field must resolve to a real json/jsonb column and the path must be a
@@ -1818,6 +1818,15 @@ export class QueryInterface {
1818
1818
  const selectExprs = [];
1819
1819
  /** by entries in order: how to read each group key off the result row. */
1820
1820
  const byReaders = [];
1821
+ // ORDER BY registries: map each key the groupBy RESULT actually contains to
1822
+ // the exact SELECT expression that produced it, so `orderBy` re-emits that
1823
+ // expression (never a SELECT alias, since not every dialect accepts alias
1824
+ // references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
1825
+ // `byOrderExprs`: plain by-field name / JSON group-key alias → column or
1826
+ // extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
1827
+ // expression (including any already-bound JSON-path placeholder, reused
1828
+ // exactly like HAVING since ORDER BY is appended after all other params).
1829
+ const byOrderExprs = new Map();
1821
1830
  const usedResultKeys = new Set();
1822
1831
  const claimResultKey = (key, what) => {
1823
1832
  if (key === '_count' || usedResultKeys.has(key)) {
@@ -1838,6 +1847,7 @@ export class QueryInterface {
1838
1847
  groupExprs.push(this.q(col));
1839
1848
  selectExprs.push(this.q(col));
1840
1849
  byReaders.push({ resultKey: entry, rowKey: col, raw: false });
1850
+ byOrderExprs.set(entry, this.q(col));
1841
1851
  }
1842
1852
  else {
1843
1853
  const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
@@ -1849,13 +1859,24 @@ export class QueryInterface {
1849
1859
  selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
1850
1860
  groupExprs.push(extract);
1851
1861
  byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
1862
+ // ORDER BY by this JSON alias re-emits the extract expression (with its
1863
+ // already-bound $n): the same reuse HAVING does for JSON aggregates.
1864
+ byOrderExprs.set(alias, extract);
1852
1865
  }
1853
1866
  }
1854
1867
  // _count
1855
- if (args._count === true || args._count === undefined) {
1868
+ const countSelected = args._count === true || args._count === undefined;
1869
+ if (countSelected) {
1856
1870
  // default: always include count
1857
1871
  selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1858
1872
  }
1873
+ // ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
1874
+ // `_count`). Populated alongside the SELECT list below so `orderBy` can only
1875
+ // reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
1876
+ // the ordering expression (the SELECT cast is only for the returned value).
1877
+ const aggOrderExprs = new Map();
1878
+ if (countSelected)
1879
+ aggOrderExprs.set('_count', 'COUNT(*)');
1859
1880
  // _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
1860
1881
  // {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
1861
1882
  // as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
@@ -1878,6 +1899,7 @@ export class QueryInterface {
1878
1899
  const inner = `${sqlFn}(${this.q(col)})`;
1879
1900
  const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1880
1901
  selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
1902
+ aggOrderExprs.set(`${aggKey}:${key}`, expr);
1881
1903
  continue;
1882
1904
  }
1883
1905
  const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
@@ -1895,6 +1917,7 @@ export class QueryInterface {
1895
1917
  selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
1896
1918
  jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
1897
1919
  jsonAggExprs.set(`${key}:${aggKey}`, expr);
1920
+ aggOrderExprs.set(`${aggKey}:${key}`, expr);
1898
1921
  }
1899
1922
  };
1900
1923
  buildAggregates('_sum', 'SUM', args._sum);
@@ -1911,9 +1934,12 @@ export class QueryInterface {
1911
1934
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
1912
1935
  }
1913
1936
  }
1914
- // ORDER BY
1937
+ // ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
1938
+ // requested aggregates), not the table's physical columns.
1915
1939
  if (args.orderBy) {
1916
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
1940
+ const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
1941
+ if (orderSql)
1942
+ sql += ` ORDER BY ${orderSql}`;
1917
1943
  }
1918
1944
  return {
1919
1945
  sql,
@@ -1978,6 +2004,74 @@ export class QueryInterface {
1978
2004
  tag: `${this.table}.groupBy`,
1979
2005
  };
1980
2006
  }
2007
+ /**
2008
+ * Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
2009
+ * ({@link buildOrderBy}, which validates keys against the table's physical
2010
+ * columns), groupBy ordering targets the columns the RESULT actually
2011
+ * contains: plain by-fields, JSON group-key aliases, and requested aggregates
2012
+ * (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
2013
+ * SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
2014
+ * mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
2015
+ * to accept a SELECT-alias reference in ORDER BY, and any already-bound
2016
+ * JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
2017
+ * no `$n` renumbering). An aggregate key that was not requested, or an unknown
2018
+ * by-key, throws {@link ValidationError} E003 listing the valid keys.
2019
+ */
2020
+ buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
2021
+ const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
2022
+ /** Human-readable list of every key this call can order by (for E003). */
2023
+ const validKeys = () => {
2024
+ const keys = [...byOrderExprs.keys()];
2025
+ for (const k of aggOrderExprs.keys()) {
2026
+ keys.push(k.includes(':') ? k.replace(':', '.') : k);
2027
+ }
2028
+ return keys.join(', ') || '(none)';
2029
+ };
2030
+ const parts = [];
2031
+ for (const [key, value] of Object.entries(orderBy)) {
2032
+ if (value === undefined)
2033
+ continue;
2034
+ // Aggregate ordering blocks.
2035
+ if (aggBlocks.has(key)) {
2036
+ if (key === '_count') {
2037
+ const expr = aggOrderExprs.get('_count');
2038
+ if (!expr) {
2039
+ throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
2040
+ `Orderable keys: ${validKeys()}.`);
2041
+ }
2042
+ const { dir, nulls } = normalizeOrderBy(value);
2043
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2044
+ continue;
2045
+ }
2046
+ // `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
2047
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
2048
+ throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
2049
+ `expected a field map like { ${key}: { amount: 'desc' } }.`);
2050
+ }
2051
+ for (const [field, dirSpec] of Object.entries(value)) {
2052
+ if (dirSpec === undefined)
2053
+ continue;
2054
+ const expr = aggOrderExprs.get(`${key}:${field}`);
2055
+ if (!expr) {
2056
+ throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
2057
+ `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
2058
+ }
2059
+ const { dir, nulls } = normalizeOrderBy(dirSpec);
2060
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2061
+ }
2062
+ continue;
2063
+ }
2064
+ // Plain by-field name or JSON group-key alias.
2065
+ const expr = byOrderExprs.get(key);
2066
+ if (!expr) {
2067
+ throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
2068
+ `Orderable keys: ${validKeys()}.`);
2069
+ }
2070
+ const { dir, nulls } = normalizeOrderBy(value);
2071
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2072
+ }
2073
+ return parts.join(', ');
2074
+ }
1981
2075
  /**
1982
2076
  * Validate a JSON-path target (group key or aggregate target) in groupBy:
1983
2077
  * the field must resolve to a real json/jsonb column and the path must be a
@@ -657,6 +657,37 @@ export interface GroupByDistinctOn<T> {
657
657
  /** Which row survives per combination (required for determinism). */
658
658
  orderBy: Record<string, OrderDirection | OrderBySpec | JsonPathOrderBy>;
659
659
  }
660
+ /** A per-field aggregate ordering block: field/alias → direction or sort spec. */
661
+ export type GroupByAggregateOrderBy = Record<string, OrderDirection | OrderBySpec>;
662
+ /**
663
+ * {@link GroupByArgs.orderBy}: order the result groups by any column the
664
+ * groupBy result contains.
665
+ *
666
+ * - A plain **by-column** field name, or a **JSON group-key alias** (explicit
667
+ * `alias`, else the last path segment): `{ region: 'asc' }`.
668
+ * - An **aggregate block**: `_count` takes a direction/spec directly
669
+ * (`{ _count: 'desc' }`); `_sum`/`_avg`/`_min`/`_max` take a field map keyed
670
+ * by a requested aggregate field/alias (`{ _sum: { amount: 'desc' } }`).
671
+ *
672
+ * An aggregate ordering that references an aggregate not requested in the same
673
+ * call (or an unknown by-key) throws {@link ValidationError} (E003). Every
674
+ * value accepts an {@link OrderBySpec} for `NULLS FIRST/LAST` placement
675
+ * (PostgreSQL / SQLite only).
676
+ */
677
+ export interface GroupByOrderBy {
678
+ /** Order by the group's row count (requires `_count` to be selected). */
679
+ _count?: OrderDirection | OrderBySpec;
680
+ /** Order by a requested `_sum` aggregate, keyed by its field/alias. */
681
+ _sum?: GroupByAggregateOrderBy;
682
+ /** Order by a requested `_avg` aggregate, keyed by its field/alias. */
683
+ _avg?: GroupByAggregateOrderBy;
684
+ /** Order by a requested `_min` aggregate, keyed by its field/alias. */
685
+ _min?: GroupByAggregateOrderBy;
686
+ /** Order by a requested `_max` aggregate, keyed by its field/alias. */
687
+ _max?: GroupByAggregateOrderBy;
688
+ /** A by-column field name or JSON group-key alias → direction or sort spec. */
689
+ [key: string]: OrderDirection | OrderBySpec | GroupByAggregateOrderBy | undefined;
690
+ }
660
691
  export interface GroupByArgs<T> {
661
692
  /** Group keys: plain column field names and/or JSON-path keys ({@link JsonPathGroupKey}). */
662
693
  by: ((keyof T & string) | JsonPathGroupKey)[];
@@ -678,8 +709,15 @@ export interface GroupByArgs<T> {
678
709
  _max?: GroupByAggregateSpec<T>;
679
710
  /** Filter whole groups by their aggregate values (SQL HAVING). JSON-path aggregates key by their alias. */
680
711
  having?: HavingClause<T>;
681
- /** Order groups (supports {@link OrderBySpec} for NULLS placement). */
682
- orderBy?: Record<string, OrderDirection | OrderBySpec>;
712
+ /**
713
+ * Order the result groups. Keys may be any column the groupBy result actually
714
+ * contains: a plain by-column field name, a JSON group-key alias (explicit
715
+ * `alias`, or the last path segment when unaliased), or an aggregate block
716
+ * (`_count`, or `_sum`/`_avg`/`_min`/`_max` mapping a requested field/alias to
717
+ * its direction). Every value supports {@link OrderBySpec} for NULLS
718
+ * placement. See {@link GroupByOrderBy}.
719
+ */
720
+ orderBy?: GroupByOrderBy;
683
721
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
684
722
  timeout?: number;
685
723
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.32.1",
3
+ "version": "0.32.2",
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": {