@everystack/api 0.4.4 → 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,44 @@ 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
+
26
+ ## [0.4.5] - 2026-10-15
27
+
28
+ ### BREAKING CHANGES
29
+
30
+ - a PATCH with no row filter is now refused with 400, like DELETE; it used to update every row. Pass a filter such as ?id=eq.1
31
+ - hiddenColumns hides a column under every spelling (security); an entry that names no column, or a table not in the schema, now makes createHandler throw at startup instead of logging a warning
32
+
33
+ ### Added
34
+
35
+ - clean HTML columns on write and signal the change
36
+ - state the sanitizer contract and bound promise handling
37
+
38
+ ### Fixed
39
+
40
+ - sanitize on write review fixes: hidden signal, no raw HTML in faults
41
+ - keep input lines out of the sanitizer fault stack
42
+ - the sanitizer fault carries no error text or stack
43
+ - contain hostile thrown values and thenables in the sanitizer guard
44
+
7
45
  ## [0.4.4] - 2026-10-15
8
46
 
9
47
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.4",
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';
@@ -60,6 +61,9 @@ export interface HandlerFault {
60
61
  */
61
62
  status: number;
62
63
  message: string;
64
+ /** SQL names of the cell when a sanitizeColumns cleaner fails. */
65
+ table?: string;
66
+ column?: string;
63
67
  stack?: string;
64
68
  /** Postgres SQLSTATE code when the error carried one. */
65
69
  pgCode?: string;
@@ -361,6 +365,14 @@ export interface HandlerOptions {
361
365
  /** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
362
366
  * Prevents mass assignment / privilege escalation on sensitive columns. */
363
367
  protectedFields?: Record<string, string[]>;
368
+ /** HTML columns to clean on POST/PATCH after before-hooks. Table and column
369
+ * names accept SQL or Drizzle spelling. Requires sanitize when non-empty.
370
+ * Changed bytes are stored and signalled by X-Sanitized-Columns. */
371
+ sanitizeColumns?: Record<string, string[]>;
372
+ /** Injected HTML cleaner for sanitizeColumns. Context names are SQL names.
373
+ * Must return a string synchronously, ideally a fixed point. Throws or non-string returns
374
+ * refuse the entire write with 500 and report through onError. */
375
+ sanitize?: (html: string, ctx: { table: string; column: string }) => string;
364
376
  /** Maximum nesting depth for relation embedding via ?select=rel(sub_rel(...)).
365
377
  * Prevents DoS from deeply nested relation queries. Default: 3. */
366
378
  maxEmbedDepth?: number;
@@ -381,6 +393,9 @@ export interface HandlerOptions {
381
393
  /** When true, error messages hide internal details (table names, column names, operators).
382
394
  * Enable in production to prevent schema enumeration. Default: false. */
383
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 }>;
384
399
  /** Forensic audit logging (distinct from observability). When a sink is set,
385
400
  * records every non-GET request (writes + RPCs) — the query, not the response —
386
401
  * to an app-provided append-only sink, stamped with the trace-id (`x-amz-cf-id`).
@@ -781,6 +796,7 @@ function expandTableKeyedOptions(
781
796
  ...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
782
797
  ...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
783
798
  ...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
799
+ ...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
784
800
  ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
785
801
  ...(options.relations ? { relations: expandMap(options.relations) } : {}),
786
802
  ...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
@@ -892,6 +908,167 @@ function mayRestore(tableName: string, options: HandlerOptions | undefined, user
892
908
  return role !== '' && roles.includes(role);
893
909
  }
894
910
 
911
+ /** Refuse a cleaning policy that cannot be enforced, including conflicting table aliases. */
912
+ function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undefined): void {
913
+ const configured = new Map<DrizzleTable, Set<string>>();
914
+ for (const [tableName, columns] of Object.entries(options?.sanitizeColumns ?? {})) {
915
+ const prefix = `[everystack/api] sanitizeColumns.${tableName}:`;
916
+ const table = schema[tableName];
917
+ if (!table || sqlNameOf(table) === null) {
918
+ throw new Error(`${prefix} table "${tableName}" is not in the schema (columns ${JSON.stringify(columns)})`);
919
+ }
920
+ if (typeof options?.sanitize !== 'function') {
921
+ throw new Error(`${prefix} columns ${JSON.stringify(columns)} require a sanitize function`);
922
+ }
923
+ const resolved = new Set<string>();
924
+ const tableColumns = getTableColumns(table);
925
+ for (const name of columns) {
926
+ const key = columnPropertyName(table, name);
927
+ if (key === undefined) {
928
+ throw new Error(`${prefix} column "${name}" does not resolve on table "${tableName}"`);
929
+ }
930
+ const wire = options?.naming === 'snake_case' ? toSnakeCase(key) : key;
931
+ if (!/^[A-Za-z0-9_]+$/.test(wire)) {
932
+ throw new Error(`${prefix} column "${name}" has a name that cannot appear safely in X-Sanitized-Columns (use letters, digits and underscore)`);
933
+ }
934
+ if (tableColumns[key].dataType !== 'string') {
935
+ throw new Error(`${prefix} column "${name}" must be a string column`);
936
+ }
937
+ resolved.add(key);
938
+ }
939
+ const prior = configured.get(table);
940
+ if (prior && (prior.size !== resolved.size || [...prior].some(key => !resolved.has(key)))) {
941
+ throw new Error(`${prefix} different column lists conflict for the two table spellings: ${JSON.stringify([...prior])} and ${JSON.stringify([...resolved])}`);
942
+ }
943
+ configured.set(table, resolved);
944
+ }
945
+ }
946
+
947
+ /** Resolve hidden columns once so every read uses the same column spellings. */
948
+ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): HandlerOptions | undefined {
949
+ if (!options?.hiddenColumns) return options;
950
+ const byTable = new Map<DrizzleTable, Set<string>>();
951
+ for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
952
+ const table = schema[tableName];
953
+ const hidden = byTable.get(table) ?? new Set<string>();
954
+ for (const name of names) {
955
+ const property = columnPropertyName(table, name);
956
+ if (property === undefined) {
957
+ throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" does not resolve to a column`);
958
+ }
959
+ const column = getTableColumns(table)[property] as DrizzleColumn;
960
+ for (const spelling of [property, column.name, toSnakeCase(property), toCamelCase(column.name)]) {
961
+ hidden.add(spelling);
962
+ }
963
+ }
964
+ byTable.set(table, hidden);
965
+ }
966
+ const hiddenColumns = Object.fromEntries(Object.keys(options.hiddenColumns)
967
+ .map(tableName => [tableName, [...byTable.get(schema[tableName])!]]));
968
+ return { ...options, hiddenColumns };
969
+ }
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
+
895
1072
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
896
1073
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
897
1074
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
@@ -900,20 +1077,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
900
1077
  // apply. The root listing below stays one entry per table (the SQL name).
