@everystack/api 0.4.10 → 0.4.12

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,46 @@ 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.12] - 2026-10-23
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - lifecycle delete and restore hand the batch id to the caller
12
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
13
+ - one lifecycle hook API: (rows, ctx), veto(reason), beforeStamp
14
+
15
+ ### Added
16
+
17
+ - afterStamp and afterRestore hooks hand a lifecycle batch to app code
18
+
19
+ ### Fixed
20
+
21
+ - a lifecycle DELETE stamps only the rows beforeStamp saw
22
+
23
+ ### Documentation
24
+
25
+ - afterStamp, afterRestore and the batch in the restore response
26
+ - Extending lifecycle, the phase table, the veto and failure rules
27
+
28
+ ## [0.4.11] - 2026-10-23
29
+
30
+ ### Added
31
+
32
+ - per-parent embed order, limit and where on both read paths (F10)
33
+ - hasOne one-row embed on both read paths (P2)
34
+ - createHandler refuses a handler relation drizzleRelations does not declare (P3)
35
+ - the handler deletes, hides and restores lifecycle rows through the database (H1, P1.4)
36
+ - profiles, posts, likes and follows are one lifecycle family; account delete and restore use it (P2.1, P2.2)
37
+ - lifecycle columns are hidden from callers that cannot manage the table
38
+ - beforeDestroy and afterDestroy hooks at a real removal
39
+
40
+ ### Fixed
41
+
42
+ - an unresolved relation no longer returns a same-named column
43
+ - match embedded rows on a hidden child key before stripping it (P2 review)
44
+ - a belongsTo embed matches on a hidden target key before stripping it
45
+ - a beforeDestroy HandlerError renders with its own status
46
+
7
47
  ## [0.4.10] - 2026-10-18
8
48
 
9
49
  ### BREAKING CHANGES
package/README.md CHANGED
@@ -101,6 +101,10 @@ createHandler(db, schema, {
101
101
  afterUpdate: async (rows, user, client) => {},
102
102
  beforeDelete: async (user, client) => {},
103
103
  afterDelete: async (rows, user, client) => {},
104
+ // Lifecycle tables only (see "Extending lifecycle" in docs/handler.md); veto from '@everystack/api/handler':
105
+ beforeStamp: (row, ctx) => (ctx.user ? undefined : veto('sign in to delete')), // in the transaction; veto -> 409
106
+ afterStamp: async (rows, ctx) => {}, // after commit, once per batch (ctx.batch)
107
+ afterRestore: async (rows, ctx) => {}, // after commit, ctx.batch and ctx.counts
104
108
  },
105
109
  },
