@everystack/api 0.4.8 → 0.4.10

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,33 @@ 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.10] - 2026-10-18
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - an aggregate inside an embed (author(count())) answers 400 instead of being dropped, on every verb including writes that carry such a select
12
+ - a nested embed on the join path (drizzleRelations) answers 400 instead of being dropped, on every verb including writes that carry such a select; the batch path now keeps nested embeds and accepts snake_case embed column names
13
+
14
+ ### Fixed
15
+
16
+ - table-keyed option maps are read by own key, so a table named constructor reads no prototype value
17
+
18
+ ## [0.4.9] - 2026-10-18
19
+
20
+ ### BREAKING CHANGES
21
+
22
+ - PATCH and DELETE refuse a bad filter before running beforeUpdate or beforeDelete
23
+ - a filter the handler cannot parse or build answers 400 instead of being dropped; a bare value, is.true, is.false and in.(..) inside an or() or and() group are refused, so @everystack/query raw maps need eq.
24
+ - a repeated or= or and= now applies every group, ANDed, so a read or write that sent a second group matches fewer rows
25
+
26
+ ### Fixed
27
+
28
+ - with hideSchemaErrors, an RPC 42501 and the role gate answer Forbidden without database text
29
+
30
+ ### Documentation
31
+
32
+ - upgrade notes for strict filters, hook order and hidden RPC error text
33
+
7
34
  ## [0.4.8] - 2026-10-18
8
35
 
9
36
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
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>",
@@ -216,7 +216,11 @@ export interface AuthConfig {
216
216
  /** Ordered role hierarchy, lowest to highest privilege.
217
217
  * Used by RPC role gating. Default: ['public', 'authenticated', 'admin'] */
218
218
  roleHierarchy?: string[];
219
- /** Field in the user payload containing the role string. Used by RPC role gating. Default: 'role' */
219
+ /** Field in the user payload containing the role string. Default: 'role'
220
+ * `roleField` must hold one string. A missing, non-string or array value is treated as
221
+ * no role and fails every gate; if your tokens carry several roles, collapse them to one
222
+ * in `auth.verifyToken` before the payload reaches the handler, so the RPC, ownership,
223
+ * restore (`PATCH ?deleted=`) gates and your `pgSettings` all read the same value. */
220
224
  roleField?: string;
221
225
  }
222
226
 