901
1078
  const namePairs = tableNamePairs(rawSchema);
902
1079
  const schema = expandSchema(rawSchema, namePairs);
903
- const options = expandTableKeyedOptions(rawOptions, namePairs);
904
- // Validate configured entries, not their generated table-name twins.
905
- for (const [tableName, columns] of Object.entries(rawOptions?.hiddenColumns ?? {})) {
906
- for (const column of columns) {
907
- if (columnPropertyName(schema[tableName], column) === undefined) {
908
- console.warn(
909
- `[everystack/api] invalid hiddenColumns entry: table=${tableName} column=${column}; `
910
- + 'JOIN-path embeds of this table return 400 until the entry is fixed.',
911
- );
912
- }
913
- }
914
- }
1080
+ const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
1081
+ validateConstraintMessages(options);
915
1082
  validateRowOwnership(schema, rawOptions);
916
1083
  validateSoftDeleteRestore(schema, options);
1084
+ validateSanitizeColumns(schema, options);
917
1085
  const tableNames = new Set(Object.keys(schema));
918
1086
  const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
919
1087
 
@@ -1094,20 +1262,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1094
1262
  const rpcCacheControl = getCacheControl(options);
1095
1263
  return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
1096
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);
1097
1269
  const code = safeRead(error, 'code');
