@everystack/api 0.4.9 → 0.4.11

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,36 @@ 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.11] - 2026-10-23
8
+
9
+ ### Added
10
+
11
+ - per-parent embed order, limit and where on both read paths (F10)
12
+ - hasOne one-row embed on both read paths (P2)
13
+ - createHandler refuses a handler relation drizzleRelations does not declare (P3)
14
+ - the handler deletes, hides and restores lifecycle rows through the database (H1, P1.4)
15
+ - profiles, posts, likes and follows are one lifecycle family; account delete and restore use it (P2.1, P2.2)
16
+ - lifecycle columns are hidden from callers that cannot manage the table
17
+ - beforeDestroy and afterDestroy hooks at a real removal
18
+
19
+ ### Fixed
20
+
21
+ - an unresolved relation no longer returns a same-named column
22
+ - match embedded rows on a hidden child key before stripping it (P2 review)
23
+ - a belongsTo embed matches on a hidden target key before stripping it
24
+ - a beforeDestroy HandlerError renders with its own status
25
+
26
+ ## [0.4.10] - 2026-10-18
27
+
28
+ ### BREAKING CHANGES
29
+
30
+ - an aggregate inside an embed (author(count())) answers 400 instead of being dropped, on every verb including writes that carry such a select
31
+ - 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
32
+
33
+ ### Fixed
34
+
35
+ - table-keyed option maps are read by own key, so a table named constructor reads no prototype value
36
+
7
37
  ## [0.4.9] - 2026-10-18
8
38
 
9
39
  ### 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.11",
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>",
@@ -83,6 +83,15 @@ export interface QueryBuilder {
83
83
  insert(data: Record<string, unknown>): Promise<ClientResponse<unknown>>;
84
84
  update(data: Record<string, unknown>): Promise<ClientResponse<unknown[]>>;
85
85
  remove(): Promise<ClientResponse<unknown[]>>;
86
+ /** Lifecycle tables: restore a deleted row and its delete batch, on the table's delete rule.
87
+ * POSTs `{baseUrl}/{table}/restore` with `{ id }`; the data is `{ restored, skipped }`. */
88
+ restore(id: string | number): Promise<ClientResponse<RestoreResult>>;
89
+ }
90
+
91
+ /** What a lifecycle restore brought back: rows restored, and batch rows left deleted. */
92
+ export interface RestoreResult {
93
+ restored: number;
94
+ skipped: number;
86
95
  }
87
96
 
88
97
  export interface Client {
@@ -270,6 +279,26 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
270
279
  const result = await response.json();
271
280
  return { data: result, error: null };
272
281
  },
282
+
283
+ async restore(id: string | number): Promise<ClientResponse<RestoreResult>> {
284
+ const url = `${options.baseUrl}/${table}/restore`;
285
+ const headers = await buildHeaders(options);
286
+ headers['Content-Type'] = 'application/json';
287
+
288
+ const { response, retried } = await fetchWithRetry(
289
+ url,
290
+ { method: 'POST', headers, body: JSON.stringify({ id }) },
291
+ options,
292
+ );
293
+
294
+ if (retried) return retried as ClientResponse<RestoreResult>;
295
+
296
+ if (!response.ok) {
297
+ return { data: null, error: await parseError(response) };
298
+ }
299
+
300
+ return { data: (await response.json()) as RestoreResult, error: null };
301
+ },
273
302
  };
274
303
 
275
304
  return builder;
@@ -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
 
@@ -229,6 +233,20 @@ export interface RelationConfig {
229
233
  to: string;
230
234
  /** If true, this is a one-to-many relation (returns array). Default: false (many-to-one, returns object) */
231
235
  many?: boolean;
236
+ /** To-one from the parent side (returns an object or null): the first target row whose `to`
237
+ * equals the parent's `from`, in `orderBy` order. Same column roles as `many`. Not with
238
+ * `many` or `limit`. The target must have a primary key. */
239
+ hasOne?: true;
240
+ /** Many or hasOne. Display order of each parent's rows, by target column. The target's primary
241
+ * key is appended as the tie-breaker (a hasOne with no orderBy is ordered by that key).
242
+ * Never read from the request. */
243
+ orderBy?: Array<{ column: string; order: 'asc' | 'desc' }>;
244
+ /** Many only. At most this many rows per parent, in `orderBy` order. Unrelated to `?limit=`. */
245
+ limit?: number;
246
+ /** Many or hasOne. Equality on target columns (`null` means IS NULL), applied before `limit`
247
+ * or the hasOne choice. A display filter the request cannot widen. It is not access control:
248
+ * use RLS for that. */
249
+ where?: Record<string, string | number | boolean | null>;
232
250
  }
233
251
 
234
252
  export interface SoftDeleteConfig {
@@ -241,10 +259,28 @@ export interface SoftDeleteConfig {
241
259
  * default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
242
260
  * Anyone else gets live rows only, as if the param were absent. A restore body must be
243
261
  * exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
244
- * This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md). */
262
+ * This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md).
263
+ * A lifecycle table may not appear here: it restores through `POST {table}/restore`. */
245
264
  restore?: Record<string, string[]>;
265
+ /** `lifecycle: true` tables (deriveHandlerConfig emits them), keyed like `tables`. Each is a
266
+ * soft-delete table whose column is always `deletedAt`, soft-deleted by the database itself:
267
+ * DELETE runs a real SQL DELETE (a trigger stamps the row), reads and embeds hide deleted rows
268
+ * for every role unless `?deleted=include|only` (honoured for any role; RLS decides what each
269
+ * sees), and `POST {basePath}/{table}/restore {id}` calls `<schema>.<table>_restore(id)` as the
270
+ * caller. PATCH with `?deleted=include|only` is refused with 400. `?deleted=only` reaches embeds
271
+ * too, so a live parent embedded under a deleted child comes back null. */
272
+ lifecycle?: string[];
273
+ }
274
+
275
+ /** Context for a real removal. External effects must be idempotent across retries. */
276
+ export interface DestroyContext {
277
+ source: 'purge' | 'api';
278
+ user: Record<string, unknown> | null;
279
+ client: Record<string, unknown> | null;
246
280
  }
247
281
 
