@everystack/api 0.4.6 → 0.4.8

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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to `@everystack/api` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.8] - 2026-10-18
8
+
9
+ ### Fixed
10
+
11
+ - compile under NodeNext; the primary-key tiebreak no longer hands a pg-core column to asc()
12
+
13
+ ## [0.4.7] - 2026-10-18
14
+
15
+ ### BREAKING CHANGES
16
+
17
+ - date filters no longer throw; refuse impossible calendar dates; map 22007 and 22008 to 422
18
+ - list fields sort by default; the API refuses an order it cannot apply (#92)
19
+ - datetimes show in the viewer's zone and edit round-trip in it (#94)
20
+
21
+ ### Added
22
+
23
+ - HandlerError carries a machine-readable code and column on RPC answers
24
+
25
+ ### Fixed
26
+
27
+ - hiddenColumns accepts the camelCase of a column's SQL name; an ambiguous alias refuses at init
28
+
29
+ ### Changed
30
+
31
+ - move HandlerError to its own import-free module, @everystack/api/errors
32
+
7
33
  ## [0.4.6] - 2026-10-16
8
34
 
9
35
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -27,6 +27,11 @@
27
27
  "types": "./src/handler/index.ts",
28
28
  "default": "./src/handler/index.ts"
29
29
  },
30
+ "./errors": {
31
+ "source": "./src/handler/errors.ts",
32
+ "types": "./src/handler/errors.ts",
33
+ "default": "./src/handler/errors.ts"
34
+ },
30
35
  "./client": {
31
36
  "source": "./src/client/index.ts",
32
37
  "types": "./src/client/index.ts",
@@ -0,0 +1,39 @@
1
+ /**
2
+ * HandlerError lives in its own module with no imports, so code that only needs to throw one
3
+ * (the admin's RPC factory) does not pull the whole server handler into its bundle.
4
+ */
5
+
6
+ /** An application error code: lower-case words, never a Postgres SQLSTATE or a system errno. */
7
+ const APP_ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/;
8
+ const isAppErrorCode = (value: unknown): value is string => typeof value === 'string' && APP_ERROR_CODE.test(value);
9
+ const isAppErrorColumn = (value: unknown): value is string =>
10
+ typeof value === 'string' && value.length >= 1 && value.length <= 128 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
11
+
12
+ const appErrorMetadata = new WeakMap<object, { readonly code?: string; readonly column?: string }>();
13
+ const EMPTY_METADATA: { readonly code?: string; readonly column?: string } = Object.freeze(Object.create(null));
14
+
15
+ /** Throw from hooks or RPCs to return a custom status and message; RPCs also expose optional metadata. */
16
+ export class HandlerError extends Error {
17
+ status: number;
18
+ constructor(status: number, message: string, options?: { code?: string; column?: string }) {
19
+ super(message);
20
+ const code = options?.code;
21
+ const column = options?.column;
22
+ if (code !== undefined && !isAppErrorCode(code)) {
23
+ throw new TypeError('HandlerError code must match /^[a-z][a-z0-9_]{0,63}$/');
24
+ }
25
+ if (column !== undefined && !isAppErrorColumn(column)) {
26
+ throw new TypeError('HandlerError column must be 1 to 128 characters with no control characters');
27
+ }
28
+ this.status = status;
29
+ this.name = 'HandlerError';
30
+ // Held outside the instance: only what the author passed here is ever sent, and no property
31
+ // or getter on the error can change it afterwards.
32
+ appErrorMetadata.set(this, Object.freeze({ code, column }));
33
+ }
34
+
35
+ /** The `code` and `column` given to the constructor. Anything assigned to the error later is not here. */
36
+ static metadataOf(error: HandlerError): { code?: string; column?: string } {
37
+ return appErrorMetadata.get(error) ?? EMPTY_METADATA;
38
+ }
39
+ }
@@ -7,7 +7,8 @@
7
7
  * // Mount as Expo Router API route or any Web Standard handler
8
8
  */
9
9
 
10
- import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, arrayContains, arrayContained, arrayOverlaps, and, or, not, asc, desc, sql, getTableColumns, Param, type SQL } from 'drizzle-orm';
10
+ import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, arrayContains, arrayContained, arrayOverlaps, and, or, not, asc, desc, sql, getTableColumns, is, Param, type SQL } from 'drizzle-orm';
11
+ import { getTableConfig, PgTable } from 'drizzle-orm/pg-core';
11
12
  import { parseArrayLiteral, ARRAY_LITERAL_MAX_ELEMENTS, ARRAY_LITERAL_MAX_ELEMENT_LENGTH } from './array-literal';