1098
1270
  const text = safeRead(error, 'message');
1099
- // A value whose getter throws is hostile: classify nothing from it
1100
- // and echo nothing of it. It answers the generic 500 (or an outage's 503).
1101
- 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;
1102
1275
  const pgCode = hostile ? undefined : code;
1103
- // An outage: answer 503 with a fixed body. The driver's message
1104
- // carries host and port; it goes to onError only. A HandlerError's
1105
- // own answer still wins: its return below runs first.
1106
- const unavailable = isServiceUnavailable(error);
1276
+ const mapped = !hostile ? integrity : undefined;
1107
1277
  let status = 500;
1108
1278
  if (unavailable) status = 503;
1279
+ else if (mapped) status = mapped.status;
1109
1280
  else if (pgCode === '42501') status = 403;
1110
- else if (pgCode === '23505') status = 409;
1111
1281
  else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
1112
1282
  // Split the outcome: an authz/validation refusal (4xx) is `deny`; a
1113
1283
  // system fault (5xx) is `error`. Conflating them blinds an investigation.
@@ -1130,6 +1300,8 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1130
1300
  return json({ message: error.message }, error.status);
1131
1301
  }
1132
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);
1133
1305
  const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
1134
1306
  if (options?.hideSchemaErrors && status === 500) {
1135
1307
  return json({ message: 'Internal Server Error' }, 500);
@@ -1208,6 +1380,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1208
1380
  // Bound every select before the hidden-column guard walks it. The guard
1209
1381
  // reads duplicates and runs for writes too, so a GET-only check is too late.
1210
1382
  let queryError: string | undefined;
1383
+ let queryErrorEchoesInput = false;
1211
1384
  let auditReadDenial = false;
1212
1385
  for (const selectParam of url.searchParams.getAll('select')) {
1213
1386
  const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
@@ -1218,20 +1391,33 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1218
1391
  }
1219
1392
  }
1220
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
+
1221
1408
  // Refusals share the response audit below without opening a transaction.
1222
1409
  // Response stripping cannot prevent value oracles or aggregate alias reads.
1223
1410
  if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
1224
1411
  queryError = 'Invalid query';
1225
1412
  }
1226
- // Refuse projections we cannot enforce on the common pre-DB audit path.
1227
- const tableRelations = ownEntry(options?.relations, tableName);
1228
- if (!queryError && options?.drizzleRelations && tableRelations) {
1413
+ // Refuse projections neither read path can enforce, on the common pre-DB audit path.
1414
+ if (!queryError && tableRelations) {
1229
1415
  for (const selectParam of url.searchParams.getAll('select')) {
1230
- const { embeds } = parseSelectParam(selectParam, tableRelations, options.relations);
1231
- const withClause = buildWithClause(embeds, tableRelations, schema, options);
1232
- 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) {
1233
1419
  queryError = 'Invalid query';
1234
- auditReadDenial = method === 'GET' && withClause === 'unresolvedHiddenColumns';
1420
+ auditReadDenial = method === 'GET' && refused === 'unresolvedHiddenColumns';
1235
1421
  break;
1236
1422
  }
1237
1423
  }
@@ -1255,7 +1441,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1255
1441
  const deferred: Deferred[] = [];
1256
1442
  let response: Response;
1257
1443
  try {
1258
- response = queryError ? json({ message: queryError }, 400)
1444
+ response = queryError ? (queryErrorEchoesInput ? sanitizeError(queryError, 400, options) : json({ message: queryError }, 400))
1259
1445
  : unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
1260
1446
  if (method === 'GET') {
1261
1447
  const cacheControl = getCacheControl(options);
@@ -1296,20 +1482,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1296
1482
  }
1297
1483
  return response;
1298
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);
1299
1491
  if (isWrite) {
1300
1492
  await emitAudit(options?.audit, {
1301
1493
  kind: 'rest', name: tableName, user, client,
1302
- result: isPermissionDenied(error) ? 'deny' : 'error',
1494
+ result: mapped || permissionDenied ? 'deny' : 'error',
1303
1495
  requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
1304
1496
  });
1305
1497
  }
1306
- if (isPermissionDenied(error)) {
1307
- return sanitizeError('Permission denied', 403, options);
1308
- }
1309
- // An outage answers 503 with a fixed body; the driver's message (host and
1310
- // port) goes to onError only. A HandlerError is the app's own answer and
1311
- // never an outage.
1312
- 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);
1313
1500
  // Other table faults re-throw to the Lambda boundary (which logs to