282
+ export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
283
+
248
284
  export interface HookConfig {
249
285
  /** Called before INSERT. Can modify the body (e.g., inject userId). Throw to abort. */
250
286
  beforeCreate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
@@ -258,6 +294,14 @@ export interface HookConfig {
258
294
  beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
259
295
  /** Called after DELETE with all deleted rows. Fire-and-forget. */
260
296
  afterDelete?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
297
+ /** After the hard DELETE statement and before commit, for each returned row. A throw
298
+ * rolls back the database delete only. No transaction is passed; external effects
299
+ * cannot be undone and must be idempotent. Lifecycle API deletes do not call this.
300
+ * Treat the row as read-only: it is the same object later serialized into the response. */
301
+ beforeDestroy?: DestroyHook;
302
+ /** After a real removal commits, per returned row, best-effort. Must be idempotent.
303
+ * Treat the row as read-only: it is the same object later serialized into the response. */
304
+ afterDestroy?: DestroyHook;
261
305
  }
262
306
 
263
307
  /** Cache-Control header configuration for different response categories. */
@@ -339,7 +383,8 @@ export interface HandlerOptions {
339
383
  drizzleRelations?: Record<string, unknown>;
340
384
  /** Compute PostgreSQL session variables from the authenticated user.
341
385
  * When provided, queries are wrapped in a transaction with set_config() calls
342
- * so RLS policies can reference the user context. */
386
+ * so RLS policies can reference the user context.
387
+ * Read the same field `roleField` names. Do not derive the database role from a different claim. */
343
388
  pgSettings?: (user: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Record<string, string>;
344
389
  /** Per-table lifecycle hooks for mutations. */
345
390
  hooks?: Record<string, HookConfig>;
@@ -361,6 +406,12 @@ export interface HandlerOptions {
361
406
  * Explicit references in query parameters are refused before database access.
362
407
  * Does not prevent writes to these columns; use protectedFields for that. */
363
408
  hiddenColumns?: Record<string, string[]>;
409
+ /** Exact roles allowed to read deletedBy, deletedBatch and deletedXid on each lifecycle
410
+ * table. Other hidden columns remain hidden. Uses auth.roleField (default role), with
411
+ * no hierarchy or case folding. Missing/non-string claims never qualify. The claim is compared
412
+ * to the model's PostgreSQL role name, so an app whose pgSettings maps claim to role
413
+ * differently sees the two diverge: keep the claim values equal to the manage role names. */
414
+ hiddenColumnsExceptRoles?: Record<string, string[]>;
364
415
  /** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
365
416
  * Prevents mass assignment / privilege escalation on sensitive columns. */
366
417
  protectedFields?: Record<string, string[]>;
@@ -410,8 +461,16 @@ export interface HandlerOptions {
410
461
  onError?: (fault: HandlerFault) => void | Promise<void>;
411
462
  }
412
463
 
413
- function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
464
+ /** The lifecycle machinery's timestamp column: fixed, whatever softDelete.column says. */
465
+ const LIFECYCLE_DELETED_AT = 'deletedAt';
466
+
467
+ function isLifecycleTable(tableName: string, options?: HandlerOptions): boolean {
468
+ return options?.softDelete?.lifecycle?.includes(tableName) ?? false;
469
+ }
470
+
471
+ function getSoftDeleteColumn(tableName: string, table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
414
472
  if (!options?.softDelete) return null;
473
+ if (isLifecycleTable(tableName, options)) return getColumn(table, LIFECYCLE_DELETED_AT);
415
474
  const colName = options.softDelete.column || 'deletedAt';
416
475
  return getColumn(table, colName);
417
476
  }
@@ -429,11 +488,29 @@ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL
429
488
 
430
489
  function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
431
490
  if (!options?.softDelete) return false;
491
+ if (isLifecycleTable(tableName, options)) return true;
432
492
  if (options.softDelete.tables) {
433
493
  return options.softDelete.tables.includes(tableName);
434
494
  }
435
495
  // Auto-detect: table has the soft-delete column
436
- return getSoftDeleteColumn(table, options) != null;
496
+ return getSoftDeleteColumn(tableName, table, options) != null;
497
+ }
498
+
499
+ /**
500
+ * The soft-delete guard on an embedded table. Live rows only, except that a lifecycle table
501
+ * honours the request's ?deleted=include|only like its top-level read: RLS decides what each
502
+ * role sees. `params` is absent where no request applies (a pre-DB validation pass).
503
+ */
504
+ function embedSoftDeleteCondition(
505
+ tableName: string,
506
+ table: DrizzleTable,
507
+ options: HandlerOptions | undefined,
508
+ params: URLSearchParams | undefined,
509
+ ): SQL | null {
510
+ if (!isSoftDeleteTable(tableName, table, options)) return null;
511
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
512
+ if (!sdCol) return null;
513
+ return params && isLifecycleTable(tableName, options) ? softDeleteCondition(sdCol, params) : isNull(sdCol);
437
514
  }
438
515
 
439
516
  /**
@@ -452,17 +529,18 @@ async function executeWithSettings<T>(
452
529
  db: DrizzleDb,
453
530
  settings: Record<string, string> | undefined,
454
531
  fn: (dbOrTx: DrizzleDb) => Promise<T>,
532
+ forceTransaction = false,
455
533
  ): Promise<T> {
456
- if (settings && Object.keys(settings).length > 0) {
534
+ if (forceTransaction || (settings && Object.keys(settings).length > 0)) {
457
535
  return db.transaction(async (tx: DrizzleDb) => {
458
- const role = settings['role'];
536
+ const role = settings?.['role'];
459
537
  if (role) {
460
538
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
461
539
  throw new Error(`Invalid role name: ${role}`);
462
540
  }
463
541
  await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
464
542
  }
465
- for (const [key, value] of Object.entries(settings)) {
543
+ for (const [key, value] of Object.entries(settings ?? {})) {
466
544
  if (key !== 'role' && value !== undefined && value !== '') {
467
545
  await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
468
546
  }
@@ -587,11 +665,11 @@ async function checkRoleAuth(
587
665
 
588
666
  /**
589
667
  * Determine the Cache-Control header for a GET response.
590
- * - pgSettings configured → private (per-user data via RLS)
591
- * - No pgSettings → null (let server package set its default)
668
+ * - pgSettings or role-dependent column visibility configured → private
669
+ * - Neither configured → null (let server package set its default)
592
670
  */
593
671
  function getCacheControl(options?: HandlerOptions): string | null {
594
- if (options?.pgSettings) {
672
+ if (options?.pgSettings || Object.keys(options?.hiddenColumnsExceptRoles ?? {}).length) {
595
673
  return options?.cache?.private ?? 'private, no-store';
596
674
  }
597
675
  return null;
@@ -637,7 +715,7 @@ function stripProtectedFields(
637
715
  tableName: string,
638
716
  options?: HandlerOptions,
639
717
  ): unknown {
640
- const fields = options?.protectedFields?.[tableName];
718
+ const fields = ownEntry(options?.protectedFields, tableName);
641
719
  if (!fields || fields.length === 0) return body;
642
720
  if (Array.isArray(body)) return body.map(row => stripProtectedFields(row, tableName, options));
643
721
  if (body === null || typeof body !== 'object') {
@@ -655,7 +733,7 @@ function stripHiddenColumns(
655
733
  tableName: string,
656
734
  options?: HandlerOptions,
657
735
  ): unknown {
658
- const columns = options?.hiddenColumns?.[tableName];
736
+ const columns = ownEntry(options?.hiddenColumns, tableName);
659
737
  if (!columns || columns.length === 0) return data;
660
738
 
661
739
  const strip = (row: Record<string, unknown>): Record<string, unknown> => {
@@ -835,6 +913,7 @@ function expandTableKeyedOptions(
835
913
  ...options,
836
914
  ...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
837
915
  ...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
916
+ ...(options.hiddenColumnsExceptRoles ? { hiddenColumnsExceptRoles: expandMap(options.hiddenColumnsExceptRoles) } : {}),
838
917
  ...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
839
918
  ...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
840
919
  ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
@@ -846,6 +925,7 @@ function expandTableKeyedOptions(
846
925
  ...options.softDelete,
847
926
  ...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
848
927
  ...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
928
+ ...(options.softDelete.lifecycle ? { lifecycle: expandList(options.softDelete.lifecycle) } : {}),
849
929
  },
850
930
  }
851
931
  : {}),
@@ -865,6 +945,7 @@ function declaredTables(options: HandlerOptions): Set<string> {
865
945
  ...Object.keys(options.relations ?? {}),
866
946
  ...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
867
947
  ...(options.softDelete?.tables ?? []),
948
+ ...(options.softDelete?.lifecycle ?? []),
868
949
  ];
869
950
  return new Set(names.map(toSnakeCase));
870
951
  }
@@ -917,6 +998,11 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
917
998
  const colName = softDelete?.column || 'deletedAt';
918
999
  for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
919
1000
  const table = schema[tableName];
1001
+ if (isLifecycleTable(tableName, options)) {
1002
+ throw new Error(
1003
+ `[everystack/api] softDelete.restore.${tableName}: table "${tableName}" is a lifecycle table. It restores through POST {basePath}/${tableName}/restore, on its delete rule; remove the entry.`,
1004
+ );
1005
+ }
920
1006
  const isSoftDelete = table
921
1007
  ? isSoftDeleteTable(tableName, table, options)
922
1008
  : (softDelete?.tables?.includes(tableName) ?? false);
@@ -928,7 +1014,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
928
1014
  const sameColumn = (field: string): boolean => (table
929
1015
  ? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
930
1016
  : toSnakeCase(field) === toSnakeCase(colName));
931
- if (roles.length > 0 && (options?.protectedFields?.[tableName] ?? []).some(sameColumn)) {
1017
+ if (roles.length > 0 && (ownEntry(options?.protectedFields, tableName) ?? []).some(sameColumn)) {
932
1018
  throw new Error(
933
1019
  `[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
1020
  );
@@ -942,7 +1028,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
942
1028
  * non-string role claim is not a member.
943
1029
  */
944
1030
  function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
945
- const roles = options?.softDelete?.restore?.[tableName];
1031
+ const roles = ownEntry(options?.softDelete?.restore, tableName);
946
1032
  if (!roles?.length || !user) return false;
947
1033
  const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
948
1034
  return role !== '' && roles.includes(role);
@@ -984,8 +1070,148 @@ function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undef
984
1070
  }
985
1071
  }
986
1072
 
1073
+ /**
1074
+ * With `drizzleRelations`, the JOIN path hands each relation to drizzle's `with` by its handler
1075
+ * name, so drizzle must declare a relation of that name on that table. A mismatch used to fail
1076
+ * inside drizzle at request time; it is refused here instead. A hasOne never goes to `with` (it is
1077
+ * resolved by a windowed query), and a table drizzle does not serve relationally takes the batch
1078
+ * path, so neither is checked.
1079
+ */
1080
+ function validateDrizzleRelationNames(db: DrizzleDb, options: HandlerOptions | undefined): void {
1081
+ if (!options?.drizzleRelations) return;
1082
+ const relational = (db as { _?: { schema?: Record<string, { relations?: Record<string, unknown> }> } })._?.schema;
1083
+ if (!relational) return;
1084
+ for (const [tableName, tableRelations] of Object.entries(options.relations ?? {})) {
1085
+ if (!(db as any).query?.[tableName]) continue;
1086
+ const declared = Object.keys(ownEntry(relational, tableName)?.relations ?? {});
1087
+ for (const [name, relation] of Object.entries(tableRelations)) {
1088
+ if (relation.hasOne || declared.includes(name)) continue;
1089
+ throw new Error(
1090
+ `[everystack/api] relations.${tableName}.${name}: drizzleRelations declares no relation "${name}" on "${tableName}" `
1091
+ + `(it has: ${declared.length ? [...declared].sort().join(', ') : 'none'}). The JOIN path would fail at request time. `
1092
+ + 'Declare it in your drizzle relations, rename the handler relation, or leave out drizzleRelations.',
1093
+ );
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ /**
1099
+ * Refuse a relation orderBy, limit, where or hasOne the read paths cannot apply. Only a relation
1100
+ * that declares one is checked. Its target must be in the schema when its source is: derived
1101
+ * configs name every table while a handler serves a few, and an unserved source never embeds.
1102
+ */
1103
+ function validateRelationDisplay(schema: Schema, options: HandlerOptions | undefined): void {
1104
+ for (const [tableName, tableRelations] of Object.entries(options?.relations ?? {})) {
1105
+ for (const [name, relation] of Object.entries(tableRelations)) {
1106
+ const { orderBy, limit, where, hasOne } = relation;
1107
+ const prefix = `[everystack/api] relations.${tableName}.${name}:`;
1108
+ if (hasOne !== undefined) {
1109
+ if (hasOne !== true) throw new Error(`${prefix} hasOne must be true`);
1110
+ if (relation.many) throw new Error(`${prefix} a relation is many or hasOne, not both`);
1111
+ if (limit !== undefined) throw new Error(`${prefix} hasOne returns one row: limit is not allowed`);
1112
+ } else if (orderBy === undefined && limit === undefined && where === undefined) {
1113
+ continue;
1114
+ }
1115
+ if (!relation.many && !hasOne) {
1116
+ throw new Error(`${prefix} orderBy and where apply to a many or hasOne relation only, and limit to a many relation only`);
1117
+ }
1118
+ const target = schema[relation.table];
1119
+ if (!target) {
1120
+ if (!schema[tableName]) continue;
1121
+ throw new Error(`${prefix} target table "${relation.table}" is not in the schema`);
1122
+ }
1123
+ // The key is the last tie-breaker. Without one, two runs could choose different rows.
1124
+ if (hasOne && primaryKeyColumns(target).length === 0) {
1125
+ throw new Error(`${prefix} target table "${relation.table}" has no primary key to break ties`);
1126
+ }
1127
+ const resolves = (column: unknown): boolean => typeof column === 'string' && columnPropertyName(target, column) !== undefined;
1128
+ if (orderBy !== undefined) {
1129
+ if (!Array.isArray(orderBy)) throw new Error(`${prefix} orderBy must be an array of { column, order }`);
1130
+ for (const term of orderBy) {
1131
+ if (!resolves(term?.column)) throw new Error(`${prefix} orderBy column "${term?.column}" is not a column of table "${relation.table}"`);
1132
+ if (term.order !== 'asc' && term.order !== 'desc') throw new Error(`${prefix} orderBy order for "${term.column}" must be 'asc' or 'desc'`);
1133
+ }
1134
+ }
1135
+ if (limit !== undefined && !(typeof limit === 'number' && Number.isInteger(limit) && limit > 0)) {
1136
+ throw new Error(`${prefix} limit must be a positive integer`);
1137
+ }
1138
+ if (where !== undefined) {
1139
+ if (where === null || typeof where !== 'object' || Array.isArray(where)) throw new Error(`${prefix} where must be an object of column → value`);
1140
+ for (const [column, value] of Object.entries(where)) {
1141
+ if (!resolves(column)) throw new Error(`${prefix} where column "${column}" is not a column of table "${relation.table}"`);
1142
+ const valid = value === null || typeof value === 'string' || typeof value === 'boolean'
1143
+ || (typeof value === 'number' && Number.isFinite(value));
1144
+ if (!valid) throw new Error(`${prefix} where value for "${column}" must be a string, a finite number, a boolean or null`);
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ /** The target's primary-key columns, in declaration order. Empty for a keyless or non-pg table. */
1152
+ function primaryKeyColumns(table: DrizzleTable): DrizzleColumn[] {
1153
+ if (!is(table, PgTable)) return [];
1154
+ const config = getTableConfig(table);
1155
+ const columns = Object.values(getTableColumns(table)) as DrizzleColumn[];
1156
+ const byName = new Map(columns.map(c => [c.name, c]));
1157
+ return [
1158
+ ...columns.filter(c => c.primary),
1159
+ ...config.primaryKeys.flatMap(key => key.columns.map(keyColumn => byName.get(keyColumn.name))),
1160
+ ].filter((c): c is DrizzleColumn => c !== undefined);
1161
+ }
1162
+
1163
+ /**
1164
+ * A relation's row filter and order, over the target table's own column objects (no config
1165
+ * value reaches SQL text). The filter is the soft-delete guard and the declared where. The
1166
+ * order is the declared orderBy then the primary key, or none when no orderBy, limit or hasOne
1167
+ * is declared. validateRelationDisplay has checked every name.
1168
+ */
1169
+ function relationDisplay(
1170
+ relation: RelationConfig,
1171
+ targetTable: DrizzleTable,
1172
+ options?: HandlerOptions,
1173
+ params?: URLSearchParams,
1174
+ ): { conditions: SQL[]; order: SQL[] } {
1175
+ const column = (name: string): DrizzleColumn => getTableColumns(targetTable)[columnPropertyName(targetTable, name)!] as DrizzleColumn;
1176
+ const conditions: SQL[] = [];
1177
+ const deletedGuard = embedSoftDeleteCondition(relation.table, targetTable, options, params);
1178
+ if (deletedGuard) conditions.push(deletedGuard);
1179
+ for (const [name, value] of Object.entries(relation.where ?? {})) {
1180
+ conditions.push(value === null ? isNull(column(name)) : eq(column(name), value));
1181
+ }
1182
+ const order: SQL[] = [];
1183
+ if (relation.orderBy !== undefined || relation.limit !== undefined || relation.hasOne) {
1184
+ const named = new Set<DrizzleColumn>();
1185
+ for (const term of relation.orderBy ?? []) {
1186
+ named.add(column(term.column));
1187
+ order.push(term.order === 'desc' ? desc(column(term.column)) : asc(column(term.column)));
1188
+ }
1189
+ for (const c of primaryKeyColumns(targetTable)) {
1190
+ if (!named.has(c)) { named.add(c); order.push(asc(c)); }
1191
+ }
1192
+ }
1193
+ return { conditions, order };
1194
+ }
1195
+
987
1196
  /** Resolve hidden columns once so every read uses the same column spellings. */
988
1197
  function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): HandlerOptions | undefined {
1198
+ const rolesByTable = new Map<DrizzleTable, string[]>();
1199
+ const configuredHidden = { ...options?.hiddenColumns };
1200
+ for (const [tableName, roles] of Object.entries(options?.hiddenColumnsExceptRoles ?? {})) {
1201
+ const table = schema[tableName];
1202
+ if (!table || !Array.isArray(roles) || roles.some(role => typeof role !== 'string' || role === '')) {
1203
+ throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: expected a known table and an array of role names`);
1204
+ }
1205
+ const previous = rolesByTable.get(table);
1206
+ if (previous && (previous.length !== roles.length || previous.some(role => !roles.includes(role)))) {
1207
+ throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: conflicting role exceptions for one table`);
1208
+ }
1209
+ rolesByTable.set(table, roles);
1210
+ if (isLifecycleTable(tableName, options)) {
1211
+ configuredHidden[tableName] = [...new Set([...(configuredHidden[tableName] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
1212
+ }
1213
+ }
1214
+ if (options?.hiddenColumnsExceptRoles) options = { ...options, hiddenColumns: configuredHidden };
989
1215
  if (!options?.hiddenColumns) return options;
990
1216
  const byTable = new Map<DrizzleTable, Set<string>>();
991
1217
  for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
@@ -1017,6 +1243,27 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
1017
1243
  return { ...options, hiddenColumns };
1018
1244
  }
1019
1245
 
1246
+ /** Resolve one request's visibility before query validation and every response path. */
1247
+ function hiddenColumnsForUser(schema: Schema, options: HandlerOptions | undefined, user: Record<string, unknown> | null): HandlerOptions | undefined {
1248
+ if (!options?.hiddenColumnsExceptRoles || !user) return options;
1249
+ const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
1250
+ if (!role) return options;
1251
+ const hiddenColumns = Object.fromEntries(Object.entries(options.hiddenColumns ?? {}).map(([tableName, columns]) => {
1252
+ const except = ownEntry(options.hiddenColumnsExceptRoles, tableName);
1253
+ if (!isLifecycleTable(tableName, options) || !except?.includes(role)) return [tableName, columns];
1254
+ const table = schema[tableName];
1255
+ const metadata = new Set<string>();
1256
+ for (const name of ['deleted_by', 'deleted_batch', 'deleted_xid']) {
1257
+ const property = columnPropertyName(table, name);
1258
+ if (!property) continue;
1259
+ const column = getTableColumns(table)[property] as DrizzleColumn;
1260
+ for (const spelling of [property, column.name, toSnakeCase(property), toCamelCase(column.name)]) metadata.add(spelling);
1261
+ }
1262
+ return [tableName, columns.filter(column => !metadata.has(column))];
1263
+ }));
1264
+ return { ...options, hiddenColumns };
1265
+ }
1266
+
1020
1267
  /** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
1021
1268
  const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
1022
1269
  '23514': { status: 422, message: 'Value does not meet a constraint' },
@@ -1128,15 +1375,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1128
1375
  // apply. The root listing below stays one entry per table (the SQL name).
1129
1376
  const namePairs = tableNamePairs(rawSchema);
1130
1377
  const schema = expandSchema(rawSchema, namePairs);
1131
- const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
1132
- validateConstraintMessages(options);
1378
+ const configuredOptions = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
1379
+ validateConstraintMessages(configuredOptions);
1133
1380
  validateRowOwnership(schema, rawOptions);
1134
- validateSoftDeleteRestore(schema, options);
1135
- validateSanitizeColumns(schema, options);
1381
+ validateSoftDeleteRestore(schema, configuredOptions);
1382
+ validateSanitizeColumns(schema, configuredOptions);
1383
+ validateRelationDisplay(schema, configuredOptions);
1384
+ validateDrizzleRelationNames(db, configuredOptions);
1136
1385
  const tableNames = new Set(Object.keys(schema));
1137
1386
  const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
1138
1387
 
1139
1388
  return async (request: Request): Promise<Response> => {
1389
+ let options = configuredOptions;
1140
1390
  const url = new URL(request.url);
1141
1391
  let pathname = url.pathname;
1142
1392
  if (options?.basePath) {
@@ -1207,8 +1457,9 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1207
1457
  // Root path — table listing, always public. One entry per table (SQL name),
1208
1458
  // regardless of how many spellings route to it.
1209
1459
  if (pathSegments.length === 0) {
1210
- const tables = options?.exposedTables
1211
- ? canonicalTables.filter(t => options.exposedTables!.includes(t))
1460
+ const exposedTables = options?.exposedTables;
1461
+ const tables = exposedTables
1462
+ ? canonicalTables.filter(t => exposedTables.includes(t))
1212
1463
  : canonicalTables;
1213
1464
  return json({ tables }, 200);
1214
1465
  }
@@ -1263,6 +1514,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1263
1514
  return json({ message: 'Authentication required: no auth token provided' }, 401);
1264
1515
  }
1265
1516
 
1517
+ options = hiddenColumnsForUser(schema, options, user);
1266
1518
  const fn = isObjectForm ? entry.fn : entry;
1267
1519
  const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
1268
1520
  // Request-scoped context for the trace-id spine — RPC handlers receive it
@@ -1411,8 +1663,24 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1411
1663
  }
1412
1664
  }
1413
1665
 
1666
+ options = hiddenColumnsForUser(schema, options, user);
1414
1667
  const table = schema[tableName];
1415
1668
 
1669
+ // POST {table}/restore: a lifecycle table's restore. Before the generic routing, which would
1670
+ // read it as POST {table} (an insert). Any other table has no restore endpoint.
1671
+ if (method === 'POST' && pathSegments.length === 2 && pathSegments[1] === 'restore') {
1672
+ if (!isLifecycleTable(tableName, options)) {
1673
+ return sanitizeError(`Table '${tableName}' has no restore endpoint`, 404, options);
1674
+ }
1675
+ const response = await handleRestore(db, table, tableName, request, options, user, client, requestId);
1676
+ await emitAudit(options?.audit, {
1677
+ kind: 'rest', name: tableName, user, client,
1678
+ result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
1679
+ requestId, operation: 'update', columns: ['id'],
1680
+ });
1681
+ return response;
1682
+ }
1683
+
1416
1684
  // Forensic audit of the non-GET table surface (writes). Capture the query, not
1417
1685
  // the response: the operation, the WHERE predicate, and the body's column NAMES
1418
1686
  // (never values — values can be PII). Unresolved embed policies also record
@@ -1453,15 +1721,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1453
1721
 
1454
1722
  // A part with parentheses is an aggregate or a declared embed. Anything
1455
1723
  // else cannot be honored, so refuse it rather than drop it from the rows.
1724
+ // An aggregate inside an embed cannot be honored either, nor can a nested
1725
+ // embed on the JOIN path: its `with` clause is one level deep.
1456
1726
  const tableRelations = ownEntry(options?.relations, tableName);
1457
1727
  if (!queryError) {
1728
+ const joinPath = relationalQueryAvailable(routedDb, tableName, options);
1458
1729
  for (const selectParam of url.searchParams.getAll('select')) {
1459
- const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
1730
+ const { undeclared, embedAggregate, nestedEmbed } = parseSelectParam(selectParam, tableRelations, options?.relations);
1460
1731
  if (undeclared !== undefined) {
1461
1732
  queryError = undeclaredSelectPartMessage(undeclared);
1462
1733
  queryErrorEchoesInput = true;
1463
1734
  break;
1464
1735
  }
1736
+ if (embedAggregate || (nestedEmbed && joinPath)) {
1737
+ queryError = 'Invalid query';
1738
+ break;
1739
+ }
1465
1740
  }
1466
1741
  }
1467
1742
 
@@ -1498,6 +1773,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1498
1773
  // 2xx; a throw (a failed commit) skips it, since the write did not happen.
1499
1774
  // A retry wrapper around db.transaction must reset `deferred` per attempt,
1500
1775
  // or the hooks of an aborted attempt would run too.
1776
+ const destroyHook = ownEntry(options?.hooks, tableName);
1777
+ const destroyTransaction = method === 'DELETE'
1778
+ && !isLifecycleTable(tableName, options)
1779
+ && !(isSoftDeleteTable(tableName, table, options) && getSoftDeleteColumn(tableName, table, options))
1780
+ && Boolean(destroyHook?.beforeDestroy || destroyHook?.afterDestroy);
1501
1781
  const deferred: Deferred[] = [];
1502
1782
  let response: Response;
1503
1783
  try {
@@ -1521,7 +1801,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1521
1801
  }
1522
1802
 
1523
1803
  return json({ message: `Method ${method} not allowed` }, 405);
1524
- });
1804
+ }, destroyTransaction);
1525
1805
  } finally {
1526
1806
  // emitFault never throws, so this cannot replace the error in flight.
1527
1807
  for (const item of deferred) if (item.always) await item.run();
@@ -1551,7 +1831,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1551
1831
  if (isWrite) {
1552
1832
  await emitAudit(options?.audit, {
1553
1833
  kind: 'rest', name: tableName, user, client,
1554
- result: mapped || permissionDenied ? 'deny' : 'error',
1834
+ result: mapped || permissionDenied || (error instanceof HandlerError && error.status < 500) ? 'deny' : 'error',
1555
1835
  requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
1556
1836
  });
1557
1837
  }
@@ -1568,6 +1848,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1568
1848
  ...describeError(error),
1569
1849
  });
1570
1850
  }
1851
+ if (error instanceof HandlerError && error.status < 500) return json({ message: error.message }, error.status);
1571
1852
  if (unavailable) return serviceUnavailableResponse();
1572
1853
  throw error;
1573
1854
  }
@@ -1583,12 +1864,21 @@ function columnPropertyName(table: DrizzleTable | undefined, name: string): stri
1583
1864
  ?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
1584
1865
  }
1585
1866
 
1867
+ /**
1868
+ * A read with embeds takes the JOIN path when this holds. The pre-DB refusal of
1869
+ * a nested embed and handleGet both ask it, so the two cannot disagree.
1870
+ */
1871
+ function relationalQueryAvailable(db: DrizzleDb, tableName: string, options?: HandlerOptions): boolean {
1872
+ return Boolean(ownEntry(options?.relations, tableName) && options?.drizzleRelations && (db as any).query?.[tableName]);
1873
+ }
1874
+
1586
1875
  /** Build each relation's projection before Drizzle reads its values. */
1587
1876
  function buildWithClause(
1588
1877
  embeds: EmbedRequest[],
1589
1878
  tableRelations: Record<string, RelationConfig>,
1590
1879
  schema: Schema,
1591
1880
  options?: HandlerOptions,
1881
+ params?: URLSearchParams,
1592
1882
  ): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
1593
1883
  if (embeds.length === 0) return undefined;
1594
1884
 
@@ -1600,7 +1890,7 @@ function buildWithClause(
1600
1890
  if (!relation) continue;
1601
1891
  hasEmbedded = true;
1602
1892
  const table = schema[relation.table];
1603
- const names = new Set(options?.hiddenColumns?.[relation.table] ?? []);
1893
+ const names = new Set(ownEntry(options?.hiddenColumns, relation.table) ?? []);
1604
1894
  const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
1605
1895
  const config: Record<string, any> = {};
1606
1896
  if (embed.columns !== null) {
@@ -1614,6 +1904,13 @@ function buildWithClause(
1614
1904
  } else if (hidden.size > 0) {
1615
1905
  config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
1616
1906
  }
1907
+ // Drizzle maps the target's columns to the join alias. The filter runs before the limit.
1908
+ if (table) {
1909
+ const { conditions, order } = relationDisplay(relation, table, options, params);
1910
+ if (conditions.length > 0) config.where = and(...conditions);
1911
+ if (order.length > 0) config.orderBy = order;
1912
+ if (relation.limit !== undefined) config.limit = relation.limit;
1913
+ }
1617
1914
  withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
1618
1915
  }
1619
1916
 
@@ -1766,7 +2063,7 @@ async function handleGet(
1766
2063
 
1767
2064
  // Soft-delete: filter deleted records unless ?deleted=include|only
1768
2065
  if (isSoftDeleteTable(tableName, table, options)) {
1769
- const sdCol = getSoftDeleteColumn(table, options);
2066
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
1770
2067
  if (sdCol) {
1771
2068
  const condition = softDeleteCondition(sdCol, params);
1772
2069
  if (condition) conditions.push(condition);
@@ -1829,10 +2126,7 @@ async function handleGet(
1829
2126
 
1830
2127
  // Detect if Drizzle relational query (JOINs) should be used
1831
2128
  const tableRelations = ownEntry(options?.relations, tableName);
1832
- const useRelationalQuery = embeds.length > 0
1833
- && tableRelations
1834
- && options?.drizzleRelations
1835
- && (db as any).query?.[tableName];
2129
+ const useRelationalQuery = embeds.length > 0 && relationalQueryAvailable(db, tableName, options);
1836
2130
 
1837
2131
  let rows: unknown[];
1838
2132
  const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
@@ -1847,10 +2141,20 @@ async function handleGet(
1847
2141
  offset: offsetVal,
1848
2142
  };
1849
2143
 
2144
+ // A hasOne never enters `with`: Drizzle's one() cannot order, so it cannot choose the row.
2145
+ // It is attached below from one windowed query, keyed on the parent's `from` column.
2146
+ const oneEmbeds = embeds.filter(embed => ownEntry(tableRelations, embed.name)?.hasOne);
1850
2147
  const colSelection = buildColumnsSelection(columns);
1851
- if (colSelection) queryOptions.columns = colSelection;
2148
+ const addedKeys: string[] = [];
2149
+ if (colSelection) {
2150
+ for (const embed of oneEmbeds) {
2151
+ const from = tableRelations![embed.name].from;
2152
+ if (!colSelection[from]) { colSelection[from] = true; addedKeys.push(from); }
2153
+ }
2154
+ queryOptions.columns = colSelection;
2155
+ }
1852
2156
 
1853
- const withClause = buildWithClause(embeds, tableRelations!, schema, options);
2157
+ const withClause = buildWithClause(embeds.filter(embed => !oneEmbeds.includes(embed)), tableRelations!, schema, options, params);
1854
2158
  if (withClause === null || withClause === 'unresolvedHiddenColumns') {
1855
2159
  return json({ message: 'Invalid query' }, 400);
1856
2160
  }
@@ -1865,6 +2169,10 @@ async function handleGet(
1865
2169
  if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
1866
2170
 
1867
2171
  rows = await (db as any).query[tableName].findMany(queryOptions);
2172
+ if (oneEmbeds.length > 0 && rows.length > 0) {
2173
+ rows = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], oneEmbeds, options!, params);
2174
+ for (const row of rows as Record<string, unknown>[]) for (const key of addedKeys) delete row[key];
2175
+ }
1868
2176
  } else {
1869
2177
  // Batch path — standard select + separate relation queries
1870
2178
  const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
@@ -1910,11 +2218,13 @@ async function handleGet(
1910
2218
  // Resolve relation embeds (batch path only — JOIN path already has nested data)
1911
2219
  let responseData: unknown[] = rows as unknown[];
1912
2220
  if (!useRelationalQuery && embeds.length > 0 && rowCount > 0) {
1913
- responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options!);
2221
+ responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options!, params);
1914
2222
  }
1915
2223
 
1916
2224
  // Apply column selection (batch path only — JOIN path handles via queryOptions.columns)
1917
2225
  if (!useRelationalQuery && columns) {
2226
+ // An embed that did not resolve was skipped above, and its name may be a real column.
2227
+ const resolvedEmbeds = embeds.filter(embed => resolveEmbedTarget(schema, tableRelations, embed.name));
1918
2228
  responseData = (responseData as Record<string, unknown>[]).map(row => {
1919
2229
  const filtered: Record<string, unknown> = {};
1920
2230
  for (const col of columns) {
@@ -1922,7 +2232,7 @@ async function handleGet(
1922
2232
  filtered[col] = row[col];
1923
2233
  }
1924
2234
  }
1925
- for (const embed of embeds) {
2235
+ for (const embed of resolvedEmbeds) {
1926
2236
  if (embed.name in row) {
1927
2237
  filtered[embed.name] = row[embed.name];
1928
2238
  }
@@ -1983,7 +2293,7 @@ function parseSelectParam(
1983
2293
  selectParam: string | null,
1984
2294
  tableRelations?: Record<string, RelationConfig>,
1985
2295
  allRelations?: Record<string, Record<string, RelationConfig>>,
1986
- ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
2296
+ ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string; embedAggregate?: true; nestedEmbed?: true } {
1987
2297
  if (!selectParam) {
1988
2298
  return { columns: null, embeds: [], aggregates: [] };
1989
2299
  }
@@ -1995,6 +2305,11 @@ function parseSelectParam(
1995
2305
  // The first part with parentheses that is neither an aggregate nor a declared
1996
2306
  // embed, at any depth. The caller refuses the select; it is never a column.
1997
2307
  let undeclared: string | undefined;
2308
+ // Aggregates exist at the top level only. One inside an embed, at any depth,
2309
+ // cannot be honored, so the caller refuses the select.
2310
+ let embedAggregate = false;
2311
+ // An embed inside an embed, at any depth. The JOIN path cannot honour one yet.
2312
+ let nestedEmbed = false;
1998
2313
 
1999
2314
  // Parse: split on commas, but respect parentheses
2000
2315
  const parts = splitSelectParam(selectParam);
@@ -2019,6 +2334,8 @@ function parseSelectParam(
2019
2334
  const targetRelations = ownEntry(allRelations, targetTableName);
2020
2335
  const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
2021
2336
  undeclared ??= inner.undeclared;
2337
+ if (inner.aggregates.length > 0 || inner.embedAggregate) embedAggregate = true;
2338
+ if (inner.embeds.length > 0) nestedEmbed = true;
2022
2339
  embeds.push({
2023
2340
  name,
2024
2341
  columns: inner.columns,
@@ -2038,6 +2355,8 @@ function parseSelectParam(
2038
2355
  embeds,
2039
2356
  aggregates,
2040
2357
  ...(undeclared === undefined ? {} : { undeclared }),
2358
+ ...(embedAggregate ? { embedAggregate: true as const } : {}),
2359
+ ...(nestedEmbed ? { nestedEmbed: true as const } : {}),
2041
2360
  };
2042
2361
  }
2043
2362
 
@@ -2172,6 +2491,47 @@ function requestUsesHiddenColumn(
2172
2491
  return false;
2173
2492
  }
2174
2493
 
2494
+ /** The relation an embed names, when its target table and key exist. resolveEmbeds skips any other. */
2495
+ function resolveEmbedTarget(
2496
+ schema: Schema,
2497
+ tableRelations: Record<string, RelationConfig> | undefined,
2498
+ name: string,
2499
+ ): { relationConfig: RelationConfig; targetTable: DrizzleTable; targetColumn: DrizzleColumn } | null {
2500
+ const relationConfig = ownEntry(tableRelations, name);
2501
+ if (!relationConfig) return null;
2502
+ const targetTable = schema[relationConfig.table];
2503
+ if (!targetTable) return null;
2504
+ const targetColumn = getColumn(targetTable, relationConfig.to);
2505
+ return targetColumn ? { relationConfig, targetTable, targetColumn } : null;
2506
+ }
2507
+
2508
+ /** The per-parent rank a limited embed is cut on. Dropped from every row. */
2509
+ const EMBED_RANK = '__everystack_embed_rank';
2510
+
2511
+ /**
2512
+ * The first `cap` target rows per parent key, ranked after the filter in `order`. One windowed
2513
+ * query on the request's own connection. Ordered by rank, so each parent's rows stay in order
2514
+ * through the grouping that follows.
2515
+ */
2516
+ async function rankedEmbedRows(
2517
+ db: DrizzleDb,
2518
+ targetTable: DrizzleTable,
2519
+ targetColumn: DrizzleColumn,
2520
+ where: SQL | undefined,
2521
+ order: SQL[],
2522
+ cap: number,
2523
+ ): Promise<Record<string, unknown>[]> {
2524
+ const orderClause = order.length > 0 ? sql` order by ${sql.join(order, sql`, `)}` : sql``;
2525
+ const ranked = db.$with('embed_ranked').as(db.select({
2526
+ ...getTableColumns(targetTable),
2527
+ [EMBED_RANK]: sql<number>`row_number() over (partition by ${targetColumn}${orderClause})`.as(EMBED_RANK),
2528
+ }).from(targetTable).where(where));
2529
+ return (await db.with(ranked).select().from(ranked)
2530
+ .where(sql`${ranked[EMBED_RANK]} <= ${cap}`)
2531
+ .orderBy(ranked[EMBED_RANK]))
2532
+ .map(({ [EMBED_RANK]: _rank, ...row }: Record<string, unknown>) => row);
2533
+ }
2534
+
2175
2535
  /**
2176
2536
  * Resolve relation embeds by fetching related rows and nesting them.
2177
2537
  */
@@ -2182,6 +2542,7 @@ async function resolveEmbeds(
2182
2542
  rows: Record<string, unknown>[],
2183
2543
  embeds: EmbedRequest[],
2184
2544
  options: HandlerOptions,
2545
+ params?: URLSearchParams,
2185
2546
  ): Promise<Record<string, unknown>[]> {
2186
2547
  const tableRelations = ownEntry(options.relations, tableName);
2187
2548
  if (!tableRelations) return rows;
@@ -2190,21 +2551,50 @@ async function resolveEmbeds(
2190
2551
  const result = rows.map(r => ({ ...r }));
2191
2552
 
2192
2553
  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;
2554
+ const target = resolveEmbedTarget(schema, tableRelations, embed.name);
2555
+ if (!target) continue;
2556
+ const { relationConfig, targetTable, targetColumn } = target;
2557
+
2558
+ // Rows are keyed by schema property, so pick by property name, as
2559
+ // buildWithClause does. A nested embed is kept only when it resolves: an
2560
+ // unresolved one is skipped below, and its name may be a real column.
2561
+ const nestedRelations = ownEntry(options.relations, relationConfig.table);
2562
+ const picked = embed.columns && [
2563
+ ...embed.columns.map(name => columnPropertyName(targetTable, name)).filter((name): name is string => name !== undefined),
2564
+ ...embed.embeds.filter(nested => resolveEmbedTarget(schema, nestedRelations, nested.name)).map(nested => nested.name),
2565
+ ];
2566
+
2567
+ // The soft-delete guard for the target table
2568
+ const deletedGuard = embedSoftDeleteCondition(relationConfig.table, targetTable, options, params);
2569
+
2570
+ if (relationConfig.hasOne) {
2571
+ // One row per parent: source.from is the parent key, target.to the child FK, as for many.
2572
+ const sourceValues = result.map(r => r[relationConfig.from]).filter(v => v != null);
2573
+ if (sourceValues.length === 0) {
2574
+ for (const row of result) row[embed.name] = null;
2575
+ continue;
2576
+ }
2198
2577
 
2199
- const targetColumn = getColumn(targetTable, relationConfig.to);
2200
- if (!targetColumn) continue;
2578
+ // The database chooses: the declared where and the soft-delete guard first, then the
2579
+ // first row by the declared order with the key last. Never a fetch of every child.
2580
+ const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
2581
+ let relatedRows = await rankedEmbedRows(
2582
+ db, targetTable, targetColumn, and(inArray(targetColumn, sourceValues as string[]), ...conditions), order, 1,
2583
+ );
2201
2584
 
2202
- // Build soft-delete filter for the target table
2203
- const targetSdCol = isSoftDeleteTable(relationConfig.table, targetTable, options)
2204
- ? getSoftDeleteColumn(targetTable, options)
2205
- : null;
2585
+ if (embed.embeds.length > 0) {
2586
+ relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
2587
+ }
2206
2588
 
2207
- if (relationConfig.many) {
2589
+ // Match on the key, then strip: the key may itself be a hidden column.
2590
+ const index = new Map<unknown, Record<string, unknown>>();
2591
+ for (const r of relatedRows) index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
2592
+ for (const row of result) {
2593
+ const sourceVal = row[relationConfig.from];
2594
+ const related = sourceVal == null ? null : index.get(sourceVal) ?? null;
2595
+ row[embed.name] = related && picked ? pickColumns(related, picked) : related;
2596
+ }
2597
+ } else if (relationConfig.many) {
2208
2598
  // One-to-many: source.from is the PK, target.to is the FK
2209
2599
  // Collect all source PKs
2210
2600
  const sourceValues = result
@@ -2220,27 +2610,32 @@ async function resolveEmbeds(
2220
2610
  }
2221
2611
 
2222
2612
  // Fetch all related rows in one query
2223
- const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[])];
2224
- if (targetSdCol) embedConditions.push(isNull(targetSdCol));
2225
-
2226
- let relatedRows: Record<string, unknown>[] = await db
2227
- .select()
2228
- .from(targetTable)
2229
- .where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
2230
- relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
2613
+ const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
2614
+ const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[]), ...conditions];
2615
+ const embedWhere = embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions);
2616
+
2617
+ let relatedRows: Record<string, unknown>[];
2618
+ if (relationConfig.limit !== undefined) {
2619
+ // Rank each parent's rows after the filter, then keep the first `limit` per parent.
2620
+ relatedRows = await rankedEmbedRows(db, targetTable, targetColumn, embedWhere, order, relationConfig.limit);
2621
+ } else {
2622
+ const query = db.select().from(targetTable).where(embedWhere);
2623
+ relatedRows = await (order.length > 0 ? query.orderBy(...order) : query);
2624
+ }
2231
2625
 
2232
2626
  // Recursively resolve nested embeds
2233
2627
  if (embed.embeds.length > 0) {
2234
- relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
2628
+ relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
2235
2629
  }
2236
2630
 
2237
- // Group by FK value and assign to each source row
2631
+ // Group by FK value and assign to each source row. Match on the key, then strip: the key
2632
+ // may itself be a hidden column.
2238
2633
  for (const row of result) {
2239
2634
  const sourceVal = row[relationConfig.from];
2240
- 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;
2635
+ const matched = relatedRows
2636
+ .filter(r => r[relationConfig.to] === sourceVal)
2637
+ .map(r => visibleRow(r, relationConfig.table, options));
2638
+ row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
2244
2639
  }
2245
2640
  } else {
2246
2641
  // Many-to-one: source.from is the FK, target.to is the PK
@@ -2259,23 +2654,23 @@ async function resolveEmbeds(
2259
2654
 
2260
2655
  // Fetch all referenced rows in one query (batch, no N+1)
2261
2656
  const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
2262
- if (targetSdCol) embedConditions.push(isNull(targetSdCol));
2657
+ if (deletedGuard) embedConditions.push(deletedGuard);
2263
2658
 
2264
2659
  let relatedRows: Record<string, unknown>[] = await db
2265
2660
  .select()
2266
2661
  .from(targetTable)
2267
2662
  .where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
2268
- relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
2269
2663
 
2270
2664
  // Recursively resolve nested embeds
2271
2665
  if (embed.embeds.length > 0) {
2272
- relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
2666
+ relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
2273
2667
  }
2274
2668
 
2275
- // Index by target key for O(1) lookup
2669
+ // Index by target key for O(1) lookup. Match on the key, then strip: the key may itself
2670
+ // be a hidden column.
2276
2671
  const index = new Map<unknown, Record<string, unknown>>();
2277
2672
  for (const r of relatedRows) {
2278
- index.set(r[relationConfig.to], r);
2673
+ index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
2279
2674
  }
2280
2675
 
2281
2676
  // Assign to each source row
@@ -2285,7 +2680,7 @@ async function resolveEmbeds(
2285
2680
  row[embed.name] = null;
2286
2681
  } else {
2287
2682
  const related = index.get(fkVal) || null;
2288
- row[embed.name] = related && embed.columns ? pickColumns(related, embed.columns) : related;
2683
+ row[embed.name] = related && picked ? pickColumns(related, picked) : related;
2289
2684
  }
2290
2685
  }
2291
2686
  }
@@ -2294,6 +2689,11 @@ async function resolveEmbeds(
2294
2689
  return result;
2295
2690
  }
2296
2691
 
2692
+ /** One embedded row without its table's hidden columns. */
2693
+ function visibleRow(row: Record<string, unknown>, tableName: string, options: HandlerOptions): Record<string, unknown> {
2694
+ return stripHiddenColumns(row, tableName, options) as Record<string, unknown>;
2695
+ }
2696
+
2297
2697
  function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
2298
2698
  const result: Record<string, unknown> = {};
2299
2699
  for (const col of columns) {
@@ -2371,10 +2771,10 @@ function sanitizeWriteBody(
2371
2771
  at: { requestId?: string; name: string; method: string },
2372
2772
  deferred: Deferred[],
2373
2773
  ): { body: unknown; changed: string[] } | { refusal: Response } {
2374
- const columns = options?.sanitizeColumns?.[tableName];
2774
+ const columns = ownEntry(options?.sanitizeColumns, tableName);
2375
2775
  if (!columns?.length) return { body, changed: [] };
2376
2776
  const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
2377
- const hidden = new Set((options?.hiddenColumns?.[tableName] ?? []).map(name => columnPropertyName(table, name)));
2777
+ const hidden = new Set((ownEntry(options?.hiddenColumns, tableName) ?? []).map(name => columnPropertyName(table, name)));
2378
2778
  const refuse = (message: string, column: string): { refusal: Response } => {
2379
2779
  const fault: HandlerFault = {
2380
2780
  ...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
@@ -2456,7 +2856,7 @@ async function handlePost(
2456
2856
  throw err;
2457
2857
  }
2458
2858
 
2459
- const hook = options?.hooks?.[tableName];
2859
+ const hook = ownEntry(options?.hooks, tableName);
2460
2860
 
2461
2861
  if (hook?.beforeCreate) {
2462
2862
  try {
@@ -2509,6 +2909,12 @@ async function handlePatch(
2509
2909
  const scope = ownershipScope(table, tableName, options, user);
2510
2910
  if ('refusal' in scope) return scope.refusal;
2511
2911
 
2912
+ // A lifecycle row is restored by its own function, on its delete rule, never by a PATCH.
2913
+ const requestedDeleted = params.get('deleted');
2914
+ if (isLifecycleTable(tableName, options) && (requestedDeleted === 'include' || requestedDeleted === 'only')) {
2915
+ return json({ message: `PATCH does not accept ?deleted= on ${tableName}: restore a deleted row with POST ${restorePath(tableName, options)} { "id": <id> }` }, 400);
2916
+ }
2917
+
2512
2918
  let body: Record<string, unknown>;
2513
2919
  try {
2514
2920
  body = await request.json();
@@ -2533,7 +2939,7 @@ async function handlePatch(
2533
2939
 
2534
2940
  // Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
2535
2941
  // this table. Anyone else is matched to live rows, as if the param were absent.
2536
- const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(table, options) : null;
2942
+ const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(tableName, table, options) : null;
2537
2943
  const restoring = sdCol !== null && mayRestore(tableName, options, user);
2538
2944
  const deletedMode = params.get('deleted');
2539
2945
  if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
@@ -2550,7 +2956,7 @@ async function handlePatch(
2550
2956
 
2551
2957
  // With the ownership backstop lifted, an unfiltered PATCH would write every row the
2552
2958
  // role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
2553
- const ownership = options?.rowOwnership?.[tableName];
2959
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2554
2960
  if (user && options && ownership && isOwnershipExempt(ownership, user, options)
2555
2961
  && buildsNoFilter(table, params)) {
2556
2962
  return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
@@ -2574,7 +2980,7 @@ async function handlePatch(
2574
2980
  return sanitizeError(filterResult.error, 400, options);
2575
2981
  }
2576
2982
 
2577
- const hook = options?.hooks?.[tableName];
2983
+ const hook = ownEntry(options?.hooks, tableName);
2578
2984
 
2579
2985
  if (hook?.beforeUpdate) {
2580
2986
  try {
@@ -2666,7 +3072,7 @@ function unbackedExemptionRefusal(
2666
3072
  settings: Record<string, string> | undefined,
2667
3073
  ): string | null {
2668
3074
  if (method !== 'PATCH' && method !== 'DELETE') return null;
2669
- const ownership = options?.rowOwnership?.[tableName];
3075
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2670
3076
  if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
2671
3077
  if (settings?.['role']) return null;
2672
3078
  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 +3120,7 @@ function ownershipScope(
2714
3120
  options: HandlerOptions | undefined,
2715
3121
  user: Record<string, unknown> | null,
2716
3122
  ): OwnershipScope {
2717
- const ownership = options?.rowOwnership?.[tableName];
3123
+ const ownership = ownEntry(options?.rowOwnership, tableName);
2718
3124
  if (!options || !ownership) return { condition: null };
2719
3125
  if (!user) {
2720
3126
  return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
@@ -2763,7 +3169,7 @@ async function handleDelete(
2763
3169
  return sanitizeError(filterResult.error, 400, options);
2764
3170
  }
2765
3171
 
2766
- const hook = options?.hooks?.[tableName];
3172
+ const hook = ownEntry(options?.hooks, tableName);
2767
3173
 
2768
3174
  if (hook?.beforeDelete) {
2769
3175
  try {
@@ -2778,9 +3184,20 @@ async function handleDelete(
2778
3184
  // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
2779
3185
  if (scope.condition) conditions.push(scope.condition);
2780
3186
 
3187
+ // Lifecycle: a real DELETE, which the table's trigger turns into a stamp (it reports 0 rows).
3188
+ if (isLifecycleTable(tableName, options)) {
3189
+ const rows = await lifecycleDelete(db, table, tableName, conditions, options);
3190
+ if (hook?.afterDelete && rows.length > 0) {
3191
+ const { afterDelete } = hook;
3192
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3193
+ () => afterDelete.call(hook, rows, user, client));
3194
+ }
3195
+ return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3196
+ }
3197
+
2781
3198
  // Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
2782
3199
  if (isSoftDeleteTable(tableName, table, options)) {
2783
- const sdCol = getSoftDeleteColumn(table, options);
3200
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
2784
3201
  if (sdCol) {
2785
3202
  // Only soft-delete records that aren't already deleted
2786
3203
  conditions.push(isNull(sdCol));
@@ -2814,7 +3231,21 @@ async function handleDelete(
2814
3231
  query = query.where(and(...conditions));
2815
3232
  }
2816
3233
 
2817
- const rows = await query.returning();
3234
+ const rows: Record<string, unknown>[] = await query.returning();
3235
+
3236
+ // RETURNING proves DELETE authority, including policies that differ from SELECT.
3237
+ // The dispatcher keeps this statement and beforeDestroy in one caller transaction.
3238
+ const ctx: DestroyContext = { source: 'api', user, client };
3239
+ for (const row of rows) {
3240
+ if (hook?.beforeDestroy) await hook.beforeDestroy(row, ctx);
3241
+ }
3242
+ if (hook?.afterDestroy) {
3243
+ const { afterDestroy } = hook;
3244
+ for (const row of rows) {
3245
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDestroy`, method: 'DELETE' },
3246
+ () => Promise.resolve(afterDestroy.call(hook, row, ctx)));
3247
+ }
3248
+ }
2818
3249
 
2819
3250
  if (hook?.afterDelete && rows.length > 0) {
2820
3251
  const { afterDelete } = hook;
@@ -2825,6 +3256,122 @@ async function handleDelete(
2825
3256
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
2826
3257
  }
2827
3258
 
3259
+ /**
3260
+ * DELETE on a lifecycle table. The trigger stamps each row and cancels its delete, so DELETE
3261
+ * reports 0 rows and RETURNING has nothing. In one transaction: read the live rows the request
3262
+ * matches (the pre-image), DELETE with the same conditions, then drop every row still live: RLS
3263
+ * refused its delete. What is left is the pre-image of the rows this request soft-deleted.
3264
+ * Under READ COMMITTED a row another request stamps between the read and the DELETE is still
3265
+ * listed (and reaches afterDelete): a false attribution, never a leak, since the caller could read it.
3266
+ */
3267
+ async function lifecycleDelete(
3268
+ db: DrizzleDb,
3269
+ table: DrizzleTable,
3270
+ tableName: string,
3271
+ conditions: SQL[],
3272
+ options?: HandlerOptions,
3273
+ ): Promise<Record<string, unknown>[]> {
3274
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
3275
+ const keys = primaryKeyColumns(table)
3276
+ .map(column => columnPropertyName(table, column.name))
3277
+ .filter((key): key is string => key !== undefined);
3278
+ if (!sdCol || keys.length === 0) {
3279
+ throw new Error(`[everystack/api] softDelete.lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a primary key`);
3280
+ }
3281
+ const where = and(...conditions, isNull(sdCol))!;
3282
+ const keyOf = (row: Record<string, unknown>): string => JSON.stringify(keys.map(key => row[key]));
3283
+ return db.transaction(async (tx: DrizzleDb) => {
3284
+ const matched: Record<string, unknown>[] = await tx.select().from(table).where(where);
3285
+ if (matched.length === 0) return [];
3286
+ await tx.delete(table).where(where);
3287
+ const stillLive: Record<string, unknown>[] = await tx
3288
+ .select(Object.fromEntries(keys.map(key => [key, getTableColumns(table)[key]])))
3289
+ .from(table)
3290
+ .where(where);
3291
+ const live = new Set(stillLive.map(keyOf));
3292
+ return matched.filter(row => !live.has(keyOf(row)));
3293
+ });
3294
+ }
3295
+
3296
+ /**
3297
+ * POST {table}/restore { id } on a lifecycle table: `<schema>.<table>_restore(id)` inside the
3298
+ * request's pgSettings transaction, so the function sees the caller's role and claims and decides
3299
+ * on the table's delete rule. The id is bound and cast to the key's type, so an author's own
3300
+ * overload of the name is never chosen. 42501 (the function's one refusal) is 403 with its
3301
+ * message; 55000 (not READ COMMITTED) is 409; 22P02 (an id the key type cannot read) is 400.
3302
+ */
3303
+ async function handleRestore(
3304
+ db: DrizzleDb,
3305
+ table: DrizzleTable,
3306
+ tableName: string,
3307
+ request: Request,
3308
+ options: HandlerOptions | undefined,
3309
+ user: Record<string, unknown> | null,
3310
+ client: Record<string, unknown> | null,
3311
+ requestId: string | undefined,
3312
+ ): Promise<Response> {
3313
+ const body: unknown = await request.json().catch(() => undefined);
3314
+ const id = body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>).id : undefined;
3315
+ if (!(typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)))) {
3316
+ return json({ message: 'restore requires a JSON body { "id": <id> }' }, 400);
3317
+ }
3318
+ const [key] = primaryKeyColumns(table);
3319
+ const keyType = key ? restoreArgType(key) : null;
3320
+ const name = sqlNameOf(table);
3321
+ if (!keyType || name === null || primaryKeyColumns(table).length !== 1) {
3322
+ const message = `[everystack/api] softDelete.lifecycle.${tableName}: restore needs a table with a single-column primary key of a plain type`;
3323
+ await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, message });
3324
+ return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : message }, 500);
3325
+ }
3326
+ const fn = sql`${sql.identifier(getTableConfig(table).schema ?? 'public')}.${sql.identifier(`${name}_restore`)}`;
3327
+ try {
3328
+ const result = await executeWithSettings(db, options?.pgSettings?.(user, client), async (tx) => {
3329
+ const rows = await tx.execute(sql`SELECT ${fn}(CAST(${id} AS ${sql.raw(keyType)})) AS result`);
3330
+ return (rows as unknown as Array<{ result: unknown }>)[0]?.result;
3331
+ });
3332
+ return json(result, 200);
3333
+ } catch (error) {
3334
+ // Code and message from the one object that carries the code: a wrapper's message can quote the query.
3335
+ const carrier = pgErrorCarrier(error);
3336
+ const code = carrier && safeRead(carrier, 'code') as string;
3337
+ const text = carrier && safeRead(carrier, 'message');
3338
+ const message = typeof text === 'string' ? text : '';
3339
+ if (code === '42501') return sanitizeError(message || 'Permission denied', 403, options);
3340
+ if (code === '55000') return json({ message, code }, 409);
3341
+ if (code === '22P02') return json({ message: 'Invalid id', code }, 400);
3342
+ if (isServiceUnavailable(error)) return serviceUnavailableResponse();
3343
+ await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, ...describeError(error) });
3344
+ return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'Restore failed' }, 500);
3345
+ }
3346
+ }
3347
+
3348
+ /** The first object on the error's cause chain with a string `code` (drivers and wrappers nest it). */
3349
+ function pgErrorCarrier(error: unknown): object | undefined {
3350
+ let current: unknown = error;
3351
+ for (let hop = 0; hop <= MAX_CAUSE_HOPS && current !== null && typeof current === 'object'; hop++) {
3352
+ if (typeof safeRead(current, 'code') === 'string') return current;
3353
+ const cause = safeRead(current, 'cause');
3354
+ if (cause === UNREADABLE || cause === current) return undefined;
3355
+ current = cause;
3356
+ }
3357
+ return undefined;
3358
+ }
3359
+
3360
+ /**
3361
+ * The SQL type the restore id is cast to: the key column's declared type, a serial read as its
3362
+ * integer type. Null for a type text this function will not put in SQL.
3363
+ */
3364
+ function restoreArgType(column: DrizzleColumn): string | null {
3365
+ const declared = String((column as { getSQLType?: () => string }).getSQLType?.() ?? '').trim().toLowerCase();
3366
+ const type = ({ serial: 'integer', smallserial: 'smallint', bigserial: 'bigint' } as Record<string, string>)[declared] ?? declared;
3367
+ return /^[a-z][a-z0-9_ ]*(\(\d+(, ?\d+)?\))?$/.test(type) ? type : null;
3368
+ }
3369
+
3370
+ /** The restore endpoint's path, as a client calls it. */
3371
+ function restorePath(tableName: string, options?: HandlerOptions): string {
3372
+ return `${(options?.basePath ?? '').replace(/\/+$/, '')}/${tableName}/restore`;
3373
+ }
3374
+
2828
3375
  interface JsonPath {
2829
3376
  baseColumn: string;
2830
3377
  steps: Array<{ key: string; asText: boolean }>;