12
13
  import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
13
14
  import { isServiceUnavailable, SERVICE_UNAVAILABLE_MESSAGE } from './service-unavailable';
@@ -25,15 +26,8 @@ type Schema = Record<string, DrizzleTable>;
25
26
 
26
27
  type Handler = (request: Request) => Promise<Response>;
27
28
 
28
- /** Throw from hooks to return a custom status code and message to the client. */
29
- export class HandlerError extends Error {
30
- status: number;
31
- constructor(status: number, message: string) {
32
- super(message);
33
- this.status = status;
34
- this.name = 'HandlerError';
35
- }
36
- }
29
+ export { HandlerError } from './errors';
30
+ import { HandlerError } from './errors';
37
31
 
38
32
  /**
39
33
  * A 5xx system fault surfaced to the observability sink. Distinct from a 4xx
@@ -177,13 +171,18 @@ function describeError(error: unknown): Pick<HandlerFault, 'message' | 'stack' |
177
171
  }
178
172
 
179
173
  /** Replace schema-revealing error details with a generic message when hideSchemaErrors is enabled. */
180
- function sanitizeError(message: string, status: number, options?: HandlerOptions): Response {
174
+ function sanitizeError(
175
+ message: string,
176
+ status: number,
177
+ options?: HandlerOptions,
178
+ extra?: { code: string; column: string },
179
+ ): Response {
181
180
  if (options?.hideSchemaErrors) {
182
181
  if (status === 404) return json({ message: 'Not found' }, 404);
183
182
  if (status === 403) return json({ message: 'Forbidden' }, 403);
184
- if (status === 400) return json({ message: 'Bad request' }, 400);
183
+ if (status === 400) return json({ message: 'Bad request', ...extra }, 400);
185
184
  }
186
- return json({ message }, status);
185
+ return json({ message, ...extra }, status);
187
186
  }
188
187
 
189
188
  export interface AuthConfig {
@@ -718,6 +717,48 @@ function transformBodyKeys(body: unknown, table: DrizzleTable): unknown {
718
717
  */
719
718
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?$/;
720
719
 
720
+ /**
721
+ * The one date rule, shared by a write body and a query-string filter so the two cannot
722
+ * drift: the Date for `value`, or the 400 message naming `key`.
723
+ */
724
+ function parseDateValue(key: string, value: string | number): Date | { error: string } {
725
+ if (typeof value === 'string' && !ISO_DATE_RE.test(value)) {
726
+ return { error: `Invalid date value for "${key}": expected ISO 8601, got ${JSON.stringify(value)}` };
727
+ }
728
+ const parsed = new Date(value);
729
+ if (Number.isNaN(parsed.getTime())) {
730
+ return { error: `Invalid date value for "${key}": ${JSON.stringify(value)}` };
731
+ }
732
+ if (typeof value === 'string' && !isCalendarDate(value.slice(0, 10))) {
733
+ return { error: `Invalid date value for "${key}": ${value.slice(0, 10)} is not a calendar date` };
734
+ }
735
+ return parsed;
736
+ }
737
+
738
+ /**
739
+ * The YYYY-MM-DD digits as written name a real day. `new Date('2026-02-29')` rolls over to
740
+ * 1 March with no error, which would match or store the wrong day; PostgreSQL refuses it.
741
+ * Checked on the digits before any offset is applied.
742
+ */
743
+ function isCalendarDate(ymd: string): boolean {
744
+ const [year, month, day] = ymd.split('-').map(Number);
745
+ const probe = new Date(0);
746
+ probe.setUTCFullYear(year, month - 1, day);
747
+ return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
748
+ }
749
+
750
+ /**
751
+ * A JSON boolean, array or plain object: never a date, and the date encoder throws on it.
752
+ * A Date or a drizzle SQL/Param a hook set has a class prototype and is not one of these.
753
+ */
754
+ function isJsonShape(value: unknown): boolean {
755
+ if (typeof value === 'boolean' || Array.isArray(value)) return true;
756
+ if (value === null || typeof value !== 'object') return false;
757
+ // Plain from any realm: a parsed body's Object.prototype need not be this module's.
758
+ const proto = Object.getPrototypeOf(value);
759
+ return proto === null || Object.getPrototypeOf(proto) === null;
760
+ }
761
+
721
762
  function coerceDateColumnValues(body: unknown, table: DrizzleTable): unknown {
722
763
  if (Array.isArray(body)) return body.map(row => coerceDateColumnValues(row, table));
723
764
  if (body === null || typeof body !== 'object') {
@@ -726,16 +767,14 @@ function coerceDateColumnValues(body: unknown, table: DrizzleTable): unknown {
726
767
 
727
768
  const result: Record<string, unknown> = { ...(body as Record<string, unknown>) };
728
769
  for (const [key, value] of Object.entries(result)) {
729
- if (typeof value !== 'string' && typeof value !== 'number') continue;
770
+ if (typeof value !== 'string' && typeof value !== 'number' && !isJsonShape(value)) continue;
730
771
  const column = getColumn(table, key);
731
772
  if (!column || column.dataType !== 'date') continue;
732
- if (typeof value === 'string' && !ISO_DATE_RE.test(value)) {
773
+ if (isJsonShape(value)) {
733
774
  throw new HandlerError(400, `Invalid date value for "${key}": expected ISO 8601, got ${JSON.stringify(value)}`);
734
775
  }
735
- const parsed = new Date(value);
736
- if (Number.isNaN(parsed.getTime())) {
737
- throw new HandlerError(400, `Invalid date value for "${key}": ${JSON.stringify(value)}`);
738
- }
776
+ const parsed = parseDateValue(key, value as string | number);
777
+ if (!(parsed instanceof Date)) throw new HandlerError(400, parsed.error);
739
778
  result[key] = parsed;
740
779
  }
741
780
  return result;
@@ -952,7 +991,16 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
952
991
  const table = schema[tableName];
953
992
  const hidden = byTable.get(table) ?? new Set<string>();
954
993
  for (const name of names) {
955
- const property = columnPropertyName(table, name);
994
+ let property = columnPropertyName(table, name);
995
+ if (property === undefined && table) {
996
+ const matches = Object.entries(getTableColumns(table))
997
+ .filter(([, column]) => toCamelCase((column as DrizzleColumn).name) === name);
998
+ if (matches.length > 1) {
999
+ const candidates = matches.map(([key]) => JSON.stringify(key)).join(' and ');
1000
+ throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" is ambiguous: it is the camelCase of columns ${candidates}; name one by its property or SQL name`);
1001
+ }
1002
+ property = matches[0]?.[0];
1003
+ }
956
1004
  if (property === undefined) {
957
1005
  throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" does not resolve to a column`);
958
1006
  }
@@ -979,6 +1027,8 @@ const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
979
1027
  '22P02': { status: 422, message: 'Invalid value format' },
980
1028
  '22001': { status: 422, message: 'Value is too long' },
981
1029
  '22003': { status: 422, message: 'Value is out of range' },
1030
+ '22007': { status: 422, message: 'Invalid date or time format' },
1031
+ '22008': { status: 422, message: 'Date or time value is out of range' },
982
1032
  };
983
1033
 
984
1034
  type IntegrityAnswer = { status: number; body: {
@@ -1297,7 +1347,12 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1297
1347
  });
1298
1348
  }
1299
1349
  if (error instanceof HandlerError) {
1300
- return json({ message: error.message }, error.status);
1350
+ const { code, column } = HandlerError.metadataOf(error);
1351
+ return json({
1352
+ message: error.message,
1353
+ ...(code !== undefined ? { code } : {}),
1354
+ ...(column !== undefined ? { column } : {}),
1355
+ }, error.status);
1301
1356
  }
1302
1357
  if (unavailable) return serviceUnavailableResponse();
1303
1358
  if (hostile) return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'RPC execution failed' }, 500);
@@ -1720,6 +1775,9 @@ async function handleGet(
1720
1775
  }
1721
1776
 
1722
1777
  const groupColumns = columns || [];
1778
+ const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, false, groupColumns);
1779
+ if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
1780
+
1723
1781
  const selectObj = buildAggregateSelect(table, aggregates, groupColumns);
1724
1782
 
1725
1783
  let query: any = db.select(selectObj).from(table);
@@ -1737,10 +1795,7 @@ async function handleGet(
1737
1795
  }
1738
1796
  }
1739
1797
 
1740
- if (orderParam) {
1741
- const orderExpr = buildOrderBy(table, orderParam);
1742
- if (orderExpr) query = query.orderBy(orderExpr);
1743
- }
1798
+ if (order.expressions.length > 0) query = query.orderBy(...order.expressions);
1744
1799
 
1745
1800
  query = query.limit(limitVal).offset(offsetVal);
1746
1801
 
@@ -1775,8 +1830,12 @@ async function handleGet(
1775
1830
  && (db as any).query?.[tableName];
1776
1831
 
1777
1832
  let rows: unknown[];
1833
+ const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
1778
1834
 
1779
1835
  if (useRelationalQuery) {
1836
+ const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
1837
+ if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
1838
+
1780
1839
  // JOIN path — single query via Drizzle relational API (db.query.findMany)
1781
1840
  const queryOptions: Record<string, any> = {
1782
1841
  limit: limitVal,
@@ -1798,14 +1857,14 @@ async function handleGet(
1798
1857
  queryOptions.where = and(...conditions);
1799
1858
  }
1800
1859
 
1801
- if (orderParam) {
1802
- const orderExpr = buildOrderBy(table, orderParam);
1803
- if (orderExpr) queryOptions.orderBy = orderExpr;
1804
- }
1860
+ if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
1805
1861
 
1806
1862
  rows = await (db as any).query[tableName].findMany(queryOptions);
1807
1863
  } else {
1808
1864
  // Batch path — standard select + separate relation queries
1865
+ const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
1866
+ if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
1867
+
1809
1868
  let query: any = db.select().from(table);
1810
1869
 
1811
1870
  if (conditions.length === 1) {
@@ -1814,12 +1873,7 @@ async function handleGet(
1814
1873
  query = query.where(and(...conditions));
1815
1874
  }
1816
1875
 
1817
- if (orderParam) {
1818
- const orderExpr = buildOrderBy(table, orderParam);
1819
- if (orderExpr) {
1820
- query = query.orderBy(orderExpr);
1821
- }
1822
- }
1876
+ if (order.expressions.length > 0) query = query.orderBy(...order.expressions);
1823
1877
 
1824
1878
  query = query.limit(limitVal).offset(offsetVal);
1825
1879
  rows = await query;
@@ -2042,6 +2096,34 @@ function splitSelectParam(param: string): string[] {
2042
2096
  return parts;
2043
2097
  }
2044
2098
 
2099
+ /** Check a reference against the resolved hidden policy, including related tables. */
2100
+ function isHiddenColumn(
2101
+ schema: Schema,
2102
+ sourceTable: string,
2103
+ reference: string,
2104
+ options?: HandlerOptions,
2105
+ ): boolean {
2106
+ if (!options?.hiddenColumns) return false;
2107
+
2108
+ // A resolved SQL name can contain path syntax; match the literal name first.
2109
+ if (ownEntry(options.hiddenColumns, sourceTable)?.includes(reference)) return true;
2110
+
2111
+ let targetName = sourceTable;
2112
+ let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
2113
+ // Prefer a real source column. The relation fallback only rejects hidden
2114
+ // references; the filter and order builders do not support relation paths.
2115
+ if (!getColumn(schema[targetName], columnName)) {
2116
+ const parts = columnName.split('.');
2117
+ while (parts.length > 1) {
2118
+ const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
2119
+ if (!relation || !schema[relation.table]) return false;
2120
+ targetName = relation.table;
2121
+ }
2122
+ columnName = parts[0];
2123
+ }
2124
+ return (ownEntry(options.hiddenColumns, targetName) ?? []).includes(columnName);
2125
+ }
2126
+
2045
2127
  /** Check explicit query references, including each related table's own policy. */
2046
2128
  function requestUsesHiddenColumn(
2047
2129
  schema: Schema,
@@ -2051,22 +2133,7 @@ function requestUsesHiddenColumn(
2051
2133
  ): boolean {
2052
2134
  if (!options?.hiddenColumns) return false;
2053
2135
 
2054
- const isHidden = (sourceTable: string, reference: string): boolean => {
2055
- let targetName = sourceTable;
2056
- let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
2057
- // Prefer a real source column. The relation fallback only rejects hidden
2058
- // references; the filter and order builders do not support relation paths.
2059
- if (!getColumn(schema[targetName], columnName)) {
2060
- const parts = columnName.split('.');
2061
- while (parts.length > 1) {
2062
- const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
2063
- if (!relation || !schema[relation.table]) return false;
2064
- targetName = relation.table;
2065
- }
2066
- columnName = parts[0];
2067
- }
2068
- return (options.hiddenColumns?.[targetName] ?? []).includes(columnName);
2069
- };
2136
+ const isHidden = (sourceTable: string, reference: string): boolean => isHiddenColumn(schema, sourceTable, reference, options);
2070
2137
 
2071
2138
  const selectUsesHidden = (sourceTable: string, select: string): boolean => {
2072
2139
  // Walk the raw selection: parseSelectParam collapses `*,secret` to a
@@ -2942,6 +3009,33 @@ function buildArrayCondition(column: DrizzleColumn, filter: Filter): any {
2942
3009
  return filter.negated ? not(condition) : condition;
2943
3010
  }
2944
3011
 
3012
+ /** PostgREST format: in.(val1,val2,val3) */
3013
+ function parseInList(value: string): string[] {
3014
+ return value.replace(/^\(/, '').replace(/\)$/, '').split(',');
3015
+ }
3016
+
3017
+ const DATE_FILTER_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in']);
3018
+
3019
+ /**
3020
+ * A date-mode column's driver encoder calls `value.toISOString()`, so a filter value must
3021
+ * reach drizzle as a Date: a string throws a TypeError out of the handler. Each value goes
3022
+ * through the write path's rule (parseDateValue); `in` yields one Date per member. An
3023
+ * operator with no date meaning (like, ilike) is refused, never handed a Date.
3024
+ */
3025
+ function dateFilterOperand(filter: Filter): Date | Date[] | { __filterError: string } {
3026
+ if (!DATE_FILTER_OPERATORS.has(filter.operator)) {
3027
+ return { __filterError: `Operator '${filter.operator}' is not supported on date column "${filter.column}"` };
3028
+ }
3029
+ const raw = filter.operator === 'in' ? parseInList(filter.value) : [filter.value];
3030
+ const dates: Date[] = [];
3031
+ for (const member of raw) {
3032
+ const parsed = parseDateValue(filter.column, member);
3033
+ if (!(parsed instanceof Date)) return { __filterError: parsed.error };
3034
+ dates.push(parsed);
3035
+ }
3036
+ return filter.operator === 'in' ? dates : dates[0];
3037
+ }
3038
+
2945
3039
  function buildCondition(table: DrizzleTable, filter: Filter): any {
2946
3040
  if (filter.jsonPath) {
2947
3041
  return buildJsonCondition(table, filter);
@@ -2955,26 +3049,35 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
2955
3049
  }
2956
3050
  if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
2957
3051
 
3052
+ let value: unknown = filter.value;
3053
+ let values: unknown[] | null = null;
3054
+ if (column.dataType === 'date' && filter.operator !== 'is') {
3055
+ const dated = dateFilterOperand(filter);
3056
+ if (isFilterError(dated)) return dated;
3057
+ if (Array.isArray(dated)) values = dated;
3058
+ else value = dated;
3059
+ }
3060
+
2958
3061
  let condition: any = null;
2959
3062
 
2960
3063
  switch (filter.operator) {
2961
3064
  case 'eq':
2962
- condition = eq(column, filter.value);
3065
+ condition = eq(column, value);
2963
3066
  break;
2964
3067
  case 'neq':
2965
- condition = ne(column, filter.value);
3068
+ condition = ne(column, value);
2966
3069
  break;
2967
3070
  case 'gt':
2968
- condition = gt(column, filter.value);
3071
+ condition = gt(column, value);
2969
3072
  break;
2970
3073
  case 'gte':
2971
- condition = gte(column, filter.value);
3074
+ condition = gte(column, value);
2972
3075
  break;
2973
3076
  case 'lt':
2974
- condition = lt(column, filter.value);
3077
+ condition = lt(column, value);
2975
3078
  break;
2976
3079
  case 'lte':
2977
- condition = lte(column, filter.value);
3080
+ condition = lte(column, value);
2978
3081
  break;
2979
3082
  case 'like':
2980
3083
  // PostgREST uses * for wildcard, SQL uses %
@@ -2987,12 +3090,9 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
2987
3090
  if (filter.value === 'null') condition = isNull(column);
2988
3091
  else if (filter.value === 'not.null') condition = isNotNull(column);
2989
3092
  break;
2990
- case 'in': {
2991
- // PostgREST format: in.(val1,val2,val3)
2992
- const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
2993
- condition = inArray(column, values);
3093
+ case 'in':
3094
+ condition = inArray(column, values ?? parseInList(filter.value));
2994
3095
  break;
2995
- }
2996
3096
  default:
2997
3097
  return null;
2998
3098
  }
@@ -3009,18 +3109,55 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
3009
3109
  return condition;
3010
3110
  }
3011
3111
 
3012
- function buildOrderBy(table: DrizzleTable, orderParam: string): any {
3013
- // Format: column.direction (e.g., created_at.desc)
3014
- const dotIndex = orderParam.lastIndexOf('.');
3015
- if (dotIndex === -1) return null;
3016
-
3017
- const columnName = orderParam.substring(0, dotIndex);
3018
- const direction = orderParam.substring(dotIndex + 1);
3019
-
3020
- const column = getColumn(table, columnName);
3021
- if (!column) return null;
3022
-
3023
- return direction === 'desc' ? desc(column) : asc(column);
3112
+ function buildOrderBy(
3113
+ table: DrizzleTable,
3114
+ orderParam: string,
3115
+ tieBreak: boolean,
3116
+ groupColumns?: string[],
3117
+ isHidden?: (column: string) => boolean,
3118
+ ): { expressions: SQL[] } | { error: string; code: 'invalid_order'; column: string } {
3119
+ const columns = Object.values(getTableColumns(table)) as DrizzleColumn[];
3120
+ const expressions: SQL[] = [];
3121
+ const named = new Set<string>();
3122
+
3123
+ for (const part of orderParam.split(',')) {
3124
+ const dotIndex = part.lastIndexOf('.');
3125
+ const columnName = (dotIndex < 0 ? part : part.slice(0, dotIndex)).trim();
3126
+ const direction = dotIndex < 0 ? '' : part.slice(dotIndex + 1).trim();
3127
+ const column = getColumn(table, columnName);
3128
+ // getColumn also sees table properties and inherited names; only real columns can order rows.
3129
+ if (!column || !columns.includes(column)) {
3130
+ return { error: `Unknown order column: ${columnName}`, code: 'invalid_order', column: columnName };
3131
+ }
3132
+ if (direction !== 'asc' && direction !== 'desc') {
3133
+ return { error: `Invalid order direction for ${columnName}: expected asc or desc`, code: 'invalid_order', column: columnName };
3134
+ }
3135
+ if (groupColumns && !groupColumns.some(name => getColumn(table, name) === column)) {
3136
+ return { error: `Order column must be grouped: ${columnName}`, code: 'invalid_order', column: columnName };
3137
+ }
3138
+ expressions.push(direction === 'desc' ? desc(column) : asc(column));
3139
+ named.add(column.name);
3140
+ }
3141
+
3142
+ // Aggregate groups do not represent individual rows, so their order cannot use the row key.
3143
+ if (tieBreak && is(table, PgTable)) {
3144
+ // Resolve the key columns from `columns` (getTableColumns), the same typing asc() gets for
3145
+ // the named columns above. getTableConfig's pg-core column types are a second Column class
3146
+ // under NodeNext (drizzle's .d.cts imports both .js and .cjs), so they must not reach asc().
3147
+ const config = getTableConfig(table);
3148
+ const byName = new Map(columns.map(column => [column.name, column]));
3149
+ const primaryColumns = [
3150
+ ...columns.filter(column => column.primary),
3151
+ ...config.primaryKeys.flatMap(key => key.columns.map(keyColumn => byName.get(keyColumn.name))),
3152
+ ].filter((column): column is DrizzleColumn => column !== undefined);
3153
+ for (const column of primaryColumns) {
3154
+ if (!named.has(column.name) && !isHidden?.(column.name)) {
3155
+ expressions.push(asc(column));
3156
+ named.add(column.name);
3157
+ }
3158
+ }
3159
+ }
3160
+ return { expressions };
3024
3161
  }
3025
3162
 
3026
3163
  const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);