@everystack/api 0.4.5 → 0.4.6

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,25 @@ 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.6] - 2026-10-16
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - an undeclared embed in select now answers 400 instead of a 200 with the embed dropped
12
+ - a JSON-path filter that cannot be built now answers 400 instead of being dropped; a DELETE or PATCH with such a filter used to run on the remaining filters alone
13
+
14
+ ### Added
15
+
16
+ - cs, cd and ov array filter operators on text[] and varchar[] columns
17
+
18
+ ### Fixed
19
+
20
+ - map integrity errors to safe client refusals
21
+ - preserve legacy errors alongside integrity mapping
22
+ - protect integrity diagnostics across failing tables
23
+ - show integrity errors under matching form fields
24
+ - the integrity mapper follows nested causes and keeps legacy hostile-value answers
25
+
7
26
  ## [0.4.5] - 2026-10-15
8
27
 
9
28
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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,9 @@ export interface ClientOptions {
27
27
  export interface ClientError {
28
28
  status: number;
29
29
  message: string;
30
+ code?: string;
31
+ constraint?: string;
32
+ column?: string;
30
33
  }
31
34
 
32
35
  export interface ClientResponse<T> {
@@ -337,7 +340,13 @@ function parseContentRange(header: string | null): number {
337
340
 
338
341
  async function parseError(response: Response): Promise<ClientError> {
339
342
  const body = await response.json().catch(() => ({}));
340
- return { status: response.status, message: body.message || response.statusText };
343
+ return {
344
+ status: response.status,
345
+ message: body.message || response.statusText,
346
+ ...(typeof body.code === 'string' ? { code: body.code } : {}),
347
+ ...(typeof body.constraint === 'string' ? { constraint: body.constraint } : {}),
348
+ ...(typeof body.column === 'string' ? { column: body.column } : {}),
349
+ };
341
350
  }
342
351
 
343
352
  function buildUrl(
@@ -0,0 +1,58 @@
1
+ /** Most elements an array filter value may list. A longer list is refused. */
2
+ export const ARRAY_LITERAL_MAX_ELEMENTS = 100;
3
+ /** Most characters one element may hold, after unescaping. A longer one is refused. */
4
+ export const ARRAY_LITERAL_MAX_ELEMENT_LENGTH = 200;
5
+
6
+ const UNQUOTED_FORBIDDEN = new Set(['{', '}', '"', '\\']);
7
+
8
+ /**
9
+ * Parse the PostgREST array value of a cs/cd/ov filter: `{a,b}`, `{"a,b","c\"d"}`, `{}`.
10
+ *
11
+ * An element is either unquoted (non-empty; no comma, brace, double quote, backslash or
12
+ * edge whitespace) or double-quoted, where `\"` and `\\` are the only escapes. Returns
13
+ * null for anything else, or past the element and length caps. The caller binds the
14
+ * result as one parameter; this text never reaches SQL.
15
+ */
16
+ export function parseArrayLiteral(value: string): string[] | null {
17
+ if (value.length < 2 || value[0] !== '{' || value[value.length - 1] !== '}') return null;
18
+ const body = value.slice(1, -1);
19
+ if (body === '') return [];
20
+
21
+ const elements: string[] = [];
22
+ let i = 0;
23
+ for (;;) {
24
+ if (elements.length === ARRAY_LITERAL_MAX_ELEMENTS) return null;
25
+ let element = '';
26
+ if (body[i] === '"') {
27
+ i++;
28
+ for (;;) {
29
+ if (i >= body.length) return null;
30
+ const char = body[i];
31
+ if (char === '"') { i++; break; }
32
+ if (char === '\\') {
33
+ const next = body[i + 1];
34
+ if (next !== '"' && next !== '\\') return null;
35
+ element += next;
36
+ i += 2;
37
+ } else {
38
+ element += char;
39
+ i++;
40
+ }
41
+ if (element.length > ARRAY_LITERAL_MAX_ELEMENT_LENGTH) return null;
42
+ }
43
+ } else {
44
+ const start = i;
45
+ while (i < body.length && body[i] !== ',') {
46
+ if (UNQUOTED_FORBIDDEN.has(body[i])) return null;
47
+ i++;
48
+ }
49
+ element = body.slice(start, i);
50
+ if (element === '' || /^\s|\s$/.test(element)) return null;
51
+ if (element.length > ARRAY_LITERAL_MAX_ELEMENT_LENGTH) return null;
52
+ }
53
+ elements.push(element);
54
+ if (i === body.length) return elements;
55
+ if (body[i] !== ',') return null;
56
+ i++;
57
+ }
58
+ }
@@ -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, and, or, not, asc, desc, sql, getTableColumns, 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, Param, type SQL } from 'drizzle-orm';
11
+ import { parseArrayLiteral, ARRAY_LITERAL_MAX_ELEMENTS, ARRAY_LITERAL_MAX_ELEMENT_LENGTH } from './array-literal';
11
12
  import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
12
13
  import { isServiceUnavailable, SERVICE_UNAVAILABLE_MESSAGE } from './service-unavailable';
13
14
  import { isPermissionDenied, safeRead, UNREADABLE } from './thrown';
@@ -392,6 +393,9 @@ export interface HandlerOptions {
392
393
  /** When true, error messages hide internal details (table names, column names, operators).
393
394
  * Enable in production to prevent schema enumeration. Default: false. */
394
395
  hideSchemaErrors?: boolean;
396
+ /** App-approved messages keyed by PostgreSQL constraint name. These may expose an
397
+ * optional column even with hideSchemaErrors; hiddenColumns always wins. */
398
+ constraintMessages?: Record<string, string | { message: string; column?: string }>;
395
399
  /** Forensic audit logging (distinct from observability). When a sink is set,
396
400
  * records every non-GET request (writes + RPCs) — the query, not the response —
397
401
  * to an app-provided append-only sink, stamped with the trace-id (`x-amz-cf-id`).
@@ -964,6 +968,107 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
964
968
  return { ...options, hiddenColumns };
965
969
  }
966
970
 
971
+ /** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
972
+ const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
973
+ '23514': { status: 422, message: 'Value does not meet a constraint' },
974
+ '23502': { status: 422, message: 'A required value is missing' },
975
+ '23503': { status: 422, message: 'Related record does not exist' },
976
+ '23000': { status: 422, message: 'Value violates an integrity constraint' },
977
+ '23505': { status: 409, message: 'Value already exists' },
978
+ '23P01': { status: 409, message: 'Value conflicts with an existing record' },
979
+ '22P02': { status: 422, message: 'Invalid value format' },
980
+ '22001': { status: 422, message: 'Value is too long' },
981
+ '22003': { status: 422, message: 'Value is out of range' },
982
+ };
983
+
984
+ type IntegrityAnswer = { status: number; body: {
985
+ message: string; code: string; constraint?: string; column?: string;
986
+ } };
987
+
988
+ /** How many `cause` links the integrity mapper follows from the thrown value. */
989
+ const MAX_CAUSE_HOPS = 3;
990
+
991
+ /**
992
+ * Both dispatch catches use this after their transaction has rolled back. A throwing
993
+ * diagnostic getter invalidates the whole classification, including a wrapped cause.
994
+ * undefined means unclassified; UNREADABLE preserves the caller's 500 fault path.
995
+ */
996
+ function mapIntegrityError(
997
+ error: unknown,
998
+ tableName: string | undefined,
999
+ options?: HandlerOptions,
1000
+ ): IntegrityAnswer | typeof UNREADABLE | undefined {
1001
+ // Drizzle drivers may throw directly or wrap the driver's error as cause, and a
1002
+ // query wrapper can wrap again. The first string code on the chain decides.
1003
+ // Classify before touching diagnostics, so unrelated codes keep their old answer.
1004
+ const chain: unknown[] = [error];
1005
+ let code = safeRead(error, 'code');
1006
+ while (typeof code !== 'string' && chain.length <= MAX_CAUSE_HOPS) {
1007
+ if (code === UNREADABLE) return UNREADABLE;
1008
+ const cause = safeRead(chain[chain.length - 1], 'cause');
1009
+ if (cause === UNREADABLE) return UNREADABLE;
1010
+ if (cause === null || typeof cause !== 'object' || chain.includes(cause)) break;
1011
+ chain.push(cause);
1012
+ code = safeRead(cause, 'code');
1013
+ }
1014
+ if (code === UNREADABLE) return UNREADABLE;
1015
+ if (typeof code !== 'string' || !Object.hasOwn(INTEGRITY_ERRORS, code)) return undefined;
1016
+ const read = (value: unknown) => ({
1017
+ message: safeRead(value, 'message'), cause: safeRead(value, 'cause'),
1018
+ constraint: safeRead(value, 'constraint') ?? safeRead(value, 'constraint_name'),
1019
+ column: safeRead(value, 'column') ?? safeRead(value, 'column_name'),
1020
+ table: safeRead(value, 'table') ?? safeRead(value, 'table_name'),
1021
+ });
1022
+ // Diagnostics come only from the object that carried the code (the last link).
1023
+ // Wrapper messages and fields are read only to refuse a hostile value.
1024
+ let fields!: ReturnType<typeof read>;
1025
+ for (const value of chain) {
1026
+ fields = read(value);
1027
+ if (Object.values(fields).includes(UNREADABLE)) return UNREADABLE;
1028
+ }
1029
+ const mapped = INTEGRITY_ERRORS[code];
1030
+ const constraint = typeof fields.constraint === 'string' ? fields.constraint : undefined;
1031
+ const declared = constraint === undefined ? undefined : ownEntry(options?.constraintMessages, constraint);
1032
+ const body: IntegrityAnswer['body'] = { message: mapped.message, code };
1033
+ if (declared !== undefined) {
1034
+ body.message = typeof declared === 'string' ? declared : declared.message;
1035
+ body.constraint = constraint;
1036
+ if (typeof declared !== 'string' && declared.column !== undefined) body.column = declared.column;
1037
+ } else if (!options?.hideSchemaErrors) {
1038
+ if (constraint !== undefined) body.constraint = constraint;
1039
+ if (code === '23502' && typeof fields.column === 'string') body.column = fields.column;
1040
+ }
1041
+ // A failing trigger or FK action can name a different table than the URL.
1042
+ // Missing driver tables (and unknown RPC policies) use every configured list.
1043
+ const driverTable = typeof fields.table === 'string' ? fields.table : undefined;
1044
+ const hiddenColumns = options?.hiddenColumns ?? {};
1045
+ const driverHidden = driverTable === undefined
1046
+ || (tableName === undefined && !Object.hasOwn(hiddenColumns, driverTable))
1047
+ ? Object.values(hiddenColumns).flat() : ownEntry(hiddenColumns, driverTable) ?? [];
1048
+ const hidden = [
1049
+ ...(tableName !== undefined ? ownEntry(hiddenColumns, tableName) ?? [] : []),
1050
+ ...driverHidden,
1051
+ ];
1052
+ if (body.column !== undefined && hidden.includes(body.column)) delete body.column;
1053
+ return { status: mapped.status, body };
1054
+ }
1055
+
1056
+ function validateConstraintMessages(options?: HandlerOptions): void {
1057
+ if (options?.constraintMessages === undefined) return;
1058
+ if (options.constraintMessages === null || typeof options.constraintMessages !== 'object'
1059
+ || Array.isArray(options.constraintMessages)) {
1060
+ throw new Error('[everystack/api] constraintMessages must be a record');
1061
+ }
1062
+ for (const [constraint, entry] of Object.entries(options.constraintMessages)) {
1063
+ const object = entry !== null && typeof entry === 'object' && !Array.isArray(entry);
1064
+ const message = typeof entry === 'string' ? entry : object ? entry.message : undefined;
1065
+ if (typeof message !== 'string' || message.trim().length === 0
1066
+ || (object && entry.column !== undefined && typeof entry.column !== 'string')) {
1067
+ throw new Error(`[everystack/api] constraintMessages.${constraint}: expected a non-empty message and optional string column`);
1068
+ }
1069
+ }
1070
+ }
1071
+
967
1072
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
968
1073
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
969
1074
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
@@ -973,6 +1078,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
973
1078
  const namePairs = tableNamePairs(rawSchema);
974
1079
  const schema = expandSchema(rawSchema, namePairs);
975
1080
  const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
1081
+ validateConstraintMessages(options);
976
1082
  validateRowOwnership(schema, rawOptions);
977
1083
  validateSoftDeleteRestore(schema, options);
978
1084
  validateSanitizeColumns(schema, options);
@@ -1156,20 +1262,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1156
1262
  const rpcCacheControl = getCacheControl(options);
1157
1263
  return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
1158
1264
  } catch (error) {
1265
+ // App answers and outages take precedence over integrity classification.
1266
+ const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
1267
+ const integrity = error instanceof HandlerError || unavailable
1268
+ ? undefined : mapIntegrityError(error, undefined, options);
1159
1269
  const code = safeRead(error, 'code');
1160
1270
  const text = safeRead(error, 'message');
1161
- // A value whose getter throws is hostile: classify nothing from it
1162
- // and echo nothing of it. It answers the generic 500 (or an outage's 503).
1163
- const hostile = code === UNREADABLE || text === UNREADABLE || safeRead(error, 'cause') === UNREADABLE;
1271
+ // A value whose getter throws is hostile: classify nothing from it and echo
1272
+ // nothing of it, whatever its code (the mapper reads cause only for its codes).
1273
+ const hostile = integrity === UNREADABLE || code === UNREADABLE || text === UNREADABLE
1274
+ || safeRead(error, 'cause') === UNREADABLE;
1164
1275
  const pgCode = hostile ? undefined : code;
1165
- // An outage: answer 503 with a fixed body. The driver's message
1166
- // carries host and port; it goes to onError only. A HandlerError's
1167
- // own answer still wins: its return below runs first.
1168
- const unavailable = isServiceUnavailable(error);
1276
+ const mapped = !hostile ? integrity : undefined;
1169
1277
  let status = 500;
1170
1278
  if (unavailable) status = 503;
1279
+ else if (mapped) status = mapped.status;
1171
1280
  else if (pgCode === '42501') status = 403;
1172
- else if (pgCode === '23505') status = 409;
1173
1281
  else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
1174
1282
  // Split the outcome: an authz/validation refusal (4xx) is `deny`; a
1175
1283
  // system fault (5xx) is `error`. Conflating them blinds an investigation.
@@ -1192,6 +1300,8 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1192
1300
  return json({ message: error.message }, error.status);
1193
1301
  }
1194
1302
  if (unavailable) return serviceUnavailableResponse();
1303
+ if (hostile) return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'RPC execution failed' }, 500);
1304
+ if (mapped) return json(mapped.body, mapped.status);
1195
1305
  const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
1196
1306
  if (options?.hideSchemaErrors && status === 500) {
1197
1307
  return json({ message: 'Internal Server Error' }, 500);
@@ -1270,6 +1380,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1270
1380
  // Bound every select before the hidden-column guard walks it. The guard
1271
1381
  // reads duplicates and runs for writes too, so a GET-only check is too late.
1272
1382
  let queryError: string | undefined;
1383
+ let queryErrorEchoesInput = false;
1273
1384
  let auditReadDenial = false;
1274
1385
  for (const selectParam of url.searchParams.getAll('select')) {
1275
1386
  const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
@@ -1280,20 +1391,33 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1280
1391
  }
1281
1392
  }
1282
1393
 
1394
+ // A part with parentheses is an aggregate or a declared embed. Anything
1395
+ // else cannot be honored, so refuse it rather than drop it from the rows.
1396
+ const tableRelations = ownEntry(options?.relations, tableName);
1397
+ if (!queryError) {
1398
+ for (const selectParam of url.searchParams.getAll('select')) {
1399
+ const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
1400
+ if (undeclared !== undefined) {
1401
+ queryError = undeclaredSelectPartMessage(undeclared);
1402
+ queryErrorEchoesInput = true;
1403
+ break;
1404
+ }
1405
+ }
1406
+ }
1407
+
1283
1408
  // Refusals share the response audit below without opening a transaction.
1284
1409
  // Response stripping cannot prevent value oracles or aggregate alias reads.
1285
1410
  if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
1286
1411
  queryError = 'Invalid query';
1287
1412
  }