106
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
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,18 @@ 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, batch }`. */
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;
95
+ /** The batch restored; null for a row deleted before batches existed. Absent from a server
96
+ * whose restore functions predate it (its next db:apply adds it). */
97
+ batch?: string | null;
86
98
  }
87
99
 
88
100
  export interface Client {
@@ -270,6 +282,26 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
270
282
  const result = await response.json();
271
283
  return { data: result, error: null };
272
284
  },
285
+
286
+ async restore(id: string | number): Promise<ClientResponse<RestoreResult>> {
287
+ const url = `${options.baseUrl}/${table}/restore`;
288
+ const headers = await buildHeaders(options);
289
+ headers['Content-Type'] = 'application/json';
290
+
291
+ const { response, retried } = await fetchWithRetry(
292
+ url,
293
+ { method: 'POST', headers, body: JSON.stringify({ id }) },
294
+ options,
295
+ );
296
+
297
+ if (retried) return retried as ClientResponse<RestoreResult>;
298
+
299
+ if (!response.ok) {
300
+ return { data: null, error: await parseError(response) };
301
+ }
302
+
303
+ return { data: (await response.json()) as RestoreResult, error: null };
304
+ },
273
305
  };
274
306
 
275
307
  return builder;
@@ -28,6 +28,19 @@ type Handler = (request: Request) => Promise<Response>;
28
28
 
29
29
  export { HandlerError } from './errors';
30
30
  import { HandlerError } from './errors';
31
+ export { veto, isVeto } from './veto';
32
+ export type { HookVeto, LifecycleContext, DestroyContext, DestroyHook, BeforeLifecycleHook, AfterLifecycleHook } from './veto';
33
+ import { isVeto, type AfterLifecycleHook, type BeforeLifecycleHook, type DestroyContext, type DestroyHook, type LifecycleContext } from './veto';
34
+
35
+ /** A before hook returned veto(reason): the request is refused with 409 and its transaction rolls back. */
36
+ class VetoRefusal extends Error {
37
+ constructor(readonly reason: string) { super(reason); }
38
+ }
39
+
40
+ /** A lifecycle before hook threw inside its transaction; carried out so the handler can answer as for any before hook. */
41
+ class BeforeHookThrew extends Error {
42
+ constructor(readonly thrown: unknown) { super('before hook threw'); }
43
+ }
31
44
 
32
45
  /**
33
46
  * A 5xx system fault surfaced to the observability sink. Distinct from a 4xx
@@ -233,6 +246,20 @@ export interface RelationConfig {
233
246
  to: string;
234
247
  /** If true, this is a one-to-many relation (returns array). Default: false (many-to-one, returns object) */
235
248
  many?: boolean;
249
+ /** To-one from the parent side (returns an object or null): the first target row whose `to`
250
+ * equals the parent's `from`, in `orderBy` order. Same column roles as `many`. Not with
251
+ * `many` or `limit`. The target must have a primary key. */
252
+ hasOne?: true;
253
+ /** Many or hasOne. Display order of each parent's rows, by target column. The target's primary
254
+ * key is appended as the tie-breaker (a hasOne with no orderBy is ordered by that key).
255
+ * Never read from the request. */
256
+ orderBy?: Array<{ column: string; order: 'asc' | 'desc' }>;
257
+ /** Many only. At most this many rows per parent, in `orderBy` order. Unrelated to `?limit=`. */
258
+ limit?: number;
259
+ /** Many or hasOne. Equality on target columns (`null` means IS NULL), applied before `limit`
260
+ * or the hasOne choice. A display filter the request cannot widen. It is not access control:
261
+ * use RLS for that. */
262
+ where?: Record<string, string | number | boolean | null>;
236
263
  }
237
264
 
238
265
  export interface SoftDeleteConfig {
@@ -245,8 +272,17 @@ export interface SoftDeleteConfig {
245
272
  * default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
246
273
  * Anyone else gets live rows only, as if the param were absent. A restore body must be
247
274
  * exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
248
- * This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md). */
275
+ * This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md).
276
+ * A lifecycle table may not appear here: it restores through `POST {table}/restore`. */
249
277
  restore?: Record<string, string[]>;
278
+ /** `lifecycle: true` tables (deriveHandlerConfig emits them), keyed like `tables`. Each is a
279
+ * soft-delete table whose column is always `deletedAt`, soft-deleted by the database itself:
280
+ * DELETE runs a real SQL DELETE (a trigger stamps the row), reads and embeds hide deleted rows
281
+ * for every role unless `?deleted=include|only` (honoured for any role; RLS decides what each
282
+ * sees), and `POST {basePath}/{table}/restore {id}` calls `<schema>.<table>_restore(id)` as the
283
+ * caller. PATCH with `?deleted=include|only` is refused with 400. `?deleted=only` reaches embeds
284
+ * too, so a live parent embedded under a deleted child comes back null. */
285
+ lifecycle?: string[];
250
286
  }
251
287
 
252
288
  export interface HookConfig {
@@ -262,6 +298,33 @@ export interface HookConfig {
262
298
  beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
263
299
  /** Called after DELETE with all deleted rows. Fire-and-forget. */
264
300
  afterDelete?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
301
+ /** After the hard DELETE statement and before commit, for each returned row. Return
302
+ * `veto(reason)` to refuse the request (409 `{ message, code: 'vetoed' }`, nothing deleted). A
303
+ * throw rolls back the database delete only. No transaction is passed; external effects
304
+ * cannot be undone and must be idempotent. Lifecycle API deletes do not call this.
305
+ * Treat the row as read-only: it is the same object later serialized into the response. */
306
+ beforeDestroy?: DestroyHook;
307
+ /** After a real removal commits, per returned row, best-effort. Must be idempotent.
308
+ * Treat the row as read-only: it is the same object later serialized into the response. */
309
+ afterDestroy?: DestroyHook;
310
+ /** Lifecycle tables only: inside the DELETE's transaction, before the DELETE statement, once per
311
+ * matched ROOT row (the pre-image), with `{ phase: 'stamp', batch: null }`. Return `veto(reason)` to
312
+ * refuse the whole request: 409 `{ message: reason, code: 'vetoed' }`, nothing stamped, no after
313
+ * hooks. A throw is a failure (a HandlerError keeps its status, anything else is 500). Cascaded
314
+ * children run no hook: guard them with the model's `deleteWhen`. */
315
+ beforeStamp?: BeforeLifecycleHook;
316
+ /** Lifecycle tables only: after a DELETE that stamped rows COMMITS, once per batch, with the
317
+ * stamped root rows of that batch (the pre-image, as the response lists them) and `ctx.batch`. A
318
+ * DELETE matching several rows stamps one batch per row, so this runs once per row then.
319
+ * Best-effort like afterDelete: a throw goes to onError and never changes the response. Not
320
+ * called when nothing was stamped, nor on a table that is not lifecycle. */
321
+ afterStamp?: AfterLifecycleHook;
322
+ /** Lifecycle tables only: after `POST {table}/restore` COMMITS, with `[{ <key>: id }]` (the id as
323
+ * the request sent it), `ctx.batch` and `ctx.counts`. Not called for a row deleted before batches
324
+ * existed (batch null). Best-effort: a throw goes to onError and never changes the response. */
325
+ afterRestore?: AfterLifecycleHook;
326
+ /** Reserved for a future release: createHandler refuses it, so it is never silently ignored. */
327
+ beforeRestore?: never;
265
328
  }
266
329
 
267
330
  /** Cache-Control header configuration for different response categories. */
@@ -366,6 +429,12 @@ export interface HandlerOptions {
366
429
  * Explicit references in query parameters are refused before database access.
367
430
  * Does not prevent writes to these columns; use protectedFields for that. */
368
431
  hiddenColumns?: Record<string, string[]>;
432
+ /** Exact roles allowed to read deletedBy, deletedBatch and deletedXid on each lifecycle
433
+ * table. Other hidden columns remain hidden. Uses auth.roleField (default role), with
434
+ * no hierarchy or case folding. Missing/non-string claims never qualify. The claim is compared
435
+ * to the model's PostgreSQL role name, so an app whose pgSettings maps claim to role
436
+ * differently sees the two diverge: keep the claim values equal to the manage role names. */
437
+ hiddenColumnsExceptRoles?: Record<string, string[]>;
369
438
  /** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
370
439
  * Prevents mass assignment / privilege escalation on sensitive columns. */
371
440
  protectedFields?: Record<string, string[]>;
@@ -415,8 +484,18 @@ export interface HandlerOptions {
415
484
  onError?: (fault: HandlerFault) => void | Promise<void>;
416
485
  }
417
486
 
418
- function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
487
+ /** The lifecycle machinery's timestamp column: fixed, whatever softDelete.column says. */
488
+ const LIFECYCLE_DELETED_AT = 'deletedAt';
489
+ /** The SQL name of the lifecycle batch column (columnPropertyName maps it to the table's key). */
490
+ const LIFECYCLE_DELETED_BATCH = 'deleted_batch';
491
+
492
+ function isLifecycleTable(tableName: string, options?: HandlerOptions): boolean {
493
+ return options?.softDelete?.lifecycle?.includes(tableName) ?? false;
494
+ }
495
+
496
+ function getSoftDeleteColumn(tableName: string, table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
419
497
  if (!options?.softDelete) return null;
498
+ if (isLifecycleTable(tableName, options)) return getColumn(table, LIFECYCLE_DELETED_AT);
420
499
  const colName = options.softDelete.column || 'deletedAt';
421
500
  return getColumn(table, colName);
422
501
  }
@@ -434,11 +513,29 @@ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL
434
513
 
435
514
  function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
436
515
  if (!options?.softDelete) return false;
516
+ if (isLifecycleTable(tableName, options)) return true;
437
517
  if (options.softDelete.tables) {
438
518
  return options.softDelete.tables.includes(tableName);
439
519
  }
440
520
  // Auto-detect: table has the soft-delete column
441
- return getSoftDeleteColumn(table, options) != null;
521
+ return getSoftDeleteColumn(tableName, table, options) != null;
522
+ }
523
+
524
+ /**
525
+ * The soft-delete guard on an embedded table. Live rows only, except that a lifecycle table
526
+ * honours the request's ?deleted=include|only like its top-level read: RLS decides what each
527
+ * role sees. `params` is absent where no request applies (a pre-DB validation pass).
528
+ */
529
+ function embedSoftDeleteCondition(
530
+ tableName: string,
531
+ table: DrizzleTable,
532
+ options: HandlerOptions | undefined,
533
+ params: URLSearchParams | undefined,
534
+ ): SQL | null {
535
+ if (!isSoftDeleteTable(tableName, table, options)) return null;
536
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
537
+ if (!sdCol) return null;
538
+ return params && isLifecycleTable(tableName, options) ? softDeleteCondition(sdCol, params) : isNull(sdCol);
442
539
  }
443
540
 
444
541
  /**
@@ -457,17 +554,18 @@ async function executeWithSettings<T>(
457
554
  db: DrizzleDb,
458
555
  settings: Record<string, string> | undefined,
459
556
  fn: (dbOrTx: DrizzleDb) => Promise<T>,
557
+ forceTransaction = false,
460
558
  ): Promise<T> {
461
- if (settings && Object.keys(settings).length > 0) {
559
+ if (forceTransaction || (settings && Object.keys(settings).length > 0)) {
462
560
  return db.transaction(async (tx: DrizzleDb) => {
463
- const role = settings['role'];
561
+ const role = settings?.['role'];
464
562
  if (role) {
465
563
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
466
564
  throw new Error(`Invalid role name: ${role}`);
467
565
  }
468
566
  await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
469
567
  }
470
- for (const [key, value] of Object.entries(settings)) {
568
+ for (const [key, value] of Object.entries(settings ?? {})) {
471
569
  if (key !== 'role' && value !== undefined && value !== '') {
472
570
  await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
473
571
  }
@@ -592,11 +690,11 @@ async function checkRoleAuth(
592
690
 
593
691
  /**
594
692
  * Determine the Cache-Control header for a GET response.
595
- * - pgSettings configured → private (per-user data via RLS)
596
- * - No pgSettings → null (let server package set its default)
693
+ * - pgSettings or role-dependent column visibility configured → private
694
+ * - Neither configured → null (let server package set its default)
597
695
  */
598
696
  function getCacheControl(options?: HandlerOptions): string | null {
599
- if (options?.pgSettings) {
697
+ if (options?.pgSettings || Object.keys(options?.hiddenColumnsExceptRoles ?? {}).length) {
600
698
  return options?.cache?.private ?? 'private, no-store';
601
699
  }
602
700
  return null;
@@ -840,6 +938,7 @@ function expandTableKeyedOptions(
840
938
  ...options,
841
939
  ...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
842
940
  ...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
941
+ ...(options.hiddenColumnsExceptRoles ? { hiddenColumnsExceptRoles: expandMap(options.hiddenColumnsExceptRoles) } : {}),
843
942
  ...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
844
943
  ...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
845
944
  ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
@@ -851,6 +950,7 @@ function expandTableKeyedOptions(
851
950
  ...options.softDelete,
852
951
  ...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
853
952
  ...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
953
+ ...(options.softDelete.lifecycle ? { lifecycle: expandList(options.softDelete.lifecycle) } : {}),
854
954
  },
855
955
  }
856
956
  : {}),
@@ -870,10 +970,23 @@ function declaredTables(options: HandlerOptions): Set<string> {
870
970
  ...Object.keys(options.relations ?? {}),
871
971
  ...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
872
972
  ...(options.softDelete?.tables ?? []),
973
+ ...(options.softDelete?.lifecycle ?? []),
873
974
  ];
874
975
  return new Set(names.map(toSnakeCase));
875
976
  }
876
977
 
978
+ /**
979
+ * Refuse a hook name reserved for a future release, so adding it later is additive and no one
980
+ * ships a hook that is silently ignored. Every other unknown hook key is ignored, as before.
981
+ */
982
+ function refuseReservedHooks(options: HandlerOptions | undefined): void {
983
+ for (const [tableName, hook] of Object.entries(options?.hooks ?? {})) {
984
+ if (hook && (hook as { beforeRestore?: unknown }).beforeRestore !== undefined) {
985
+ throw new Error(`[everystack/api] hooks.${tableName}.beforeRestore is reserved for a future release; remove it`);
986
+ }
987
+ }
988
+ }
989
+
877
990
  /**
878
991
  * Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
879
992
  * needs a user, so rowOwnership without auth is an error. An entry whose column does not
@@ -922,6 +1035,11 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
922
1035
  const colName = softDelete?.column || 'deletedAt';
923
1036
  for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
924
1037
  const table = schema[tableName];
1038
+ if (isLifecycleTable(tableName, options)) {
1039
+ throw new Error(
1040
+ `[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.`,
1041
+ );
1042
+ }
925
1043
  const isSoftDelete = table
926
1044
  ? isSoftDeleteTable(tableName, table, options)
927
1045
  : (softDelete?.tables?.includes(tableName) ?? false);
@@ -989,8 +1107,148 @@ function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undef
989
1107
  }
990
1108
  }
991
1109
 
1110
+ /**
1111
+ * With `drizzleRelations`, the JOIN path hands each relation to drizzle's `with` by its handler
1112
+ * name, so drizzle must declare a relation of that name on that table. A mismatch used to fail
1113
+ * inside drizzle at request time; it is refused here instead. A hasOne never goes to `with` (it is
1114
+ * resolved by a windowed query), and a table drizzle does not serve relationally takes the batch
1115
+ * path, so neither is checked.
1116
+ */
1117
+ function validateDrizzleRelationNames(db: DrizzleDb, options: HandlerOptions | undefined): void {
1118
+ if (!options?.drizzleRelations) return;
1119
+ const relational = (db as { _?: { schema?: Record<string, { relations?: Record<string, unknown> }> } })._?.schema;
1120
+ if (!relational) return;
1121
+ for (const [tableName, tableRelations] of Object.entries(options.relations ?? {})) {
1122
+ if (!(db as any).query?.[tableName]) continue;
1123
+ const declared = Object.keys(ownEntry(relational, tableName)?.relations ?? {});
1124
+ for (const [name, relation] of Object.entries(tableRelations)) {
1125
+ if (relation.hasOne || declared.includes(name)) continue;
1126
+ throw new Error(
1127
+ `[everystack/api] relations.${tableName}.${name}: drizzleRelations declares no relation "${name}" on "${tableName}" `
1128
+ + `(it has: ${declared.length ? [...declared].sort().join(', ') : 'none'}). The JOIN path would fail at request time. `
1129
+ + 'Declare it in your drizzle relations, rename the handler relation, or leave out drizzleRelations.',
1130
+ );
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ /**
1136
+ * Refuse a relation orderBy, limit, where or hasOne the read paths cannot apply. Only a relation
1137
+ * that declares one is checked. Its target must be in the schema when its source is: derived
1138
+ * configs name every table while a handler serves a few, and an unserved source never embeds.
1139
+ */
1140
+ function validateRelationDisplay(schema: Schema, options: HandlerOptions | undefined): void {
1141
+ for (const [tableName, tableRelations] of Object.entries(options?.relations ?? {})) {
1142
+ for (const [name, relation] of Object.entries(tableRelations)) {
1143
+ const { orderBy, limit, where, hasOne } = relation;
1144
+ const prefix = `[everystack/api] relations.${tableName}.${name}:`;
1145
+ if (hasOne !== undefined) {
1146
+ if (hasOne !== true) throw new Error(`${prefix} hasOne must be true`);
1147
+ if (relation.many) throw new Error(`${prefix} a relation is many or hasOne, not both`);
1148
+ if (limit !== undefined) throw new Error(`${prefix} hasOne returns one row: limit is not allowed`);
1149
+ } else if (orderBy === undefined && limit === undefined && where === undefined) {
1150
+ continue;
1151
+ }
1152
+ if (!relation.many && !hasOne) {
1153
+ throw new Error(`${prefix} orderBy and where apply to a many or hasOne relation only, and limit to a many relation only`);
1154
+ }
1155
+ const target = schema[relation.table];
1156
+ if (!target) {
1157
+ if (!schema[tableName]) continue;
1158
+ throw new Error(`${prefix} target table "${relation.table}" is not in the schema`);
1159
+ }
1160
+ // The key is the last tie-breaker. Without one, two runs could choose different rows.
1161
+ if (hasOne && primaryKeyColumns(target).length === 0) {
1162
+ throw new Error(`${prefix} target table "${relation.table}" has no primary key to break ties`);
1163
+ }
1164
+ const resolves = (column: unknown): boolean => typeof column === 'string' && columnPropertyName(target, column) !== undefined;
1165
+ if (orderBy !== undefined) {
1166
+ if (!Array.isArray(orderBy)) throw new Error(`${prefix} orderBy must be an array of { column, order }`);
1167
+ for (const term of orderBy) {
1168
+ if (!resolves(term?.column)) throw new Error(`${prefix} orderBy column "${term?.column}" is not a column of table "${relation.table}"`);
1169
+ if (term.order !== 'asc' && term.order !== 'desc') throw new Error(`${prefix} orderBy order for "${term.column}" must be 'asc' or 'desc'`);
1170
+ }
1171
+ }
1172
+ if (limit !== undefined && !(typeof limit === 'number' && Number.isInteger(limit) && limit > 0)) {
1173
+ throw new Error(`${prefix} limit must be a positive integer`);
1174
+ }
1175
+ if (where !== undefined) {
1176
+ if (where === null || typeof where !== 'object' || Array.isArray(where)) throw new Error(`${prefix} where must be an object of column → value`);
1177
+ for (const [column, value] of Object.entries(where)) {
1178
+ if (!resolves(column)) throw new Error(`${prefix} where column "${column}" is not a column of table "${relation.table}"`);
1179
+ const valid = value === null || typeof value === 'string' || typeof value === 'boolean'
1180
+ || (typeof value === 'number' && Number.isFinite(value));
1181
+ if (!valid) throw new Error(`${prefix} where value for "${column}" must be a string, a finite number, a boolean or null`);
1182
+ }
1183
+ }
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ /** The target's primary-key columns, in declaration order. Empty for a keyless or non-pg table. */
1189
+ function primaryKeyColumns(table: DrizzleTable): DrizzleColumn[] {
1190
+ if (!is(table, PgTable)) return [];
1191
+ const config = getTableConfig(table);
1192
+ const columns = Object.values(getTableColumns(table)) as DrizzleColumn[];
1193
+ const byName = new Map(columns.map(c => [c.name, c]));
1194
+ return [
1195
+ ...columns.filter(c => c.primary),
1196
+ ...config.primaryKeys.flatMap(key => key.columns.map(keyColumn => byName.get(keyColumn.name))),
1197
+ ].filter((c): c is DrizzleColumn => c !== undefined);
1198
+ }
1199
+
1200
+ /**
1201
+ * A relation's row filter and order, over the target table's own column objects (no config
1202
+ * value reaches SQL text). The filter is the soft-delete guard and the declared where. The
1203
+ * order is the declared orderBy then the primary key, or none when no orderBy, limit or hasOne
1204
+ * is declared. validateRelationDisplay has checked every name.
1205
+ */
1206
+ function relationDisplay(
1207
+ relation: RelationConfig,
1208
+ targetTable: DrizzleTable,
1209
+ options?: HandlerOptions,
1210
+ params?: URLSearchParams,
1211
+ ): { conditions: SQL[]; order: SQL[] } {
1212
+ const column = (name: string): DrizzleColumn => getTableColumns(targetTable)[columnPropertyName(targetTable, name)!] as DrizzleColumn;
1213
+ const conditions: SQL[] = [];
1214
+ const deletedGuard = embedSoftDeleteCondition(relation.table, targetTable, options, params);
1215
+ if (deletedGuard) conditions.push(deletedGuard);
1216
+ for (const [name, value] of Object.entries(relation.where ?? {})) {
1217
+ conditions.push(value === null ? isNull(column(name)) : eq(column(name), value));
1218
+ }
1219
+ const order: SQL[] = [];
1220
+ if (relation.orderBy !== undefined || relation.limit !== undefined || relation.hasOne) {
1221
+ const named = new Set<DrizzleColumn>();
1222
+ for (const term of relation.orderBy ?? []) {
1223
+ named.add(column(term.column));
1224
+ order.push(term.order === 'desc' ? desc(column(term.column)) : asc(column(term.column)));
1225
+ }
1226
+ for (const c of primaryKeyColumns(targetTable)) {
1227
+ if (!named.has(c)) { named.add(c); order.push(asc(c)); }
1228
+ }
1229
+ }
1230
+ return { conditions, order };
1231
+ }
1232
+
992
1233
  /** Resolve hidden columns once so every read uses the same column spellings. */
993
1234
  function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): HandlerOptions | undefined {
1235
+ const rolesByTable = new Map<DrizzleTable, string[]>();
1236
+ const configuredHidden = { ...options?.hiddenColumns };
1237
+ for (const [tableName, roles] of Object.entries(options?.hiddenColumnsExceptRoles ?? {})) {
1238
+ const table = schema[tableName];
1239
+ if (!table || !Array.isArray(roles) || roles.some(role => typeof role !== 'string' || role === '')) {
1240
+ throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: expected a known table and an array of role names`);
1241
+ }
1242
+ const previous = rolesByTable.get(table);
1243
+ if (previous && (previous.length !== roles.length || previous.some(role => !roles.includes(role)))) {
1244
+ throw new Error(`[everystack/api] hiddenColumnsExceptRoles.${tableName}: conflicting role exceptions for one table`);
1245
+ }
1246
+ rolesByTable.set(table, roles);
1247
+ if (isLifecycleTable(tableName, options)) {
1248
+ configuredHidden[tableName] = [...new Set([...(configuredHidden[tableName] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
1249
+ }
1250
+ }
1251
+ if (options?.hiddenColumnsExceptRoles) options = { ...options, hiddenColumns: configuredHidden };
994
1252
  if (!options?.hiddenColumns) return options;
995
1253
  const byTable = new Map<DrizzleTable, Set<string>>();
996
1254
  for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
@@ -1022,6 +1280,27 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
1022
1280
  return { ...options, hiddenColumns };
1023
1281
  }
1024
1282
 
1283
+ /** Resolve one request's visibility before query validation and every response path. */
1284
+ function hiddenColumnsForUser(schema: Schema, options: HandlerOptions | undefined, user: Record<string, unknown> | null): HandlerOptions | undefined {
1285
+ if (!options?.hiddenColumnsExceptRoles || !user) return options;
1286
+ const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
1287
+ if (!role) return options;
1288
+ const hiddenColumns = Object.fromEntries(Object.entries(options.hiddenColumns ?? {}).map(([tableName, columns]) => {
1289
+ const except = ownEntry(options.hiddenColumnsExceptRoles, tableName);
1290
+ if (!isLifecycleTable(tableName, options) || !except?.includes(role)) return [tableName, columns];
1291
+ const table = schema[tableName];
1292
+ const metadata = new Set<string>();
1293
+ for (const name of ['deleted_by', 'deleted_batch', 'deleted_xid']) {
1294
+ const property = columnPropertyName(table, name);
1295
+ if (!property) continue;
1296
+ const column = getTableColumns(table)[property] as DrizzleColumn;
1297
+ for (const spelling of [property, column.name, toSnakeCase(property), toCamelCase(column.name)]) metadata.add(spelling);
1298
+ }
1299
+ return [tableName, columns.filter(column => !metadata.has(column))];
1300
+ }));
1301
+ return { ...options, hiddenColumns };
1302
+ }
1303
+
1025
1304
  /** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
1026
1305
  const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
1027
1306
  '23514': { status: 422, message: 'Value does not meet a constraint' },
@@ -1094,6 +1373,11 @@ function mapIntegrityError(
1094
1373
  if (constraint !== undefined) body.constraint = constraint;
1095
1374
  if (code === '23502' && typeof fields.column === 'string') body.column = fields.column;
1096
1375
  }
1376
+ // lifecycle: { deleteWhen }'s guard (`<table>_lifecycle_delete_when`): a stable message, never a
1377
+ // row value. An author's constraintMessages entry for the name still wins.
1378
+ if (declared === undefined && code === '23514' && constraint?.endsWith('_lifecycle_delete_when')) {
1379
+ body.message = "Deleting this row is not allowed by its model's deleteWhen.";
1380
+ }
1097
1381
  // A failing trigger or FK action can name a different table than the URL.
1098
1382
  // Missing driver tables (and unknown RPC policies) use every configured list.
1099
1383
  const driverTable = typeof fields.table === 'string' ? fields.table : undefined;
@@ -1126,6 +1410,7 @@ function validateConstraintMessages(options?: HandlerOptions): void {
1126
1410
  }
1127
1411
 
1128
1412
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
1413
+ refuseReservedHooks(rawOptions);
1129
1414
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
1130
1415
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
1131
1416
  // multi-word table failed one gate or the other — both spellings 404'd. Collapse
@@ -1133,15 +1418,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1133
1418
  // apply. The root listing below stays one entry per table (the SQL name).
1134
1419
  const namePairs = tableNamePairs(rawSchema);
1135
1420
  const schema = expandSchema(rawSchema, namePairs);
1136
- const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
1137
- validateConstraintMessages(options);
1421
+ const configuredOptions = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
1422
+ validateConstraintMessages(configuredOptions);
1138
1423
  validateRowOwnership(schema, rawOptions);
1139
- validateSoftDeleteRestore(schema, options);
1140
- validateSanitizeColumns(schema, options);
1424
+ validateSoftDeleteRestore(schema, configuredOptions);
1425
+ validateSanitizeColumns(schema, configuredOptions);
1426
+ validateRelationDisplay(schema, configuredOptions);
1427
+ validateDrizzleRelationNames(db, configuredOptions);
1141
1428
  const tableNames = new Set(Object.keys(schema));
1142
1429
  const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
1143
1430
 
1144
1431
  return async (request: Request): Promise<Response> => {
1432
+ let options = configuredOptions;
1145
1433
  const url = new URL(request.url);
1146
1434
  let pathname = url.pathname;
1147
1435
  if (options?.basePath) {
@@ -1212,8 +1500,9 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1212
1500
  // Root path — table listing, always public. One entry per table (SQL name),
1213
1501
  // regardless of how many spellings route to it.
1214
1502
  if (pathSegments.length === 0) {
1215
- const tables = options?.exposedTables
1216
- ? canonicalTables.filter(t => options.exposedTables!.includes(t))
1503
+ const exposedTables = options?.exposedTables;
1504
+ const tables = exposedTables
1505
+ ? canonicalTables.filter(t => exposedTables.includes(t))
1217
1506
  : canonicalTables;
1218
1507
  return json({ tables }, 200);
1219
1508
  }
@@ -1268,6 +1557,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1268
1557
  return json({ message: 'Authentication required: no auth token provided' }, 401);
1269
1558
  }
1270
1559
 
1560
+ options = hiddenColumnsForUser(schema, options, user);
1271
1561
  const fn = isObjectForm ? entry.fn : entry;
1272
1562
  const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
1273
1563
  // Request-scoped context for the trace-id spine — RPC handlers receive it
@@ -1416,8 +1706,24 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1416
1706
  }
1417
1707
  }
1418
1708
 
1709
+ options = hiddenColumnsForUser(schema, options, user);
1419
1710
  const table = schema[tableName];
1420
1711
 
1712
+ // POST {table}/restore: a lifecycle table's restore. Before the generic routing, which would
1713
+ // read it as POST {table} (an insert). Any other table has no restore endpoint.
1714
+ if (method === 'POST' && pathSegments.length === 2 && pathSegments[1] === 'restore') {
1715
+ if (!isLifecycleTable(tableName, options)) {
1716
+ return sanitizeError(`Table '${tableName}' has no restore endpoint`, 404, options);
1717
+ }
1718
+ const response = await handleRestore(db, table, tableName, request, options, user, client, requestId);
1719
+ await emitAudit(options?.audit, {
1720
+ kind: 'rest', name: tableName, user, client,
1721
+ result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
1722
+ requestId, operation: 'update', columns: ['id'],
1723
+ });
1724
+ return response;
1725
+ }
1726
+
1421
1727
  // Forensic audit of the non-GET table surface (writes). Capture the query, not
1422
1728
  // the response: the operation, the WHERE predicate, and the body's column NAMES
1423
1729
  // (never values — values can be PII). Unresolved embed policies also record
@@ -1510,6 +1816,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1510
1816
  // 2xx; a throw (a failed commit) skips it, since the write did not happen.
1511
1817
  // A retry wrapper around db.transaction must reset `deferred` per attempt,
1512
1818
  // or the hooks of an aborted attempt would run too.
1819
+ const destroyHook = ownEntry(options?.hooks, tableName);
1820
+ const destroyTransaction = method === 'DELETE'
1821
+ && !isLifecycleTable(tableName, options)
1822
+ && !(isSoftDeleteTable(tableName, table, options) && getSoftDeleteColumn(tableName, table, options))
1823
+ && Boolean(destroyHook?.beforeDestroy || destroyHook?.afterDestroy);
1513
1824
  const deferred: Deferred[] = [];
1514
1825
  let response: Response;
1515
1826
  try {
@@ -1533,7 +1844,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1533
1844
  }
1534
1845
 
1535
1846
  return json({ message: `Method ${method} not allowed` }, 405);
1536
- });
1847
+ }, destroyTransaction);
1537
1848
  } finally {
1538
1849
  // emitFault never throws, so this cannot replace the error in flight.
1539
1850
  for (const item of deferred) if (item.always) await item.run();
@@ -1554,6 +1865,16 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1554
1865
  }
1555
1866
  return response;
1556
1867
  } catch (error) {
1868
+ // A before hook's veto: a decision, not a fault. Its transaction has rolled back.
1869
+ if (error instanceof VetoRefusal) {
1870
+ if (isWrite) {
1871
+ await emitAudit(options?.audit, {
1872
+ kind: 'rest', name: tableName, user, client, result: 'deny',
1873
+ requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
1874
+ });
1875
+ }
1876
+ return json({ message: error.reason, code: 'vetoed' }, 409);
1877
+ }
1557
1878
  const appError = error instanceof HandlerError;
1558
1879
  const unavailable = !appError && isServiceUnavailable(error);
1559
1880
  const integrity = appError || unavailable ? undefined : mapIntegrityError(error, tableName, options);
@@ -1563,7 +1884,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1563
1884
  if (isWrite) {
1564
1885
  await emitAudit(options?.audit, {
1565
1886
  kind: 'rest', name: tableName, user, client,
1566
- result: mapped || permissionDenied ? 'deny' : 'error',
1887
+ result: mapped || permissionDenied || (error instanceof HandlerError && error.status < 500) ? 'deny' : 'error',
1567
1888
  requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
1568
1889
  });
1569
1890
  }
@@ -1580,6 +1901,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1580
1901
  ...describeError(error),
1581
1902
  });
1582
1903
  }
1904
+ if (error instanceof HandlerError && error.status < 500) return json({ message: error.message }, error.status);
1583
1905
  if (unavailable) return serviceUnavailableResponse();
1584
1906
  throw error;
1585
1907
  }
@@ -1609,6 +1931,7 @@ function buildWithClause(
1609
1931
  tableRelations: Record<string, RelationConfig>,
1610
1932
  schema: Schema,
1611
1933
  options?: HandlerOptions,
1934
+ params?: URLSearchParams,
1612
1935
  ): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
1613
1936
  if (embeds.length === 0) return undefined;
1614
1937
 
@@ -1634,6 +1957,13 @@ function buildWithClause(
1634
1957
  } else if (hidden.size > 0) {
1635
1958
  config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
1636
1959
  }
1960
+ // Drizzle maps the target's columns to the join alias. The filter runs before the limit.
1961
+ if (table) {
1962
+ const { conditions, order } = relationDisplay(relation, table, options, params);
1963
+ if (conditions.length > 0) config.where = and(...conditions);
1964
+ if (order.length > 0) config.orderBy = order;
1965
+ if (relation.limit !== undefined) config.limit = relation.limit;
1966
+ }
1637
1967
  withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
1638
1968
  }
1639
1969
 
@@ -1786,7 +2116,7 @@ async function handleGet(
1786
2116
 
1787
2117
  // Soft-delete: filter deleted records unless ?deleted=include|only
1788
2118
  if (isSoftDeleteTable(tableName, table, options)) {
1789
- const sdCol = getSoftDeleteColumn(table, options);
2119
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
1790
2120
  if (sdCol) {
1791
2121
  const condition = softDeleteCondition(sdCol, params);
1792
2122
  if (condition) conditions.push(condition);
@@ -1864,10 +2194,20 @@ async function handleGet(
1864
2194
  offset: offsetVal,
1865
2195
  };
1866
2196
 
2197
+ // A hasOne never enters `with`: Drizzle's one() cannot order, so it cannot choose the row.
2198
+ // It is attached below from one windowed query, keyed on the parent's `from` column.
2199
+ const oneEmbeds = embeds.filter(embed => ownEntry(tableRelations, embed.name)?.hasOne);
1867
2200
  const colSelection = buildColumnsSelection(columns);
1868
- if (colSelection) queryOptions.columns = colSelection;
2201
+ const addedKeys: string[] = [];
2202
+ if (colSelection) {
2203
+ for (const embed of oneEmbeds) {
2204
+ const from = tableRelations![embed.name].from;
2205
+ if (!colSelection[from]) { colSelection[from] = true; addedKeys.push(from); }
2206
+ }
2207
+ queryOptions.columns = colSelection;
2208
+ }
1869
2209
 
1870
- const withClause = buildWithClause(embeds, tableRelations!, schema, options);
2210
+ const withClause = buildWithClause(embeds.filter(embed => !oneEmbeds.includes(embed)), tableRelations!, schema, options, params);
1871
2211
  if (withClause === null || withClause === 'unresolvedHiddenColumns') {
1872
2212
  return json({ message: 'Invalid query' }, 400);
1873
2213
  }
@@ -1882,6 +2222,10 @@ async function handleGet(
1882
2222
  if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
1883
2223
 
1884
2224
  rows = await (db as any).query[tableName].findMany(queryOptions);
2225
+ if (oneEmbeds.length > 0 && rows.length > 0) {
2226
+ rows = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], oneEmbeds, options!, params);
2227
+ for (const row of rows as Record<string, unknown>[]) for (const key of addedKeys) delete row[key];
2228
+ }
1885
2229
  } else {
1886
2230
  // Batch path — standard select + separate relation queries
1887
2231
  const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
@@ -1927,11 +2271,13 @@ async function handleGet(
1927
2271
  // Resolve relation embeds (batch path only — JOIN path already has nested data)
1928
2272
  let responseData: unknown[] = rows as unknown[];
1929
2273
  if (!useRelationalQuery && embeds.length > 0 && rowCount > 0) {
1930
- responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options!);
2274
+ responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options!, params);
1931
2275
  }
1932
2276
 
1933
2277
  // Apply column selection (batch path only — JOIN path handles via queryOptions.columns)
1934
2278
  if (!useRelationalQuery && columns) {
2279
+ // An embed that did not resolve was skipped above, and its name may be a real column.
2280
+ const resolvedEmbeds = embeds.filter(embed => resolveEmbedTarget(schema, tableRelations, embed.name));
1935
2281
  responseData = (responseData as Record<string, unknown>[]).map(row => {
1936
2282
  const filtered: Record<string, unknown> = {};
1937
2283
  for (const col of columns) {
@@ -1939,7 +2285,7 @@ async function handleGet(
1939
2285
  filtered[col] = row[col];
1940
2286
  }
1941
2287
  }
1942
- for (const embed of embeds) {
2288
+ for (const embed of resolvedEmbeds) {
1943
2289
  if (embed.name in row) {
1944
2290
  filtered[embed.name] = row[embed.name];
1945
2291
  }
@@ -2212,6 +2558,33 @@ function resolveEmbedTarget(
2212
2558
  return targetColumn ? { relationConfig, targetTable, targetColumn } : null;
2213
2559
  }
2214
2560
 
2561
+ /** The per-parent rank a limited embed is cut on. Dropped from every row. */
2562
+ const EMBED_RANK = '__everystack_embed_rank';
2563
+
2564
+ /**
2565
+ * The first `cap` target rows per parent key, ranked after the filter in `order`. One windowed
2566
+ * query on the request's own connection. Ordered by rank, so each parent's rows stay in order
2567
+ * through the grouping that follows.
2568
+ */
2569
+ async function rankedEmbedRows(
2570
+ db: DrizzleDb,
2571
+ targetTable: DrizzleTable,
2572
+ targetColumn: DrizzleColumn,
2573
+ where: SQL | undefined,
2574
+ order: SQL[],
2575
+ cap: number,
2576
+ ): Promise<Record<string, unknown>[]> {
2577
+ const orderClause = order.length > 0 ? sql` order by ${sql.join(order, sql`, `)}` : sql``;
2578
+ const ranked = db.$with('embed_ranked').as(db.select({
2579
+ ...getTableColumns(targetTable),
2580
+ [EMBED_RANK]: sql<number>`row_number() over (partition by ${targetColumn}${orderClause})`.as(EMBED_RANK),
2581
+ }).from(targetTable).where(where));
2582
+ return (await db.with(ranked).select().from(ranked)
2583
+ .where(sql`${ranked[EMBED_RANK]} <= ${cap}`)
2584
+ .orderBy(ranked[EMBED_RANK]))
2585
+ .map(({ [EMBED_RANK]: _rank, ...row }: Record<string, unknown>) => row);
2586
+ }
2587
+
2215
2588
  /**
2216
2589
  * Resolve relation embeds by fetching related rows and nesting them.
2217
2590
  */
@@ -2222,6 +2595,7 @@ async function resolveEmbeds(
2222
2595
  rows: Record<string, unknown>[],
2223
2596
  embeds: EmbedRequest[],
2224
2597
  options: HandlerOptions,
2598
+ params?: URLSearchParams,
2225
2599
  ): Promise<Record<string, unknown>[]> {
2226
2600
  const tableRelations = ownEntry(options.relations, tableName);
2227
2601
  if (!tableRelations) return rows;
@@ -2243,12 +2617,37 @@ async function resolveEmbeds(
2243
2617
  ...embed.embeds.filter(nested => resolveEmbedTarget(schema, nestedRelations, nested.name)).map(nested => nested.name),
2244
2618
  ];
2245
2619
 
2246
- // Build soft-delete filter for the target table
2247
- const targetSdCol = isSoftDeleteTable(relationConfig.table, targetTable, options)
2248
- ? getSoftDeleteColumn(targetTable, options)
2249
- : null;
2620
+ // The soft-delete guard for the target table
2621
+ const deletedGuard = embedSoftDeleteCondition(relationConfig.table, targetTable, options, params);
2250
2622
 
2251
- if (relationConfig.many) {
2623
+ if (relationConfig.hasOne) {
2624
+ // One row per parent: source.from is the parent key, target.to the child FK, as for many.
2625
+ const sourceValues = result.map(r => r[relationConfig.from]).filter(v => v != null);
2626
+ if (sourceValues.length === 0) {
2627
+ for (const row of result) row[embed.name] = null;
2628
+ continue;
2629
+ }
2630
+
2631
+ // The database chooses: the declared where and the soft-delete guard first, then the
2632
+ // first row by the declared order with the key last. Never a fetch of every child.
2633
+ const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
2634
+ let relatedRows = await rankedEmbedRows(
2635
+ db, targetTable, targetColumn, and(inArray(targetColumn, sourceValues as string[]), ...conditions), order, 1,
2636
+ );
2637
+
2638
+ if (embed.embeds.length > 0) {
2639
+ relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
2640
+ }
2641
+
2642
+ // Match on the key, then strip: the key may itself be a hidden column.
2643
+ const index = new Map<unknown, Record<string, unknown>>();
2644
+ for (const r of relatedRows) index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
2645
+ for (const row of result) {
2646
+ const sourceVal = row[relationConfig.from];
2647
+ const related = sourceVal == null ? null : index.get(sourceVal) ?? null;
2648
+ row[embed.name] = related && picked ? pickColumns(related, picked) : related;
2649
+ }
2650
+ } else if (relationConfig.many) {
2252
2651
  // One-to-many: source.from is the PK, target.to is the FK
2253
2652
  // Collect all source PKs
2254
2653
  const sourceValues = result
@@ -2264,24 +2663,31 @@ async function resolveEmbeds(
2264
2663
  }
2265
2664
 
2266
2665
  // Fetch all related rows in one query
2267
- const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[])];
2268
- if (targetSdCol) embedConditions.push(isNull(targetSdCol));
2269
-
2270
- let relatedRows: Record<string, unknown>[] = await db
2271
- .select()
2272
- .from(targetTable)
2273
- .where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
2274
- relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
2666
+ const { conditions, order } = relationDisplay(relationConfig, targetTable, options, params);
2667
+ const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[]), ...conditions];
2668
+ const embedWhere = embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions);
2669
+
2670
+ let relatedRows: Record<string, unknown>[];
2671
+ if (relationConfig.limit !== undefined) {
2672
+ // Rank each parent's rows after the filter, then keep the first `limit` per parent.
2673
+ relatedRows = await rankedEmbedRows(db, targetTable, targetColumn, embedWhere, order, relationConfig.limit);
2674
+ } else {
2675
+ const query = db.select().from(targetTable).where(embedWhere);
2676
+ relatedRows = await (order.length > 0 ? query.orderBy(...order) : query);
2677
+ }
2275
2678
 