@@ -339,7 +343,8 @@ export interface HandlerOptions {
339
343
  drizzleRelations?: Record<string, unknown>;
340
344
  /** Compute PostgreSQL session variables from the authenticated user.
341
345
  * When provided, queries are wrapped in a transaction with set_config() calls
342
- * so RLS policies can reference the user context. */
346
+ * so RLS policies can reference the user context.
347
+ * Read the same field `roleField` names. Do not derive the database role from a different claim. */
343
348
  pgSettings?: (user: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Record<string, string>;
344
349
  /** Per-table lifecycle hooks for mutations. */
345
350
  hooks?: Record<string, HookConfig>;
@@ -557,6 +562,7 @@ async function checkRoleAuth(
557
562
  requiredRole: string,
558
563
  request: Request,
559
564
  auth: AuthConfig,
565
+ hideSchemaErrors?: boolean,
560
566
  ): Promise<AuthResult> {
561
567
  if (requiredRole === 'public') {
562
568
  return { user: null, error: null };
@@ -577,7 +583,7 @@ async function checkRoleAuth(
577
583
  if (!hasRole(userRole, requiredRole, hierarchy)) {
578
584
  return {
579
585
  user: result.user,
580
- error: json({ message: `Insufficient permissions: requires ${requiredRole} role` }, 403),
586
+ error: json({ message: hideSchemaErrors ? 'Forbidden' : `Insufficient permissions: requires ${requiredRole} role` }, 403),
581
587
  };
582
588
  }
583
589
 
@@ -636,7 +642,7 @@ function stripProtectedFields(
636
642
  tableName: string,
637
643
  options?: HandlerOptions,
638
644
  ): unknown {
639
- const fields = options?.protectedFields?.[tableName];
645
+ const fields = ownEntry(options?.protectedFields, tableName);
640
646
  if (!fields || fields.length === 0) return body;
641
647
  if (Array.isArray(body)) return body.map(row => stripProtectedFields(row, tableName, options));
642
648
  if (body === null || typeof body !== 'object') {
@@ -654,7 +660,7 @@ function stripHiddenColumns(
654
660
  tableName: string,
655
661
  options?: HandlerOptions,
656
662
  ): unknown {
657
- const columns = options?.hiddenColumns?.[tableName];
663
+ const columns = ownEntry(options?.hiddenColumns, tableName);
658
664
  if (!columns || columns.length === 0) return data;
659
665
 
660
666
  const strip = (row: Record<string, unknown>): Record<string, unknown> => {
@@ -927,7 +933,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
927
933
  const sameColumn = (field: string): boolean => (table
928
934
  ? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
929
935
  : toSnakeCase(field) === toSnakeCase(colName));
930
- if (roles.length > 0 && (options?.protectedFields?.[tableName] ?? []).some(sameColumn)) {
936
+ if (roles.length > 0 && (ownEntry(options?.protectedFields, tableName) ?? []).some(sameColumn)) {
931
937
  throw new Error(
932
938
  `[everystack/api] softDelete.restore.${tableName}: the soft-delete column "${colName}" is in protectedFields.${tableName}, so every restore body is stripped to {}. Remove it from protectedFields.${tableName} or remove the restore entry.`,
933
939
  );
@@ -941,7 +947,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
941
947
  * non-string role claim is not a member.
942
948
  */
943
949
  function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
944
- const roles = options?.softDelete?.restore?.[tableName];
950
+ const roles = ownEntry(options?.softDelete?.restore, tableName);
945
951
  if (!roles?.length || !user) return false;
946
952
  const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
947
953
  return role !== '' && roles.includes(role);
@@ -1251,7 +1257,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1251
1257
 
1252
1258
  // Per-RPC role check (object syntax: { fn, role })
1253
1259
  if (isObjectForm && entry.role && options?.auth) {
1254
- const roleResult = await checkRoleAuth(entry.role, request, options.auth);
1260
+ const roleResult = await checkRoleAuth(entry.role, request, options.auth, options.hideSchemaErrors);
1255
1261
  if (roleResult.error) {
1256
1262
  await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: fnName, method }, roleResult.outage);
1257
1263
  return roleResult.error;
@@ -1361,6 +1367,10 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1361
1367
  if (options?.hideSchemaErrors && status === 500) {
1362
1368
  return json({ message: 'Internal Server Error' }, 500);
1363
1369
  }
1370
+ // PostgreSQL's 42501 text names functions and tables; 22023 and P0001 are the SQL author's channel and stay raw.
1371
+ if (options?.hideSchemaErrors && pgCode === '42501') {
1372
+ return json({ message: 'Forbidden', code: pgCode }, 403);
1373
+ }
1364
1374
  return json({ message, ...(pgCode ? { code: pgCode } : {}) }, status);
1365
1375
  }
1366
1376
  }
@@ -1448,15 +1458,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1448
1458
 
1449
1459
  // A part with parentheses is an aggregate or a declared embed. Anything
1450
1460
  // else cannot be honored, so refuse it rather than drop it from the rows.
1461
+ // An aggregate inside an embed cannot be honored either, nor can a nested
1462
+ // embed on the JOIN path: its `with` clause is one level deep.
1451
1463
  const tableRelations = ownEntry(options?.relations, tableName);
1452
1464
  if (!queryError) {
1465
+ const joinPath = relationalQueryAvailable(routedDb, tableName, options);
1453
1466
  for (const selectParam of url.searchParams.getAll('select')) {
1454
- const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
1467
+ const { undeclared, embedAggregate, nestedEmbed } = parseSelectParam(selectParam, tableRelations, options?.relations);
1455
1468
  if (undeclared !== undefined) {
1456
1469
  queryError = undeclaredSelectPartMessage(undeclared);
1457
1470
  queryErrorEchoesInput = true;
1458
1471
  break;
1459
1472
  }
1473
+ if (embedAggregate || (nestedEmbed && joinPath)) {
1474
+ queryError = 'Invalid query';
1475
+ break;
1476
+ }
1460
1477
  }
1461
1478
  }
1462
1479
 
@@ -1578,6 +1595,14 @@ function columnPropertyName(table: DrizzleTable | undefined, name: string): stri
1578
1595
  ?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
1579
1596
  }
1580
1597
 
1598
+ /**
1599
+ * A read with embeds takes the JOIN path when this holds. The pre-DB refusal of
1600
+ * a nested embed and handleGet both ask it, so the two cannot disagree.
1601
+ */
1602
+ function relationalQueryAvailable(db: DrizzleDb, tableName: string, options?: HandlerOptions): boolean {
1603
+ return Boolean(ownEntry(options?.relations, tableName) && options?.drizzleRelations && (db as any).query?.[tableName]);
1604
+ }
1605
+
1581
1606
  /** Build each relation's projection before Drizzle reads its values. */
1582
1607
  function buildWithClause(
1583
1608
  embeds: EmbedRequest[],
@@ -1595,7 +1620,7 @@ function buildWithClause(
1595
1620
  if (!relation) continue;
1596
1621
  hasEmbedded = true;
1597
1622
  const table = schema[relation.table];
1598
- const names = new Set(options?.hiddenColumns?.[relation.table] ?? []);
1623
+ const names = new Set(ownEntry(options?.hiddenColumns, relation.table) ?? []);
1599
1624
  const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
1600
1625
  const config: Record<string, any> = {};
1601
1626
  if (embed.columns !== null) {
@@ -1824,10 +1849,7 @@ async function handleGet(
1824
1849
 
1825
1850
  // Detect if Drizzle relational query (JOINs) should be used
1826
1851
  const tableRelations = ownEntry(options?.relations, tableName);
1827
- const useRelationalQuery = embeds.length > 0
1828
- && tableRelations
1829
- && options?.drizzleRelations
1830
- && (db as any).query?.[tableName];
1852
+ const useRelationalQuery = embeds.length > 0 && relationalQueryAvailable(db, tableName, options);
1831
1853
 
1832
1854
  let rows: unknown[];
1833
1855
  const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
@@ -1978,7 +2000,7 @@ function parseSelectParam(
1978
2000
  selectParam: string | null,
1979
2001
  tableRelations?: Record<string, RelationConfig>,
1980
2002
  allRelations?: Record<string, Record<string, RelationConfig>>,
1981
- ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
2003
+ ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string; embedAggregate?: true; nestedEmbed?: true } {
1982
2004
  if (!selectParam) {
1983
2005
  return { columns: null, embeds: [], aggregates: [] };
1984
2006
  }
@@ -1990,6 +2012,11 @@ function parseSelectParam(
1990
2012
  // The first part with parentheses that is neither an aggregate nor a declared
1991
2013
  // embed, at any depth. The caller refuses the select; it is never a column.
1992
2014
  let undeclared: string | undefined;
2015
+ // Aggregates exist at the top level only. One inside an embed, at any depth,
2016
+ // cannot be honored, so the caller refuses the select.
2017
+ let embedAggregate = false;
2018
+ // An embed inside an embed, at any depth. The JOIN path cannot honour one yet.
2019
+ let nestedEmbed = false;
1993
2020
 
1994
2021
  // Parse: split on commas, but respect parentheses
1995
2022
  const parts = splitSelectParam(selectParam);
@@ -2014,6 +2041,8 @@ function parseSelectParam(
2014
2041
  const targetRelations = ownEntry(allRelations, targetTableName);
2015
2042
  const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
2016
2043
  undeclared ??= inner.undeclared;
2044
+ if (inner.aggregates.length > 0 || inner.embedAggregate) embedAggregate = true;
2045
+ if (inner.embeds.length > 0) nestedEmbed = true;
2017
2046
  embeds.push({
2018
2047
  name,
2019
2048
  columns: inner.columns,
@@ -2033,6 +2062,8 @@ function parseSelectParam(
2033
2062
  embeds,
2034
2063
  aggregates,
2035
2064
  ...(undeclared === undefined ? {} : { undeclared }),
2065
+ ...(embedAggregate ? { embedAggregate: true as const } : {}),
2066
+ ...(nestedEmbed ? { nestedEmbed: true as const } : {}),
2036
2067
  };
2037
2068
  }
2038
2069
 
@@ -2159,7 +2190,7 @@ function requestUsesHiddenColumn(
2159
2190
  return isHidden(tableName, (dot < 0 ? order : order.slice(0, dot)).trim());
2160
2191
  })) return true;
2161
2192
  } else if (key === 'or' || key === 'and') {
2162
- if (parseLogicalGroup(value).some(filter => isHidden(tableName, filter.column))) return true;
2193
+ if (parseLogicalGroup(value).filters.some(filter => isHidden(tableName, filter.column))) return true;
2163
2194
  } else if (!RESERVED_QUERY_PARAMS.has(key) && isHidden(tableName, key)) {
2164
2195
  return true;
2165
2196
  }
@@ -2167,6 +2198,20 @@ function requestUsesHiddenColumn(
2167
2198
  return false;
2168
2199
  }
2169
2200
 
2201
+ /** The relation an embed names, when its target table and key exist. resolveEmbeds skips any other. */
2202
+ function resolveEmbedTarget(
2203
+ schema: Schema,
2204
+ tableRelations: Record<string, RelationConfig> | undefined,
2205
+ name: string,
2206
+ ): { relationConfig: RelationConfig; targetTable: DrizzleTable; targetColumn: DrizzleColumn } | null {
2207
+ const relationConfig = ownEntry(tableRelations, name);
2208
+ if (!relationConfig) return null;
2209
+ const targetTable = schema[relationConfig.table];
2210
+ if (!targetTable) return null;
2211
+ const targetColumn = getColumn(targetTable, relationConfig.to);
2212
+ return targetColumn ? { relationConfig, targetTable, targetColumn } : null;
2213
+ }
2214
+
2170
2215
  /**
2171
2216
  * Resolve relation embeds by fetching related rows and nesting them.
2172
2217
  */
@@ -2185,14 +2230,18 @@ async function resolveEmbeds(
2185
2230
  const result = rows.map(r => ({ ...r }));
2186
2231
 
2187
2232
  for (const embed of embeds) {
2188
- const relationConfig = tableRelations[embed.name];
2189
- if (!relationConfig) continue;
2190
-
2191
- const targetTable = schema[relationConfig.table];
2192
- if (!targetTable) continue;
2193
-
2194
- const targetColumn = getColumn(targetTable, relationConfig.to);
2195
- if (!targetColumn) continue;
2233
+ const target = resolveEmbedTarget(schema, tableRelations, embed.name);
2234
+ if (!target) continue;
2235
+ const { relationConfig, targetTable, targetColumn } = target;
2236
+
2237
+ // Rows are keyed by schema property, so pick by property name, as
2238
+ // buildWithClause does. A nested embed is kept only when it resolves: an
2239
+ // unresolved one is skipped below, and its name may be a real column.
2240
+ const nestedRelations = ownEntry(options.relations, relationConfig.table);
2241
+ const picked = embed.columns && [
2242
+ ...embed.columns.map(name => columnPropertyName(targetTable, name)).filter((name): name is string => name !== undefined),
2243
+ ...embed.embeds.filter(nested => resolveEmbedTarget(schema, nestedRelations, nested.name)).map(nested => nested.name),
2244
+ ];
2196
2245
 
2197
2246
  // Build soft-delete filter for the target table
2198
2247
  const targetSdCol = isSoftDeleteTable(relationConfig.table, targetTable, options)
@@ -2233,9 +2282,7 @@ async function resolveEmbeds(
2233
2282
  for (const row of result) {
2234
2283
  const sourceVal = row[relationConfig.from];
2235
2284
  const matched = relatedRows.filter(r => r[relationConfig.to] === sourceVal);
2236
- row[embed.name] = embed.columns
2237
- ? matched.map(r => pickColumns(r, embed.columns!))
2238
- : matched;
2285
+ row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
2239
2286
  }
2240
2287
  } else {
2241
2288
  // Many-to-one: source.from is the FK, target.to is the PK
@@ -2280,7 +2327,7 @@ async function resolveEmbeds(
2280
2327
  row[embed.name] = null;
2281
2328
  } else {
2282
2329
  const related = index.get(fkVal) || null;
2283
- row[embed.name] = related && embed.columns ? pickColumns(related, embed.columns) : related;
2330
+ row[embed.name] = related && picked ? pickColumns(related, picked) : related;
2284
2331
  }
2285
2332
  }
2286
2333
  }
@@ -2366,10 +2413,10 @@ function sanitizeWriteBody(
2366
2413
  at: { requestId?: string; name: string; method: string },
2367
2414
  deferred: Deferred[],
2368
2415
  ): { body: unknown; changed: string[] } | { refusal: Response } {
2369
- const columns = options?.sanitizeColumns?.[tableName];
2416
+ const columns = ownEntry(options?.sanitizeColumns, tableName);
2370
2417
  if (!columns?.length) return { body, changed: [] };
2371
2418
  const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
2372
- const hidden = new Set((options?.hiddenColumns?.[tableName] ?? []).map(name => columnPropertyName(table, name)));
2419
+ const hidden = new Set((ownEntry(options?.hiddenColumns, tableName) ?? []).map(name => columnPropertyName(table, name)));
2373
2420
  const refuse = (message: string, column: string): { refusal: Response } => {
2374
2421
  const fault: HandlerFault = {
2375
2422
  ...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
@@ -2451,7 +2498,7 @@ async function handlePost(
2451
2498
  throw err;
2452
2499
  }
2453
2500
 
2454
- const hook = options?.hooks?.[tableName];
2501
+ const hook = ownEntry(options?.hooks, tableName);
2455
2502
 
2456
2503
  if (hook?.beforeCreate) {
2457
2504
  try {
@@ -2545,7 +2592,7 @@ async function handlePatch(
2545
2592
 
2546
2593
  // With the ownership backstop lifted, an unfiltered PATCH would write every row the
2547
2594
  // role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
2548
- const ownership = options?.rowOwnership?.[tableName];
2595
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2549
2596
  if (user && options && ownership && isOwnershipExempt(ownership, user, options)
2550
2597
  && buildsNoFilter(table, params)) {
2551
2598
  return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
@@ -2563,7 +2610,13 @@ async function handlePatch(
2563
2610
  if (key !== null) return json({ message: `Cannot change ownership column "${key}"` }, 403);
2564
2611
  }
2565
2612
 
2566
- const hook = options?.hooks?.[tableName];
2613
+ // Before the hook, so a request that will answer 400 never runs app side effects.
2614
+ const filterResult = buildAllConditions(table, params);
2615
+ if ('error' in filterResult) {
2616
+ return sanitizeError(filterResult.error, 400, options);
2617
+ }
2618
+
2619
+ const hook = ownEntry(options?.hooks, tableName);
2567
2620
 
2568
2621
  if (hook?.beforeUpdate) {
2569
2622
  try {
@@ -2584,10 +2637,6 @@ async function handlePatch(
2584
2637
  if ('refusal' in sanitized) return sanitized.refusal;
2585
2638
  body = sanitized.body as Record<string, unknown>;
2586
2639
 
2587
- const filterResult = buildAllConditions(table, params);
2588
- if ('error' in filterResult) {
2589
- return sanitizeError(filterResult.error, 400, options);
2590
- }
2591
2640
  const conditions = filterResult.conditions;
2592
2641
 
2593
2642
  // Soft-delete: update live records, unless a restore role sent ?deleted=include|only
@@ -2659,7 +2708,7 @@ function unbackedExemptionRefusal(
2659
2708
  settings: Record<string, string> | undefined,
2660
2709
  ): string | null {
2661
2710
  if (method !== 'PATCH' && method !== 'DELETE') return null;
2662
- const ownership = options?.rowOwnership?.[tableName];
2711
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2663
2712
  if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
2664
2713
  if (settings?.['role']) return null;
2665
2714
  const message = `rowOwnership.${tableName}.exceptRoles: pgSettings returned no role for this request, so SET LOCAL ROLE did not run and no RLS policy backs the exemption. Write refused.`;
@@ -2707,7 +2756,7 @@ function ownershipScope(
2707
2756
  options: HandlerOptions | undefined,
2708
2757
  user: Record<string, unknown> | null,
2709
2758
  ): OwnershipScope {
2710
- const ownership = options?.rowOwnership?.[tableName];
2759
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2711
2760
  if (!options || !ownership) return { condition: null };
2712
2761
  if (!user) {
2713
2762
  return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
@@ -2750,7 +2799,13 @@ async function handleDelete(
2750
2799
  return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
2751
2800
  }
2752
2801
 
2753
- const hook = options?.hooks?.[tableName];
2802
+ // Before the hook, so a request that will answer 400 never runs app side effects.
2803
+ const filterResult = buildAllConditions(table, params);
2804
+ if ('error' in filterResult) {
2805
+ return sanitizeError(filterResult.error, 400, options);
2806
+ }
2807
+
2808
+ const hook = ownEntry(options?.hooks, tableName);
2754
2809
 
2755
2810
  if (hook?.beforeDelete) {
2756
2811
  try {
@@ -2760,10 +2815,6 @@ async function handleDelete(
2760
2815
  }
2761
2816
  }
2762
2817
 
2763
- const filterResult = buildAllConditions(table, params);
2764
- if ('error' in filterResult) {
2765
- return sanitizeError(filterResult.error, 400, options);
2766
- }
2767
2818
  const conditions = filterResult.conditions;
2768
2819
 
2769
2820
  // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
@@ -3089,6 +3140,7 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
3089
3140
  case 'is':
3090
3141
  if (filter.value === 'null') condition = isNull(column);
3091
3142
  else if (filter.value === 'not.null') condition = isNotNull(column);
3143
+ else return { __filterError: `Invalid value for 'is': expected null or not.null` };
3092
3144
  break;
3093
3145
  case 'in':
3094
3146
  condition = inArray(column, values ?? parseInList(filter.value));
@@ -3162,7 +3214,7 @@ function buildOrderBy(
3162
3214
 
3163
3215
  const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
3164
3216
 
3165
- function parseFilters(params: URLSearchParams): Filter[] {
3217
+ function parseFilters(params: URLSearchParams): { filters: Filter[]; error?: string } {
3166
3218
  const filters: Filter[] = [];
3167
3219
 
3168
3220
  for (const [key, value] of params.entries()) {
@@ -3178,7 +3230,9 @@ function parseFilters(params: URLSearchParams): Filter[] {
3178
3230
 
3179
3231
  // PostgREST format: column=operator.value
3180
3232
  const dotIndex = remaining.indexOf('.');
3181
- if (dotIndex === -1) continue;
3233
+ if (dotIndex === -1) {
3234
+ return { filters, error: `Invalid filter for '${key.slice(0, 100)}': expected operator.value` };
3235
+ }
3182
3236
 
3183
3237
  const operator = remaining.substring(0, dotIndex);
3184
3238
  const filterValue = remaining.substring(dotIndex + 1);
@@ -3193,7 +3247,7 @@ function parseFilters(params: URLSearchParams): Filter[] {
3193
3247
  });
3194
3248
  }
3195
3249
 
3196
- return filters;
3250
+ return { filters };
3197
3251
  }
3198
3252
 
3199
3253
  /** A group part reading `column.[not.]cs.` so far: a `{` here opens an array value. */
@@ -3238,16 +3292,22 @@ function splitLogicalGroup(content: string): string[] {
3238
3292
  * Parse a logical group value into individual filters.
3239
3293
  * Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
3240
3294
  */
3241
- function parseLogicalGroup(value: string): Filter[] {
3295
+ function parseLogicalGroup(value: string): { filters: Filter[]; error?: string } {
3242
3296
  let content = value;
3243
3297
  if (content.startsWith('(') && content.endsWith(')')) {
3244
3298
  content = content.slice(1, -1);
3245
3299
  }
3246
3300
 
3247
3301
  const filters: Filter[] = [];
3302
+ if (content === '') return { filters };
3303
+ let error: string | undefined;
3304
+ const memberError = 'Invalid filter group: every member must be column.operator.value';
3248
3305
  for (const part of splitLogicalGroup(content)) {
3249
3306
  const dotIndex = part.indexOf('.');
3250
- if (dotIndex === -1) continue;
3307
+ if (dotIndex === -1) {
3308
+ error = memberError;
3309
+ continue;
3310
+ }
3251
3311
 
3252
3312
  const column = part.substring(0, dotIndex);
3253
3313
  const rest = part.substring(dotIndex + 1);
@@ -3260,7 +3320,10 @@ function parseLogicalGroup(value: string): Filter[] {
3260
3320
  }
3261
3321
 
3262
3322
  const opDot = remaining.indexOf('.');
3263
- if (opDot === -1) continue;
3323
+ if (opDot === -1) {
3324
+ error = memberError;
3325
+ continue;
3326
+ }
3264
3327
 
3265
3328
  const operator = remaining.substring(0, opDot);
3266
3329
  const filterValue = remaining.substring(opDot + 1);
@@ -3273,7 +3336,7 @@ function parseLogicalGroup(value: string): Filter[] {
3273
3336
  ...(jsonPath ? { jsonPath } : {}),
3274
3337
  });
3275
3338
  }
3276
- return filters;
3339
+ return error ? { filters, error } : { filters };
3277
3340
  }
3278
3341
 
3279
3342
  function isFilterError(x: unknown): x is { __filterError: string } {
@@ -3286,36 +3349,27 @@ function isFilterError(x: unknown): x is { __filterError: string } {
3286
3349
  * Returns { conditions } on success, { error } on validation failure.
3287
3350
  */
3288
3351
  function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { conditions: any[] } | { error: string } {
3289
- const filters = parseFilters(params);
3290
- const rawResults = filters.map(f => buildCondition(table, f));
3352
+ const parsed = parseFilters(params);
3353
+ if (parsed.error) return { error: parsed.error };
3354
+ const rawResults = parsed.filters.map(f => buildCondition(table, f));
3291
3355
  for (const r of rawResults) {
3292
3356
  if (isFilterError(r)) return { error: r.__filterError };
3293
3357
  }
3294
3358
  const conditions = rawResults.filter(Boolean);
3295
3359
 
3296
- const orParam = params.get('or');
3297
- if (orParam) {
3298
- const orFilters = parseLogicalGroup(orParam);
3299
- const orRawResults = orFilters.map(f => buildCondition(table, f));
3300
- for (const r of orRawResults) {
3301
- if (isFilterError(r)) return { error: r.__filterError };
3302
- }
3303
- const orConditions = orRawResults.filter(Boolean);
3304
- if (orConditions.length > 0) {
3305
- conditions.push(or(...orConditions));
3306
- }
3307
- }
3308
-
3309
- const andParam = params.get('and');
3310
- if (andParam) {
3311
- const andFilters = parseLogicalGroup(andParam);
3312
- const andRawResults = andFilters.map(f => buildCondition(table, f));
3313
- for (const r of andRawResults) {
3314
- if (isFilterError(r)) return { error: r.__filterError };
3315
- }
3316
- const andConditions = andRawResults.filter(Boolean);
3317
- if (andConditions.length > 0) {
3318
- conditions.push(and(...andConditions));
3360
+ // getAll: the client appends, so `.or(a).or(b)` sends the key twice; dropping one widens the request.
3361
+ for (const [key, combine] of [['or', or], ['and', and]] as const) {
3362
+ for (const value of params.getAll(key)) {
3363
+ const group = parseLogicalGroup(value);
3364
+ if (group.error) return { error: group.error };
3365
+ const groupRawResults = group.filters.map(f => buildCondition(table, f));
3366
+ for (const r of groupRawResults) {
3367
+ if (isFilterError(r)) return { error: r.__filterError };
3368
+ }
3369
+ const groupConditions = groupRawResults.filter(Boolean);
3370
+ if (groupConditions.length > 0) {
3371
+ conditions.push(combine(...groupConditions));
3372
+ }
3319
3373
  }
3320
3374
  }
3321
3375
 
@@ -3324,9 +3378,8 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
3324
3378
 
3325
3379
  /**
3326
3380
  * True when the request's own filters build no SQL condition, so the write would run
3327
- * with no WHERE. A param that parses but builds nothing (`or=`, `or=()`, `and=junk`,
3328
- * `id=is.x`) is not a filter. Only user filters count: the soft-delete and ownership
3329
- * clauses the handler adds are not part of this test. A filter error is left to the
3381
+ * with no WHERE. An empty group (`or=`, `or=()`) is not a filter. Only user filters
3382
+ * count: the soft-delete and ownership clauses the handler adds are not part of this test. A filter error is left to the
3330
3383
  * caller's own buildAllConditions, which reports it.
3331
3384
  */
3332
3385
  function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {