@everystack/api 0.4.9 → 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,17 @@ 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
+
7
18
  ## [0.4.9] - 2026-10-18
8
19
 
9
20
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.9",
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>;
@@ -637,7 +642,7 @@ function stripProtectedFields(
637
642
  tableName: string,
638
643
  options?: HandlerOptions,
639
644
  ): unknown {
640
- const fields = options?.protectedFields?.[tableName];
645
+ const fields = ownEntry(options?.protectedFields, tableName);
641
646
  if (!fields || fields.length === 0) return body;
642
647
  if (Array.isArray(body)) return body.map(row => stripProtectedFields(row, tableName, options));
643
648
  if (body === null || typeof body !== 'object') {
@@ -655,7 +660,7 @@ function stripHiddenColumns(
655
660
  tableName: string,
656
661
  options?: HandlerOptions,
657
662
  ): unknown {
658
- const columns = options?.hiddenColumns?.[tableName];
663
+ const columns = ownEntry(options?.hiddenColumns, tableName);
659
664
  if (!columns || columns.length === 0) return data;
660
665
 
661
666
  const strip = (row: Record<string, unknown>): Record<string, unknown> => {
@@ -928,7 +933,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
928
933
  const sameColumn = (field: string): boolean => (table
929
934
  ? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
930
935
  : toSnakeCase(field) === toSnakeCase(colName));
931
- if (roles.length > 0 && (options?.protectedFields?.[tableName] ?? []).some(sameColumn)) {
936
+ if (roles.length > 0 && (ownEntry(options?.protectedFields, tableName) ?? []).some(sameColumn)) {
932
937
  throw new Error(
933
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.`,
934
939
  );
@@ -942,7 +947,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
942
947
  * non-string role claim is not a member.
943
948
  */
944
949
  function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
945
- const roles = options?.softDelete?.restore?.[tableName];
950
+ const roles = ownEntry(options?.softDelete?.restore, tableName);
946
951
  if (!roles?.length || !user) return false;
947
952
  const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
948
953
  return role !== '' && roles.includes(role);
@@ -1453,15 +1458,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1453
1458
 
1454
1459
  // A part with parentheses is an aggregate or a declared embed. Anything
1455
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.
1456
1463
  const tableRelations = ownEntry(options?.relations, tableName);
1457
1464
  if (!queryError) {
1465
+ const joinPath = relationalQueryAvailable(routedDb, tableName, options);
1458
1466
  for (const selectParam of url.searchParams.getAll('select')) {
1459
- const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
1467
+ const { undeclared, embedAggregate, nestedEmbed } = parseSelectParam(selectParam, tableRelations, options?.relations);
1460
1468
  if (undeclared !== undefined) {
1461
1469
  queryError = undeclaredSelectPartMessage(undeclared);
1462
1470
  queryErrorEchoesInput = true;
1463
1471
  break;
1464
1472
  }
1473
+ if (embedAggregate || (nestedEmbed && joinPath)) {
1474
+ queryError = 'Invalid query';
1475
+ break;
1476
+ }
1465
1477
  }
1466
1478
  }
1467
1479
 
@@ -1583,6 +1595,14 @@ function columnPropertyName(table: DrizzleTable | undefined, name: string): stri
1583
1595
  ?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
1584
1596
  }
1585
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
+
1586
1606
  /** Build each relation's projection before Drizzle reads its values. */
1587
1607
  function buildWithClause(
1588
1608
  embeds: EmbedRequest[],
@@ -1600,7 +1620,7 @@ function buildWithClause(
1600
1620
  if (!relation) continue;
1601
1621
  hasEmbedded = true;
1602
1622
  const table = schema[relation.table];
1603
- const names = new Set(options?.hiddenColumns?.[relation.table] ?? []);
1623
+ const names = new Set(ownEntry(options?.hiddenColumns, relation.table) ?? []);
1604
1624
  const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
1605
1625
  const config: Record<string, any> = {};
1606
1626
  if (embed.columns !== null) {
@@ -1829,10 +1849,7 @@ async function handleGet(
1829
1849
 
1830
1850
  // Detect if Drizzle relational query (JOINs) should be used
1831
1851
  const tableRelations = ownEntry(options?.relations, tableName);
1832
- const useRelationalQuery = embeds.length > 0
1833
- && tableRelations
1834
- && options?.drizzleRelations
1835
- && (db as any).query?.[tableName];
1852
+ const useRelationalQuery = embeds.length > 0 && relationalQueryAvailable(db, tableName, options);
1836
1853
 
1837
1854
  let rows: unknown[];
1838
1855
  const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
@@ -1983,7 +2000,7 @@ function parseSelectParam(
1983
2000
  selectParam: string | null,
1984
2001
  tableRelations?: Record<string, RelationConfig>,
1985
2002
  allRelations?: Record<string, Record<string, RelationConfig>>,
1986
- ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
2003
+ ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string; embedAggregate?: true; nestedEmbed?: true } {
1987
2004
  if (!selectParam) {
1988
2005
  return { columns: null, embeds: [], aggregates: [] };
1989
2006
  }
@@ -1995,6 +2012,11 @@ function parseSelectParam(
1995
2012
  // The first part with parentheses that is neither an aggregate nor a declared
1996
2013
  // embed, at any depth. The caller refuses the select; it is never a column.
1997
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;
1998
2020
 
1999
2021
  // Parse: split on commas, but respect parentheses
2000
2022
  const parts = splitSelectParam(selectParam);
@@ -2019,6 +2041,8 @@ function parseSelectParam(
2019
2041
  const targetRelations = ownEntry(allRelations, targetTableName);
2020
2042
  const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
2021
2043
  undeclared ??= inner.undeclared;
2044
+ if (inner.aggregates.length > 0 || inner.embedAggregate) embedAggregate = true;
2045
+ if (inner.embeds.length > 0) nestedEmbed = true;
2022
2046
  embeds.push({
2023
2047
  name,
2024
2048
  columns: inner.columns,
@@ -2038,6 +2062,8 @@ function parseSelectParam(
2038
2062
  embeds,
2039
2063
  aggregates,
2040
2064
  ...(undeclared === undefined ? {} : { undeclared }),
2065
+ ...(embedAggregate ? { embedAggregate: true as const } : {}),
2066
+ ...(nestedEmbed ? { nestedEmbed: true as const } : {}),
2041
2067
  };
2042
2068
  }
2043
2069
 
@@ -2172,6 +2198,20 @@ function requestUsesHiddenColumn(
2172
2198
  return false;
2173
2199
  }
2174
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
+
2175
2215
  /**
2176
2216
  * Resolve relation embeds by fetching related rows and nesting them.
2177
2217
  */
@@ -2190,14 +2230,18 @@ async function resolveEmbeds(
2190
2230
  const result = rows.map(r => ({ ...r }));
2191
2231
 
2192
2232
  for (const embed of embeds) {
2193
- const relationConfig = tableRelations[embed.name];
2194
- if (!relationConfig) continue;
2195
-
2196
- const targetTable = schema[relationConfig.table];
2197
- if (!targetTable) continue;
2198
-
2199
- const targetColumn = getColumn(targetTable, relationConfig.to);
2200
- 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
+ ];
2201
2245
 
2202
2246
  // Build soft-delete filter for the target table
2203
2247
  const targetSdCol = isSoftDeleteTable(relationConfig.table, targetTable, options)
@@ -2238,9 +2282,7 @@ async function resolveEmbeds(
2238
2282
  for (const row of result) {
2239
2283
  const sourceVal = row[relationConfig.from];
2240
2284
  const matched = relatedRows.filter(r => r[relationConfig.to] === sourceVal);
2241
- row[embed.name] = embed.columns
2242
- ? matched.map(r => pickColumns(r, embed.columns!))
2243
- : matched;
2285
+ row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
2244
2286
  }
2245
2287
  } else {
2246
2288
  // Many-to-one: source.from is the FK, target.to is the PK
@@ -2285,7 +2327,7 @@ async function resolveEmbeds(
2285
2327
  row[embed.name] = null;
2286
2328
  } else {
2287
2329
  const related = index.get(fkVal) || null;
2288
- row[embed.name] = related && embed.columns ? pickColumns(related, embed.columns) : related;
2330
+ row[embed.name] = related && picked ? pickColumns(related, picked) : related;
2289
2331
  }
2290
2332
  }
2291
2333
  }
@@ -2371,10 +2413,10 @@ function sanitizeWriteBody(
2371
2413
  at: { requestId?: string; name: string; method: string },
2372
2414
  deferred: Deferred[],
2373
2415
  ): { body: unknown; changed: string[] } | { refusal: Response } {
2374
- const columns = options?.sanitizeColumns?.[tableName];
2416
+ const columns = ownEntry(options?.sanitizeColumns, tableName);
2375
2417
  if (!columns?.length) return { body, changed: [] };
2376
2418
  const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
2377
- 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)));
2378
2420
  const refuse = (message: string, column: string): { refusal: Response } => {
2379
2421
  const fault: HandlerFault = {
2380
2422
  ...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
@@ -2456,7 +2498,7 @@ async function handlePost(
2456
2498
  throw err;
2457
2499
  }
2458
2500
 
2459
- const hook = options?.hooks?.[tableName];
2501
+ const hook = ownEntry(options?.hooks, tableName);
2460
2502
 
2461
2503
  if (hook?.beforeCreate) {
2462
2504
  try {
@@ -2550,7 +2592,7 @@ async function handlePatch(
2550
2592
 
2551
2593
  // With the ownership backstop lifted, an unfiltered PATCH would write every row the
2552
2594
  // role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
2553
- const ownership = options?.rowOwnership?.[tableName];
2595
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2554
2596
  if (user && options && ownership && isOwnershipExempt(ownership, user, options)
2555
2597
  && buildsNoFilter(table, params)) {
2556
2598
  return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
@@ -2574,7 +2616,7 @@ async function handlePatch(
2574
2616
  return sanitizeError(filterResult.error, 400, options);
2575
2617
  }
2576
2618
 
2577
- const hook = options?.hooks?.[tableName];
2619
+ const hook = ownEntry(options?.hooks, tableName);
2578
2620
 
2579
2621
  if (hook?.beforeUpdate) {
2580
2622
  try {
@@ -2666,7 +2708,7 @@ function unbackedExemptionRefusal(
2666
2708
  settings: Record<string, string> | undefined,
2667
2709
  ): string | null {
2668
2710
  if (method !== 'PATCH' && method !== 'DELETE') return null;
2669
- const ownership = options?.rowOwnership?.[tableName];
2711
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2670
2712
  if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
2671
2713
  if (settings?.['role']) return null;
2672
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.`;
@@ -2714,7 +2756,7 @@ function ownershipScope(
2714
2756
  options: HandlerOptions | undefined,
2715
2757
  user: Record<string, unknown> | null,
2716
2758
  ): OwnershipScope {
2717
- const ownership = options?.rowOwnership?.[tableName];
2759
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2718
2760
  if (!options || !ownership) return { condition: null };
2719
2761
  if (!user) {
2720
2762
  return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
@@ -2763,7 +2805,7 @@ async function handleDelete(
2763
2805
  return sanitizeError(filterResult.error, 400, options);
2764
2806
  }
2765
2807
 
2766
- const hook = options?.hooks?.[tableName];
2808
+ const hook = ownEntry(options?.hooks, tableName);
2767
2809
 
2768
2810
  if (hook?.beforeDelete) {
2769
2811
  try {