1288
- // Refuse projections we cannot enforce on the common pre-DB audit path.
1289
- const tableRelations = ownEntry(options?.relations, tableName);
1290
- if (!queryError && options?.drizzleRelations && tableRelations) {
1413
+ // Refuse projections neither read path can enforce, on the common pre-DB audit path.
1414
+ if (!queryError && tableRelations) {
1291
1415
  for (const selectParam of url.searchParams.getAll('select')) {
1292
- const { embeds } = parseSelectParam(selectParam, tableRelations, options.relations);
1293
- const withClause = buildWithClause(embeds, tableRelations, schema, options);
1294
- if (withClause === null || withClause === 'unresolvedHiddenColumns') {
1416
+ const { embeds } = parseSelectParam(selectParam, tableRelations, options?.relations);
1417
+ const refused = refusedEmbedProjection(embeds, tableRelations, schema, options);
1418
+ if (refused !== undefined) {
1295
1419
  queryError = 'Invalid query';
1296
- auditReadDenial = method === 'GET' && withClause === 'unresolvedHiddenColumns';
1420
+ auditReadDenial = method === 'GET' && refused === 'unresolvedHiddenColumns';
1297
1421
  break;
1298
1422
  }
1299
1423
  }
@@ -1317,7 +1441,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1317
1441
  const deferred: Deferred[] = [];
1318
1442
  let response: Response;
1319
1443
  try {
1320
- response = queryError ? json({ message: queryError }, 400)
1444
+ response = queryError ? (queryErrorEchoesInput ? sanitizeError(queryError, 400, options) : json({ message: queryError }, 400))
1321
1445
  : unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
1322
1446
  if (method === 'GET') {
1323
1447
  const cacheControl = getCacheControl(options);
@@ -1358,20 +1482,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1358
1482
  }
1359
1483
  return response;
1360
1484
  } catch (error) {
1485
+ const appError = error instanceof HandlerError;
1486
+ const unavailable = !appError && isServiceUnavailable(error);
1487
+ const integrity = appError || unavailable ? undefined : mapIntegrityError(error, tableName, options);
1488
+ const hostile = integrity === UNREADABLE;
1489
+ const mapped = integrity !== UNREADABLE ? integrity : undefined;
1490
+ const permissionDenied = !appError && !unavailable && !hostile && isPermissionDenied(error);
1361
1491
  if (isWrite) {
1362
1492
  await emitAudit(options?.audit, {
1363
1493
  kind: 'rest', name: tableName, user, client,
1364
- result: isPermissionDenied(error) ? 'deny' : 'error',
1494
+ result: mapped || permissionDenied ? 'deny' : 'error',
1365
1495
  requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
1366
1496
  });
1367
1497
  }
1368
- if (isPermissionDenied(error)) {
1369
- return sanitizeError('Permission denied', 403, options);
1370
- }
1371
- // An outage answers 503 with a fixed body; the driver's message (host and
1372
- // port) goes to onError only. A HandlerError is the app's own answer and
1373
- // never an outage.
1374
- const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
1498
+ if (mapped) return json(mapped.body, mapped.status);
1499
+ if (permissionDenied) return sanitizeError('Permission denied', 403, options);
1375
1500
  // Other table faults re-throw to the Lambda boundary (which logs to
1376
1501
  // CloudWatch), but that path never reaches the S3 archive. Surface the
1377
1502
  // cause here too so a write 5xx is in logs:errors. A HandlerError with a
@@ -1799,7 +1924,7 @@ function parseSelectParam(
1799
1924
  selectParam: string | null,
1800
1925
  tableRelations?: Record<string, RelationConfig>,
1801
1926
  allRelations?: Record<string, Record<string, RelationConfig>>,
1802
- ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
1927
+ ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
1803
1928
  if (!selectParam) {
1804
1929
  return { columns: null, embeds: [], aggregates: [] };
1805
1930
  }
@@ -1808,6 +1933,9 @@ function parseSelectParam(
1808
1933
  const columns: string[] = [];
1809
1934
  const aggregates: AggregateField[] = [];
1810
1935
  let hasWildcard = false;
1936
+ // The first part with parentheses that is neither an aggregate nor a declared
1937
+ // embed, at any depth. The caller refuses the select; it is never a column.
1938
+ let undeclared: string | undefined;
1811
1939
 
1812
1940
  // Parse: split on commas, but respect parentheses
1813
1941
  const parts = splitSelectParam(selectParam);
@@ -1831,11 +1959,14 @@ function parseSelectParam(
1831
1959
  const targetTableName = tableRelations[name].table;
1832
1960
  const targetRelations = ownEntry(allRelations, targetTableName);
1833
1961
  const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
1962
+ undeclared ??= inner.undeclared;
1834
1963
  embeds.push({
1835
1964
  name,
1836
1965
  columns: inner.columns,
1837
1966
  embeds: inner.embeds,
1838
1967
  });
1968
+ } else if (part.includes('(')) {
1969
+ undeclared ??= part;
1839
1970
  } else if (part === '*') {
1840
1971
  hasWildcard = true;
1841
1972
  } else {
@@ -1847,9 +1978,39 @@ function parseSelectParam(
1847
1978
  columns: hasWildcard ? null : (columns.length > 0 ? columns : null),
1848
1979
  embeds,
1849
1980
  aggregates,
1981
+ ...(undeclared === undefined ? {} : { undeclared }),
1850
1982
  };
1851
1983
  }
1852
1984
 
1985
+ /** The 400 for an undeclared select part. It echoes client input only, capped. */
1986
+ function undeclaredSelectPartMessage(part: string): string {
1987
+ const chars = Array.from(part);
1988
+ const shown = chars.length > 200 ? `${chars.slice(0, 200).join('')}…` : part;
1989
+ return `Select part is not a declared embed or an aggregate: ${shown}`;
1990
+ }
1991
+
1992
+ /**
1993
+ * An embed list left empty once unknown and hidden names are removed, at any
1994
+ * depth. Both read paths refuse it the same way, before any DB call.
1995
+ */
1996
+ function refusedEmbedProjection(
1997
+ embeds: EmbedRequest[],
1998
+ tableRelations: Record<string, RelationConfig>,
1999
+ schema: Schema,
2000
+ options?: HandlerOptions,
2001
+ ): null | 'unresolvedHiddenColumns' | undefined {
2002
+ const withClause = buildWithClause(embeds, tableRelations, schema, options);
2003
+ if (withClause === null || withClause === 'unresolvedHiddenColumns') return withClause;
2004
+ for (const embed of embeds) {
2005
+ const relation = ownEntry(tableRelations, embed.name);
2006
+ const targetRelations = relation && ownEntry(options?.relations, relation.table);
2007
+ if (embed.embeds.length === 0 || !targetRelations) continue;
2008
+ const nested = refusedEmbedProjection(embed.embeds, targetRelations, schema, options);
2009
+ if (nested !== undefined) return nested;
2010
+ }
2011
+ return undefined;
2012
+ }
2013
+
1853
2014
  /**
1854
2015
  * Split select param respecting parentheses:
1855
2016
  * "id,title,author(name,email),comments(*)" → ["id", "title", "author(name,email)", "comments(*)"]
@@ -2681,6 +2842,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2681
2842
  const jp = filter.jsonPath!;
2682
2843
  const baseCol = getColumn(table, jp.baseColumn);
2683
2844
  if (!baseCol) return { __filterError: `Unknown column: '${jp.baseColumn}'` };
2845
+ if (ARRAY_OPERATORS.has(filter.operator)) return arrayOperatorRefusal(filter.operator, jp.baseColumn);
2684
2846
 
2685
2847
  // Validate all JSON path keys before interpolation
2686
2848
  for (const step of jp.steps) {
@@ -2727,6 +2889,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2727
2889
  case 'is':
2728
2890
  if (filter.value === 'null') condition = sql`${expr} IS NULL`;
2729
2891
  else if (filter.value === 'not.null') condition = sql`${expr} IS NOT NULL`;
2892
+ else return { __filterError: `Invalid value for 'is': expected null or not.null` };
2730
2893
  break;
2731
2894
  case 'in': {
2732
2895
  const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
@@ -2735,7 +2898,8 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2735
2898
  break;
2736
2899
  }
2737
2900
  default:
2738
- return null;
2901
+ // Refused, never dropped: a dropped filter widens the answer.
2902
+ return { __filterError: `Unknown filter operator: '${filter.operator.slice(0, 100)}'` };
2739
2903
  }
2740
2904
 
2741
2905
  if (condition && filter.negated) {
@@ -2748,7 +2912,35 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2748
2912
  return condition;
2749
2913
  }
2750
2914
 
2751
- const VALID_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in']);
2915
+ const ARRAY_OPERATORS = new Set(['cs', 'cd', 'ov']);
2916
+ const VALID_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in', ...ARRAY_OPERATORS]);
2917
+ const TEXT_ARRAY_ELEMENT_TYPES = new Set(['PgText', 'PgVarchar']);
2918
+
2919
+ function arrayOperatorRefusal(operator: string, column: string): { __filterError: string } {
2920
+ return { __filterError: `Unknown filter operator: '${operator}' for column '${column}' (cs, cd and ov need a text[] or varchar[] column)` };
2921
+ }
2922
+
2923
+ /**
2924
+ * cs (@>), cd (<@) and ov (&&) on a text[] or varchar[] column. The parsed list is
2925
+ * bound as ONE parameter through the column's own encoder. Passing a Param, not the
2926
+ * bare array, keeps the empty set: drizzle's helpers throw on an empty JS array, and
2927
+ * `{}` must keep PostgreSQL's own meaning.
2928
+ */
2929
+ function buildArrayCondition(column: DrizzleColumn, filter: Filter): any {
2930
+ const element = (column as { baseColumn?: { columnType?: string } }).baseColumn?.columnType;
2931
+ if (column.dataType !== 'array' || !element || !TEXT_ARRAY_ELEMENT_TYPES.has(element)) {
2932
+ return arrayOperatorRefusal(filter.operator, filter.column);
2933
+ }
2934
+ const values = parseArrayLiteral(filter.value);
2935
+ if (!values) {
2936
+ return { __filterError: `Invalid array value for '${filter.operator}' on column '${filter.column}': expected {a,b} with at most ${ARRAY_LITERAL_MAX_ELEMENTS} elements of at most ${ARRAY_LITERAL_MAX_ELEMENT_LENGTH} characters` };
2937
+ }
2938
+ const list = new Param(values, column);
2939
+ const condition = filter.operator === 'cs' ? arrayContains(column, list)
2940
+ : filter.operator === 'cd' ? arrayContained(column, list)
2941
+ : arrayOverlaps(column, list);
2942
+ return filter.negated ? not(condition) : condition;
2943
+ }
2752
2944
 
2753
2945
  function buildCondition(table: DrizzleTable, filter: Filter): any {
2754
2946
  if (filter.jsonPath) {
@@ -2761,6 +2953,7 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
2761
2953
  if (!VALID_OPERATORS.has(filter.operator)) {
2762
2954
  return { __filterError: `Unknown filter operator: '${filter.operator}'` };
2763
2955
  }
2956
+ if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
2764
2957
 
2765
2958
  let condition: any = null;
2766
2959
 
@@ -2866,6 +3059,44 @@ function parseFilters(params: URLSearchParams): Filter[] {
2866
3059
  return filters;
2867
3060
  }
2868
3061
 
3062
+ /** A group part reading `column.[not.]cs.` so far: a `{` here opens an array value. */
3063
+ const ARRAY_VALUE_OPENING = /^[^.]+\.(?:not\.)?(?:cs|cd|ov)\.$/;
3064
+
3065
+ /**
3066
+ * Split a group's content on commas, except inside the `{...}` value of a cs/cd/ov
3067
+ * filter, where a comma, or a brace inside a double-quoted element, is data. Every
3068
+ * other operator splits on every comma, as it always has (`in.(a,b)` included).
3069
+ */
3070
+ function splitLogicalGroup(content: string): string[] {
3071
+ const parts: string[] = [];
3072
+ let current = '';
3073
+ let inArray = false;
3074
+ let inQuote = false;
3075
+ for (let i = 0; i < content.length; i++) {
3076
+ const char = content[i];
3077
+ if (inArray) {
3078
+ if (inQuote && char === '\\') {
3079
+ current += char + (content[i + 1] ?? '');
3080
+ i++;
3081
+ continue;
3082
+ }
3083
+ if (char === '"') inQuote = !inQuote;
3084
+ else if (char === '}' && !inQuote) inArray = false;
3085
+ current += char;
3086
+ continue;
3087
+ }
3088
+ if (char === ',') {
3089
+ parts.push(current);
3090
+ current = '';
3091
+ continue;
3092
+ }
3093
+ if (char === '{' && ARRAY_VALUE_OPENING.test(current)) inArray = true;
3094
+ current += char;
3095
+ }
3096
+ parts.push(current);
3097
+ return parts;
3098
+ }
3099
+
2869
3100
  /**
2870
3101
  * Parse a logical group value into individual filters.
2871
3102
  * Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
@@ -2877,7 +3108,7 @@ function parseLogicalGroup(value: string): Filter[] {
2877
3108
  }
2878
3109
 
2879
3110
  const filters: Filter[] = [];
2880
- for (const part of content.split(',')) {
3111
+ for (const part of splitLogicalGroup(content)) {
2881
3112
  const dotIndex = part.indexOf('.');
2882
3113
  if (dotIndex === -1) continue;
2883
3114