1314
1501
  // CloudWatch), but that path never reaches the S3 archive. Surface the
1315
1502
  // cause here too so a write 5xx is in logs:errors. A HandlerError with a
@@ -1353,12 +1540,8 @@ function buildWithClause(
1353
1540
  if (!relation) continue;
1354
1541
  hasEmbedded = true;
1355
1542
  const table = schema[relation.table];
1356
- const hidden = new Set<string>();
1357
- for (const name of options?.hiddenColumns?.[relation.table] ?? []) {
1358
- const property = columnPropertyName(table, name);
1359
- if (property === undefined) return 'unresolvedHiddenColumns';
1360
- hidden.add(property);
1361
- }
1543
+ const names = new Set(options?.hiddenColumns?.[relation.table] ?? []);
1544
+ const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
1362
1545
  const config: Record<string, any> = {};
1363
1546
  if (embed.columns !== null) {
1364
1547
  // Drop names that are not columns of a known table: Drizzle ignores them,
@@ -1394,13 +1577,7 @@ function stripEmbeddedHiddenColumns(
1394
1577
  const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
1395
1578
  const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
1396
1579
  if (!relation || !(key in result)) continue;
1397
- const hidden = (options?.hiddenColumns?.[relation.table] ?? []).flatMap(name => {
1398
- const property = columnPropertyName(schema[relation.table], name);
1399
- return [name, toSnakeCase(name), ...(property ? [property, toSnakeCase(property)] : [])];
1400
- });
1401
- const related = stripHiddenColumns(result[key], relation.table, {
1402
- hiddenColumns: { [relation.table]: hidden },
1403
- });
1580
+ const related = stripHiddenColumns(result[key], relation.table, options);
1404
1581
  result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
1405
1582
  }
1406
1583
  return result;
@@ -1747,7 +1924,7 @@ function parseSelectParam(
1747
1924
  selectParam: string | null,
1748
1925
  tableRelations?: Record<string, RelationConfig>,
1749
1926
  allRelations?: Record<string, Record<string, RelationConfig>>,
1750
- ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
1927
+ ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
1751
1928
  if (!selectParam) {
1752
1929
  return { columns: null, embeds: [], aggregates: [] };
1753
1930
  }
@@ -1756,6 +1933,9 @@ function parseSelectParam(
1756
1933
  const columns: string[] = [];
1757
1934
  const aggregates: AggregateField[] = [];
1758
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;
1759
1939
 
1760
1940
  // Parse: split on commas, but respect parentheses
1761
1941
  const parts = splitSelectParam(selectParam);
@@ -1779,11 +1959,14 @@ function parseSelectParam(
1779
1959
  const targetTableName = tableRelations[name].table;
1780
1960
  const targetRelations = ownEntry(allRelations, targetTableName);
1781
1961
  const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
1962
+ undeclared ??= inner.undeclared;
1782
1963
  embeds.push({
1783
1964
  name,
1784
1965
  columns: inner.columns,
1785
1966
  embeds: inner.embeds,
1786
1967
  });
1968
+ } else if (part.includes('(')) {
1969
+ undeclared ??= part;
1787
1970
  } else if (part === '*') {
1788
1971
  hasWildcard = true;
1789
1972
  } else {
@@ -1795,9 +1978,39 @@ function parseSelectParam(
1795
1978
  columns: hasWildcard ? null : (columns.length > 0 ? columns : null),
1796
1979
  embeds,
1797
1980
  aggregates,
1981
+ ...(undeclared === undefined ? {} : { undeclared }),
1798
1982
  };
1799
1983
  }
1800
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
+
1801
2014
  /**
1802
2015
  * Split select param respecting parentheses:
1803
2016
  * "id,title,author(name,email),comments(*)" → ["id", "title", "author(name,email)", "comments(*)"]
@@ -1852,14 +2065,7 @@ function requestUsesHiddenColumn(
1852
2065
  }
1853
2066
  columnName = parts[0];
1854
2067
  }
1855
- const table = schema[targetName];
1856
- const column = getColumn(table, columnName);
1857
- return (options.hiddenColumns?.[targetName] ?? []).some(hidden => {
1858
- const hiddenColumn = getColumn(table, hidden);
1859
- return column && hiddenColumn
1860
- ? column === hiddenColumn
1861
- : toCamelCase(columnName) === toCamelCase(hidden);
1862
- });
2068
+ return (options.hiddenColumns?.[targetName] ?? []).includes(columnName);
1863
2069
  };
1864
2070
 
1865
2071
  const selectUsesHidden = (sourceTable: string, select: string): boolean => {
@@ -2057,6 +2263,95 @@ function beforeHookFailure(
2057
2263
  return json({ message: 'Hook error' }, 500);
2058
2264
  }
2059
2265
 
2266
+ function errorKind(err: unknown): string {
2267
+ try {
2268
+ return err instanceof TypeError ? 'TypeError'
2269
+ : err instanceof RangeError ? 'RangeError'
2270
+ : err instanceof SyntaxError ? 'SyntaxError'
2271
+ : err instanceof ReferenceError ? 'ReferenceError'
2272
+ : err instanceof EvalError ? 'EvalError'
2273
+ : err instanceof URIError ? 'URIError'
2274
+ : err instanceof AggregateError ? 'AggregateError'
2275
+ : err instanceof Error ? 'Error' : 'non-Error';
2276
+ } catch {
2277
+ return 'Error';
2278
+ }
2279
+ }
2280
+
2281
+ function consumeThenable(value: unknown, depth = 4): void {
2282
+ if (depth === 0) return;
2283
+ try {
2284
+ const then = (value as { then?: unknown } | null | undefined)?.then;
2285
+ if (typeof then !== 'function') return;
2286
+ const next = then.call(value, () => {}, () => {});
2287
+ consumeThenable(next, depth - 1);
2288
+ } catch {
2289
+ // A throwing getter or then must not prevent the refusal.
2290
+ }
2291
+ }
2292
+
2293
+ /** Clean every submitted cell before the caller can start a write. */
2294
+ function sanitizeWriteBody(
2295
+ body: unknown,
2296
+ table: DrizzleTable,
2297
+ tableName: string,
2298
+ options: HandlerOptions | undefined,
2299
+ at: { requestId?: string; name: string; method: string },
2300
+ deferred: Deferred[],
2301
+ ): { body: unknown; changed: string[] } | { refusal: Response } {
2302
+ const columns = options?.sanitizeColumns?.[tableName];
2303
+ if (!columns?.length) return { body, changed: [] };
2304
+ const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
2305
+ const hidden = new Set((options?.hiddenColumns?.[tableName] ?? []).map(name => columnPropertyName(table, name)));
2306
+ const refuse = (message: string, column: string): { refusal: Response } => {
2307
+ const fault: HandlerFault = {
2308
+ ...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
2309
+ };
2310
+ deferred.push({ always: true, run: () => emitFault(options?.onError, fault) });
2311
+ return { refusal: json({ message: 'Sanitizer error' }, 500) };
2312
+ };
2313
+ const bulk = Array.isArray(body);
2314
+ const rows = bulk ? body : [body];
2315
+ const cleaned: unknown[] = [];
2316
+ const changed: string[] = [];
2317
+ for (const [index, row] of rows.entries()) {
2318
+ // Leave body-shape checks to coerceDateColumnValues.
2319
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
2320
+ cleaned.push(row);
2321
+ continue;
2322
+ }
2323
+ const result: Record<string, unknown> = { ...row };
2324
+ for (const key of keys) {
2325
+ if (!Object.hasOwn(result, key) || result[key] === null) continue;
2326
+ const value = result[key];
2327
+ const wire = options?.naming === 'snake_case' ? toSnakeCase(key) : key;
2328
+ if (typeof value !== 'string') {
2329
+ return { refusal: sanitizeError(`Column "${wire}" must be a string`, 400, options) };
2330
+ }
2331
+ let sanitized: unknown;
2332
+ try {
2333
+ sanitized = options!.sanitize!(value, { table: sqlNameOf(table)!, column: table[key].name });
2334
+ } catch (err) {
2335
+ consumeThenable(err);
2336
+ return refuse(`Sanitizer threw (${errorKind(err)})`, table[key].name);
2337
+ }
2338
+ if (typeof sanitized !== 'string') {
2339
+ // An async cleaner is invalid; rejection handling is bounded and best effort.
2340
+ consumeThenable(sanitized);
2341
+ return refuse('Sanitizer returned a non-string', table[key].name);
2342
+ }
2343
+ result[key] = sanitized;
2344
+ if (sanitized !== value && !hidden.has(key)) changed.push(bulk ? `${wire}[${index}]` : wire);
2345
+ }
2346
+ cleaned.push(result);
2347
+ }
2348
+ return { body: bulk ? cleaned : cleaned[0], changed };
2349
+ }
2350
+
2351
+ function sanitizedHeaders(changed: string[]): Record<string, string> | undefined {
2352
+ return changed.length > 0 ? { 'X-Sanitized-Columns': changed.join(',') } : undefined;
2353
+ }
2354
+
2060
2355
  async function handlePost(
2061
2356
  db: DrizzleDb,
2062
2357
  table: DrizzleTable,
@@ -2099,6 +2394,11 @@ async function handlePost(
2099
2394
  }
2100
2395
  }
2101
2396
 
2397
+ const sanitized = sanitizeWriteBody(body, table, tableName, options,
2398
+ { requestId, name: tableName, method: 'POST' }, deferred);
2399
+ if ('refusal' in sanitized) return sanitized.refusal;
2400
+ body = sanitized.body as Record<string, unknown>;
2401
+
2102
2402
  let coerced: unknown;
2103
2403
  try {
2104
2404
  coerced = coerceDateColumnValues(body, table);
@@ -2117,7 +2417,8 @@ async function handlePost(
2117
2417
  () => afterCreate.call(hook, rows[0], user, client));
2118
2418
  }
2119
2419
 
2120
- return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201);
2420
+ return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201,
2421
+ rows.length > 0 ? sanitizedHeaders(sanitized.changed) : undefined);
2121
2422
  }
2122
2423
 
2123
2424
  async function handlePatch(
@@ -2183,6 +2484,11 @@ async function handlePatch(
2183
2484
  return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
2184
2485
  }
2185
2486
 
2487
+ // Safety: require at least one filter to prevent accidental mass update
2488
+ if (buildsNoFilter(table, params)) {
2489
+ return json({ message: 'PATCH requires at least one filter to prevent accidental mass update' }, 400);
2490
+ }
2491
+
2186
2492
  // The owner clause proves the caller owns the row, not that they may give it away.
2187
2493
  // A caller it scopes may not set the owner column to anyone else. RLS stays primary.
2188
2494
  if (scope.condition && user && ownership) {
@@ -2206,6 +2512,11 @@ async function handlePatch(
2206
2512
  return json({ message: 'No writable fields in request body' }, 400);
2207
2513
  }
2208
2514
 
2515
+ const sanitized = sanitizeWriteBody(body, table, tableName, options,
2516
+ { requestId, name: tableName, method: 'PATCH' }, deferred);
2517
+ if ('refusal' in sanitized) return sanitized.refusal;
2518
+ body = sanitized.body as Record<string, unknown>;
2519
+
2209
2520
  const filterResult = buildAllConditions(table, params);
2210
2521
  if ('error' in filterResult) {
2211
2522
  return sanitizeError(filterResult.error, 400, options);
@@ -2247,7 +2558,8 @@ async function handlePatch(
2247
2558
  () => afterUpdate.call(hook, rows, user, client));
2248
2559
  }
2249
2560
 
2250
- return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
2561
+ return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200,
2562
+ rows.length > 0 ? sanitizedHeaders(sanitized.changed) : undefined);
2251
2563
  }