2276
2679
  // Recursively resolve nested embeds
2277
2680
  if (embed.embeds.length > 0) {
2278
- relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
2681
+ relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
2279
2682
  }
2280
2683
 
2281
- // Group by FK value and assign to each source row
2684
+ // Group by FK value and assign to each source row. Match on the key, then strip: the key
2685
+ // may itself be a hidden column.
2282
2686
  for (const row of result) {
2283
2687
  const sourceVal = row[relationConfig.from];
2284
- const matched = relatedRows.filter(r => r[relationConfig.to] === sourceVal);
2688
+ const matched = relatedRows
2689
+ .filter(r => r[relationConfig.to] === sourceVal)
2690
+ .map(r => visibleRow(r, relationConfig.table, options));
2285
2691
  row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
2286
2692
  }
2287
2693
  } else {
@@ -2301,23 +2707,23 @@ async function resolveEmbeds(
2301
2707
 
2302
2708
  // Fetch all referenced rows in one query (batch, no N+1)
2303
2709
  const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
2304
- if (targetSdCol) embedConditions.push(isNull(targetSdCol));
2710
+ if (deletedGuard) embedConditions.push(deletedGuard);
2305
2711
 
2306
2712
  let relatedRows: Record<string, unknown>[] = await db
2307
2713
  .select()
2308
2714
  .from(targetTable)
2309
2715
  .where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
2310
- relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
2311
2716
 
2312
2717
  // Recursively resolve nested embeds
2313
2718
  if (embed.embeds.length > 0) {
2314
- relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
2719
+ relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options, params);
2315
2720
  }
2316
2721
 
2317
- // Index by target key for O(1) lookup
2722
+ // Index by target key for O(1) lookup. Match on the key, then strip: the key may itself
2723
+ // be a hidden column.
2318
2724
  const index = new Map<unknown, Record<string, unknown>>();
2319
2725
  for (const r of relatedRows) {
2320
- index.set(r[relationConfig.to], r);
2726
+ index.set(r[relationConfig.to], visibleRow(r, relationConfig.table, options));
2321
2727
  }
2322
2728
 
2323
2729
  // Assign to each source row
@@ -2336,6 +2742,11 @@ async function resolveEmbeds(
2336
2742
  return result;
2337
2743
  }
2338
2744
 
2745
+ /** One embedded row without its table's hidden columns. */
2746
+ function visibleRow(row: Record<string, unknown>, tableName: string, options: HandlerOptions): Record<string, unknown> {
2747
+ return stripHiddenColumns(row, tableName, options) as Record<string, unknown>;
2748
+ }
2749
+
2339
2750
  function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
2340
2751
  const result: Record<string, unknown> = {};
2341
2752
  for (const col of columns) {
@@ -2551,6 +2962,12 @@ async function handlePatch(
2551
2962
  const scope = ownershipScope(table, tableName, options, user);
2552
2963
  if ('refusal' in scope) return scope.refusal;
2553
2964
 
2965
+ // A lifecycle row is restored by its own function, on its delete rule, never by a PATCH.
2966
+ const requestedDeleted = params.get('deleted');
2967
+ if (isLifecycleTable(tableName, options) && (requestedDeleted === 'include' || requestedDeleted === 'only')) {
2968
+ return json({ message: `PATCH does not accept ?deleted= on ${tableName}: restore a deleted row with POST ${restorePath(tableName, options)} { "id": <id> }` }, 400);
2969
+ }
2970
+
2554
2971
  let body: Record<string, unknown>;
2555
2972
  try {
2556
2973
  body = await request.json();
@@ -2575,7 +2992,7 @@ async function handlePatch(
2575
2992
 
2576
2993
  // Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
2577
2994
  // this table. Anyone else is matched to live rows, as if the param were absent.
2578
- const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(table, options) : null;
2995
+ const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(tableName, table, options) : null;
2579
2996
  const restoring = sdCol !== null && mayRestore(tableName, options, user);
2580
2997
  const deletedMode = params.get('deleted');
2581
2998
  if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
@@ -2820,9 +3237,43 @@ async function handleDelete(
2820
3237
  // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
2821
3238
  if (scope.condition) conditions.push(scope.condition);
2822
3239
 
3240
+ // Lifecycle: a real DELETE, which the table's trigger turns into a stamp (it reports 0 rows).
3241
+ if (isLifecycleTable(tableName, options)) {
3242
+ let stamped: Awaited<ReturnType<typeof lifecycleDelete>>;
3243
+ try {
3244
+ stamped = await lifecycleDelete(db, table, tableName, conditions, options, hook?.beforeStamp
3245
+ ? { run: hook.beforeStamp.bind(hook), ctx: { phase: 'stamp', source: 'api', batch: null, user, client } } : undefined);
3246
+ } catch (err) {
3247
+ // Its transaction has rolled back: nothing was stamped.
3248
+ if (err instanceof BeforeHookThrew) return beforeHookFailure(err.thrown, options, { requestId, name: `${tableName}.beforeStamp`, method: 'DELETE' }, deferred);
3249
+ throw err;
3250
+ }
3251
+ const { rows, batchOf } = stamped;
3252
+ // The pre-image carries deletedBatch NULL; give each root its batch. stripHiddenColumns keeps
3253
+ // it for a plain manage role only.
3254
+ const batchKey = columnPropertyName(table, LIFECYCLE_DELETED_BATCH);
3255
+ if (batchKey) for (const [row, batch] of batchOf) row[batchKey] = batch;
3256
+ if (hook?.afterDelete && rows.length > 0) {
3257
+ const { afterDelete } = hook;
3258
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3259
+ () => afterDelete.call(hook, rows, user, client));
3260
+ }
3261
+ if (hook?.afterStamp && batchOf.size > 0) {
3262
+ const { afterStamp } = hook;
3263
+ const roots = new Map<string, Record<string, unknown>[]>();
3264
+ for (const [row, batch] of batchOf) roots.set(batch, [...(roots.get(batch) ?? []), row]);
3265
+ for (const [batch, batchRoots] of roots) {
3266
+ const ctx: LifecycleContext = { phase: 'stamp', source: 'api', batch, user, client };
3267
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterStamp`, method: 'DELETE' },
3268
+ async () => { await afterStamp.call(hook, batchRoots, ctx); });
3269
+ }
3270
+ }
3271
+ return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3272
+ }
3273
+
2823
3274
  // Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
2824
3275
  if (isSoftDeleteTable(tableName, table, options)) {
2825
- const sdCol = getSoftDeleteColumn(table, options);
3276
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
2826
3277
  if (sdCol) {
2827
3278
  // Only soft-delete records that aren't already deleted
2828
3279
  conditions.push(isNull(sdCol));
@@ -2856,7 +3307,24 @@ async function handleDelete(
2856
3307
  query = query.where(and(...conditions));
2857
3308
  }
2858
3309
 
2859
- const rows = await query.returning();
3310
+ const rows: Record<string, unknown>[] = await query.returning();
3311
+
3312
+ // RETURNING proves DELETE authority, including policies that differ from SELECT.
3313
+ // The dispatcher keeps this statement and beforeDestroy in one caller transaction.
3314
+ // A veto throws out of the caller transaction, so the DELETE rolls back; the dispatcher answers 409.
3315
+ const ctx: DestroyContext = { phase: 'destroy', source: 'api', batch: null, user, client };
3316
+ for (const row of rows) {
3317
+ if (!hook?.beforeDestroy) break;
3318
+ const verdict = await hook.beforeDestroy(row, ctx);
3319
+ if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
3320
+ }
3321
+ if (hook?.afterDestroy) {
3322
+ const { afterDestroy } = hook;
3323
+ for (const row of rows) {
3324
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDestroy`, method: 'DELETE' },
3325
+ async () => { await afterDestroy.call(hook, row, ctx); });
3326
+ }
3327
+ }
2860
3328
 
2861
3329
  if (hook?.afterDelete && rows.length > 0) {
2862
3330
  const { afterDelete } = hook;
@@ -2867,6 +3335,166 @@ async function handleDelete(
2867
3335
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
2868
3336
  }
2869
3337
 
3338
+ /**
3339
+ * DELETE on a lifecycle table. The trigger stamps each row and cancels its delete, so DELETE
3340
+ * reports 0 rows and RETURNING has nothing. In one transaction: read the live rows the request
3341
+ * matches (the pre-image), run beforeStamp on each, DELETE those keys only (with the same
3342
+ * conditions), then drop every row still live: RLS or the filter refused its delete. What is left is
3343
+ * the pre-image of the rows this request soft-deleted. The read takes no lock, so a hook sees the
3344
+ * row as read; a rule that must hold on the stamped values belongs in deleteWhen, which the trigger
3345
+ * checks on the current row. Under READ COMMITTED a row another request stamps between the read
3346
+ * and the DELETE is still listed (and reaches afterDelete): a false attribution, never a leak.
3347
+ */
3348
+ async function lifecycleDelete(
3349
+ db: DrizzleDb,
3350
+ table: DrizzleTable,
3351
+ tableName: string,
3352
+ conditions: SQL[],
3353
+ options?: HandlerOptions,
3354
+ beforeStamp?: { run: BeforeLifecycleHook; ctx: LifecycleContext },
3355
+ ): Promise<{ rows: Record<string, unknown>[]; batchOf: Map<Record<string, unknown>, string> }> {
3356
+ const sdCol = getSoftDeleteColumn(tableName, table, options);
3357
+ const pk = primaryKeyColumns(table);
3358
+ const key = pk.length === 1 ? columnPropertyName(table, pk[0].name) : undefined;
3359
+ if (!sdCol || key === undefined) {
3360
+ throw new Error(`[everystack/api] softDelete.lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a single-column primary key`);
3361
+ }
3362
+ const keyColumn = getTableColumns(table)[key];
3363
+ const batchOf = new Map<Record<string, unknown>, string>();
3364
+ const rows = await db.transaction(async (tx: DrizzleDb) => {
3365
+ // No row lock: FOR UPDATE needs UPDATE privilege, which a delete-only role does not hold.
3366
+ const matched: Record<string, unknown>[] = await tx.select().from(table).where(and(...conditions, isNull(sdCol)));
3367
+ if (matched.length === 0) return [];
3368
+ // beforeStamp: once per matched root, before the DELETE, inside this transaction. A veto or a
3369
+ // throw leaves it, so nothing is stamped.
3370
+ if (beforeStamp) {
3371
+ for (const row of matched) {
3372
+ let verdict: unknown;
3373
+ try { verdict = await beforeStamp.run(row, beforeStamp.ctx); } catch (err) { throw new BeforeHookThrew(err); }
3374
+ if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
3375
+ }
3376
+ }
3377
+ // The transition appends one `<JSON key> <batch>` line per stamped root to this setting (a
3378
+ // caller that is not a manage role cannot read deleted_batch). Cleared first, so only this
3379
+ // DELETE's lines are read back; informational, the stamp columns stay the truth.
3380
+ await tx.execute(sql`SELECT pg_catalog.set_config('everystack.lifecycle_batch', '', true)`);
3381
+ // Exactly the rows the hook saw: a row that starts to match while a hook runs is not stamped.
3382
+ // The request filter stays, so a matched row edited out of it meanwhile is not stamped either.
3383
+ const matchedKeys = matched.map(row => row[key]);
3384
+ await tx.delete(table).where(and(...conditions, isNull(sdCol), inArray(keyColumn, matchedKeys)));
3385
+ const stillLive: Record<string, unknown>[] = await tx
3386
+ .select({ [key]: keyColumn })
3387
+ .from(table)
3388
+ .where(and(isNull(sdCol), inArray(keyColumn, matchedKeys)));
3389
+ const lines = await tx.execute(sql`SELECT pg_catalog.current_setting('everystack.lifecycle_batch', true) AS v`) as unknown as Array<{ v: string | null }>;
3390
+ const byKey = new Map<string, string>();
3391
+ for (const line of String(lines[0]?.v ?? '').split('\n')) {
3392
+ if (line.length < 38) continue;
3393
+ try { byKey.set(String(JSON.parse(line.slice(0, -37))), line.slice(-36)); } catch { /* not a line the transition wrote */ }
3394
+ }
3395
+ const live = new Set(stillLive.map(row => String(row[key])));
3396
+ const stamped = matched.filter(row => !live.has(String(row[key])));
3397
+ for (const row of stamped) {
3398
+ const batch = byKey.get(String(row[key]));
3399
+ if (batch) batchOf.set(row, batch);
3400
+ }
3401
+ return stamped;
3402
+ });
3403
+ return { rows, batchOf };
3404
+ }
3405
+
3406
+ /**
3407
+ * POST {table}/restore { id } on a lifecycle table: `<schema>.<table>_restore(id)` inside the
3408
+ * request's pgSettings transaction, so the function sees the caller's role and claims and decides
3409
+ * on the table's delete rule. The id is bound and cast to the key's type, so an author's own
3410
+ * overload of the name is never chosen. 42501 (the function's one refusal) is 403 with its
3411
+ * message; 55000 (not READ COMMITTED) is 409; 22P02 (an id the key type cannot read) is 400.
3412
+ */
3413
+ async function handleRestore(
3414
+ db: DrizzleDb,
3415
+ table: DrizzleTable,
3416
+ tableName: string,
3417
+ request: Request,
3418
+ options: HandlerOptions | undefined,
3419
+ user: Record<string, unknown> | null,
3420
+ client: Record<string, unknown> | null,
3421
+ requestId: string | undefined,
3422
+ ): Promise<Response> {
3423
+ const body: unknown = await request.json().catch(() => undefined);
3424
+ const id = body !== null && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>).id : undefined;
3425
+ if (!(typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)))) {
3426
+ return json({ message: 'restore requires a JSON body { "id": <id> }' }, 400);
3427
+ }
3428
+ const [key] = primaryKeyColumns(table);
3429
+ const keyType = key ? restoreArgType(key) : null;
3430
+ const name = sqlNameOf(table);
3431
+ if (!keyType || name === null || primaryKeyColumns(table).length !== 1) {
3432
+ const message = `[everystack/api] softDelete.lifecycle.${tableName}: restore needs a table with a single-column primary key of a plain type`;
3433
+ await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, message });
3434
+ return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : message }, 500);
3435
+ }
3436
+ const fn = sql`${sql.identifier(getTableConfig(table).schema ?? 'public')}.${sql.identifier(`${name}_restore`)}`;
3437
+ try {
3438
+ const result = await executeWithSettings(db, options?.pgSettings?.(user, client), async (tx) => {
3439
+ const rows = await tx.execute(sql`SELECT ${fn}(CAST(${id} AS ${sql.raw(keyType)})) AS result`);
3440
+ return (rows as unknown as Array<{ result: unknown }>)[0]?.result;
3441
+ });
3442
+ // After the commit (executeWithSettings has returned). Best-effort: never changes the response.
3443
+ const hook = ownEntry(options?.hooks, tableName);
3444
+ const r = result as { restored?: unknown; skipped?: unknown; batch?: unknown } | null;
3445
+ if (hook?.afterRestore && r && typeof r.batch === 'string') {
3446
+ try {
3447
+ await hook.afterRestore([{ [columnPropertyName(table, key.name) ?? key.name]: id }], {
3448
+ phase: 'restore', source: 'api', batch: r.batch, user, client,
3449
+ counts: { restored: Number(r.restored), skipped: Number(r.skipped) },
3450
+ });
3451
+ } catch (err) {
3452
+ await reportHookFault(options?.onError, { requestId, name: `${tableName}.afterRestore`, method: 'POST' }, err);
3453
+ }
3454
+ }
3455
+ return json(result, 200);
3456
+ } catch (error) {
3457
+ // Code and message from the one object that carries the code: a wrapper's message can quote the query.
3458
+ const carrier = pgErrorCarrier(error);
3459
+ const code = carrier && safeRead(carrier, 'code') as string;
3460
+ const text = carrier && safeRead(carrier, 'message');
3461
+ const message = typeof text === 'string' ? text : '';
3462
+ if (code === '42501') return sanitizeError(message || 'Permission denied', 403, options);
3463
+ if (code === '55000') return json({ message, code }, 409);
3464
+ if (code === '22P02') return json({ message: 'Invalid id', code }, 400);
3465
+ if (isServiceUnavailable(error)) return serviceUnavailableResponse();
3466
+ await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method: 'POST', status: 500, ...describeError(error) });
3467
+ return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'Restore failed' }, 500);
3468
+ }
3469
+ }
3470
+
3471
+ /** The first object on the error's cause chain with a string `code` (drivers and wrappers nest it). */
3472
+ function pgErrorCarrier(error: unknown): object | undefined {
3473
+ let current: unknown = error;
3474
+ for (let hop = 0; hop <= MAX_CAUSE_HOPS && current !== null && typeof current === 'object'; hop++) {
3475
+ if (typeof safeRead(current, 'code') === 'string') return current;
3476
+ const cause = safeRead(current, 'cause');
3477
+ if (cause === UNREADABLE || cause === current) return undefined;
3478
+ current = cause;
3479
+ }
3480
+ return undefined;
3481
+ }
3482
+
3483
+ /**
3484
+ * The SQL type the restore id is cast to: the key column's declared type, a serial read as its
3485
+ * integer type. Null for a type text this function will not put in SQL.
3486
+ */
3487
+ function restoreArgType(column: DrizzleColumn): string | null {
3488
+ const declared = String((column as { getSQLType?: () => string }).getSQLType?.() ?? '').trim().toLowerCase();
3489
+ const type = ({ serial: 'integer', smallserial: 'smallint', bigserial: 'bigint' } as Record<string, string>)[declared] ?? declared;
3490
+ return /^[a-z][a-z0-9_ ]*(\(\d+(, ?\d+)?\))?$/.test(type) ? type : null;
3491
+ }
3492
+
3493
+ /** The restore endpoint's path, as a client calls it. */
3494
+ function restorePath(tableName: string, options?: HandlerOptions): string {
3495
+ return `${(options?.basePath ?? '').replace(/\/+$/, '')}/${tableName}/restore`;
3496
+ }
3497
+
2870
3498
  interface JsonPath {
2871
3499
  baseColumn: string;
2872
3500
  steps: Array<{ key: string; asText: boolean }>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The lifecycle hook contract. A structurally identical twin of `@everystack/model`'s
3
+ * lifecycle-hooks (this package has no runtime dependencies): a veto made by either is
4
+ * recognized by both, and the types are interchangeable. Keep the two in step.
5
+ */
6
+
7
+ const VETO_KIND = 'everystack.hookVeto';
8
+
9
+ /** What a before hook returns to refuse its operation. Make it with {@link veto}. */
10
+ export interface HookVeto {
11
+ readonly kind: typeof VETO_KIND;
12
+ readonly reason: string;
13
+ }
14
+
15
+ /** Refuse the operation a before hook guards. A throw is a failure; a veto is a decision. */
16
+ export function veto(reason: string): HookVeto {
17
+ if (typeof reason !== 'string' || reason.trim() === '') throw new TypeError('veto(reason) needs a non-empty reason string');
18
+ return Object.freeze({ kind: VETO_KIND, reason });
19
+ }
20
+
21
+ /** True only for a value {@link veto} made (or its twin in `@everystack/model`). */
22
+ export function isVeto(value: unknown): value is HookVeto {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
24
+ && (value as { kind?: unknown }).kind === VETO_KIND && typeof (value as { reason?: unknown }).reason === 'string';
25
+ }
26
+
27
+ /** The one context every lifecycle hook receives. */
28
+ export interface LifecycleContext {
29
+ /** `stamp`: the row was marked deleted. `restore`: a batch came back. `destroy`: physical removal. */
30
+ phase: 'stamp' | 'restore' | 'destroy';
31
+ /** `api`: a request through createHandler. `purge`: purgeExpired. */
32
+ source: 'api' | 'purge';
33
+ /** The lifecycle batch, or null when there is none to report (before a stamp, a non-lifecycle table, a pre-batch row). Never invented. */
34
+ batch: string | null;
35
+ user: Record<string, unknown> | null;
36
+ client: Record<string, unknown> | null;
37
+ /** Restore only: what the restore function reported. */
38
+ counts?: { restored: number; skipped: number };
39
+ }
40
+
41
+ /** Context for a real removal. External effects must be idempotent across retries. */
42
+ export interface DestroyContext extends LifecycleContext {
43
+ phase: 'destroy';
44
+ }
45
+
46
+ /** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
47
+ export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
48
+
49
+ /** A before hook of the lifecycle: called once per root row; returns nothing or a veto. */
50
+ export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
51
+
52
+ /** An after hook of the lifecycle: called after commit with the root rows of one batch. Best-effort. */
53
+ export type AfterLifecycleHook = (rows: Record<string, unknown>[], ctx: LifecycleContext) => void | Promise<void>;