2252
2564
 
2253
2565
  /**
@@ -2530,6 +2842,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2530
2842
  const jp = filter.jsonPath!;
2531
2843
  const baseCol = getColumn(table, jp.baseColumn);
2532
2844
  if (!baseCol) return { __filterError: `Unknown column: '${jp.baseColumn}'` };
2845
+ if (ARRAY_OPERATORS.has(filter.operator)) return arrayOperatorRefusal(filter.operator, jp.baseColumn);
2533
2846
 
2534
2847
  // Validate all JSON path keys before interpolation
2535
2848
  for (const step of jp.steps) {
@@ -2576,6 +2889,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2576
2889
  case 'is':
2577
2890
  if (filter.value === 'null') condition = sql`${expr} IS NULL`;
2578
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` };
2579
2893
  break;
2580
2894
  case 'in': {
2581
2895
  const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
@@ -2584,7 +2898,8 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2584
2898
  break;
2585
2899
  }
2586
2900
  default:
2587
- return null;
2901
+ // Refused, never dropped: a dropped filter widens the answer.
2902
+ return { __filterError: `Unknown filter operator: '${filter.operator.slice(0, 100)}'` };
2588
2903
  }
2589
2904
 
2590
2905
  if (condition && filter.negated) {
@@ -2597,7 +2912,35 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
2597
2912
  return condition;
2598
2913
  }
2599
2914
 
2600
- 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
+ }
2601
2944
 
2602
2945
  function buildCondition(table: DrizzleTable, filter: Filter): any {
2603
2946
  if (filter.jsonPath) {
@@ -2610,6 +2953,7 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
2610
2953
  if (!VALID_OPERATORS.has(filter.operator)) {
2611
2954
  return { __filterError: `Unknown filter operator: '${filter.operator}'` };
2612
2955
  }
2956
+ if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
2613
2957
 
2614
2958
  let condition: any = null;
2615
2959
 
@@ -2715,6 +3059,44 @@ function parseFilters(params: URLSearchParams): Filter[] {
2715
3059
  return filters;
2716
3060
  }
2717
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
+
2718
3100
  /**
2719
3101
  * Parse a logical group value into individual filters.
2720
3102
  * Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
@@ -2726,7 +3108,7 @@ function parseLogicalGroup(value: string): Filter[] {
2726
3108
  }
2727
3109
 
2728
3110
  const filters: Filter[] = [];
2729
- for (const part of content.split(',')) {
3111
+ for (const part of splitLogicalGroup(content)) {
2730
3112
  const dotIndex = part.indexOf('.');
2731
3113
  if (dotIndex === -1) continue;
2732
3114