@everystack/api 0.4.12 → 0.4.13

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,25 @@ 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.13] - 2026-10-30
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - destroy is one generated SQL function per lifecycle table; purgeExpired is removed
12
+
13
+ ### Fixed
14
+
15
+ - destroy hooks are refused on a table that is not a lifecycle table
16
+ - a delete before-hook outage answers the fixed 503 again
17
+
18
+ ### Changed
19
+
20
+ - one delete hook pair; beforeStamp and afterStamp are folded into beforeDelete and afterDelete
21
+
22
+ ### Documentation
23
+
24
+ - one delete hook pair
25
+
7
26
  ## [0.4.12] - 2026-10-23
8
27
 
9
28
  ### BREAKING CHANGES
package/README.md CHANGED
@@ -99,11 +99,9 @@ createHandler(db, schema, {
99
99
  afterCreate: async (row, user, client) => { /* notify */ },
100
100
  beforeUpdate: async (body, user, client) => body,
101
101
  afterUpdate: async (rows, user, client) => {},
102
- beforeDelete: async (user, client) => {},
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)
102
+ // All models; veto from '@everystack/api/handler' rolls back the request:
103
+ beforeDelete: (row, ctx) => (ctx.user ? undefined : veto('sign in to delete')), // in the transaction; veto -> 409
104
+ afterDelete: async (rows, ctx) => {}, // after commit, once per request; ctx.batch is null
107
105
  afterRestore: async (rows, ctx) => {}, // after commit, ctx.batch and ctx.counts
108
106
  },
109
107
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
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>",
@@ -30,14 +30,14 @@ export { HandlerError } from './errors';
30
30
  import { HandlerError } from './errors';
31
31
  export { veto, isVeto } from './veto';
32
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';
33
+ import { isVeto, type AfterLifecycleHook, type BeforeLifecycleHook, type LifecycleContext } from './veto';
34
34
 
35
35
  /** A before hook returned veto(reason): the request is refused with 409 and its transaction rolls back. */
36
36
  class VetoRefusal extends Error {
37
37
  constructor(readonly reason: string) { super(reason); }
38
38
  }
39
39
 
40
- /** A lifecycle before hook threw inside its transaction; carried out so the handler can answer as for any before hook. */
40
+ /** A delete before hook threw inside its transaction; carried out so the handler can answer as for any before hook. */
41
41
  class BeforeHookThrew extends Error {
42
42
  constructor(readonly thrown: unknown) { super('before hook threw'); }
43
43
  }
@@ -294,31 +294,13 @@ export interface HookConfig {
294
294
  beforeUpdate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
295
295
  /** Called after UPDATE with all returned rows. Fire-and-forget. */
296
296
  afterUpdate?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
297
- /** Called before DELETE. Throw to abort. */
298
- beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
299
- /** Called after DELETE with all deleted rows. Fire-and-forget. */
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;
297
+ /** Inside the transaction, once per matched root row. Return veto(reason) to roll back the
298
+ * request with 409. Lifecycle rows are pre-images; plain and legacy softDelete rows come from
299
+ * RETURNING. Throws roll back too. Cascaded children run no hook: guard them with deleteWhen. */
300
+ beforeDelete?: BeforeLifecycleHook;
301
+ /** After commit, once per request with its root rows, best-effort. Empty deletes fire nothing.
302
+ * Each lifecycle row carries its own deletedBatch; ctx.batch is null for a delete request. */
303
+ afterDelete?: AfterLifecycleHook;
322
304
  /** Lifecycle tables only: after `POST {table}/restore` COMMITS, with `[{ <key>: id }]` (the id as
323
305
  * the request sent it), `ctx.batch` and `ctx.counts`. Not called for a row deleted before batches
324
306
  * existed (batch null). Best-effort: a throw goes to onError and never changes the response. */
@@ -976,11 +958,23 @@ function declaredTables(options: HandlerOptions): Set<string> {
976
958
  }
977
959
 
978
960
  /**
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.
961
+ * Refuse removed and reserved hook names so a guard cannot be silently ignored.
962
+ * Other unknown hook keys are ignored, as before.
981
963
  */
982
964
  function refuseReservedHooks(options: HandlerOptions | undefined): void {
983
965
  for (const [tableName, hook] of Object.entries(options?.hooks ?? {})) {
966
+ for (const [oldName, replacement] of [['beforeStamp', 'beforeDelete'], ['afterStamp', 'afterDelete']]) {
967
+ if ((hook as Record<string, unknown> | undefined)?.[oldName] !== undefined) {
968
+ throw new Error(`[everystack/api] hooks.${tableName}.${oldName} was renamed to ${replacement}; update the hook name`);
969
+ }
970
+ }
971
+ if (!isLifecycleTable(tableName, options)) {
972
+ for (const name of ['beforeDestroy', 'afterDestroy']) {
973
+ if ((hook as Record<string, unknown> | undefined)?.[name] !== undefined) {
974
+ throw new Error(`[everystack/model] hooks.${tableName}.${name} runs only when a lifecycle table is destroyed, and ${tableName} is not a lifecycle table. Use beforeDelete or afterDelete, which run at the DELETE`);
975
+ }
976
+ }
977
+ }
984
978
  if (hook && (hook as { beforeRestore?: unknown }).beforeRestore !== undefined) {
985
979
  throw new Error(`[everystack/api] hooks.${tableName}.beforeRestore is reserved for a future release; remove it`);
986
980
  }
@@ -1816,11 +1810,8 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1816
1810
  // 2xx; a throw (a failed commit) skips it, since the write did not happen.
1817
1811
  // A retry wrapper around db.transaction must reset `deferred` per attempt,
1818
1812
  // 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);
1813
+ const deleteTransaction = method === 'DELETE' && !isLifecycleTable(tableName, options)
1814
+ && Boolean(ownEntry(options?.hooks, tableName)?.beforeDelete);
1824
1815
  const deferred: Deferred[] = [];
1825
1816
  let response: Response;
1826
1817
  try {
@@ -1844,7 +1835,10 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1844
1835
  }
1845
1836
 
1846
1837
  return json({ message: `Method ${method} not allowed` }, 405);
1847
- }, destroyTransaction);
1838
+ }, deleteTransaction);
1839
+ } catch (err) {
1840
+ if (!(err instanceof BeforeHookThrew)) throw err;
1841
+ response = beforeHookFailure(err.thrown, options, { requestId, name: `${tableName}.beforeDelete`, method: 'DELETE' }, deferred);
1848
1842
  } finally {
1849
1843
  // emitFault never throws, so this cannot replace the error in flight.
1850
1844
  for (const item of deferred) if (item.always) await item.run();
@@ -2759,8 +2753,8 @@ function pickColumns(row: Record<string, unknown>, columns: string[]): Record<st
2759
2753
 
2760
2754
  /**
2761
2755
  * The response for a before-hook that threw. A HandlerError keeps its own status and
2762
- * message. An outage (the hook could not reach the database) answers the fixed 503,
2763
- * like every other outage path. Anything else is a generic 500 so the hook's text
2756
+ * message. Before-hook outages (create, update and delete) answer the fixed 503. Other non-HandlerError
2757
+ * throws answer a generic 500 so the hook's text
2764
2758
  * never reaches the client. A 5xx is a fault and goes to onError; a 4xx HandlerError
2765
2759
  * is a refusal and does not. The fault is queued, not sent: with pgSettings the hook
2766
2760
  * ran inside the transaction, and a slow onError must not hold it open.
@@ -3224,125 +3218,50 @@ async function handleDelete(
3224
3218
 
3225
3219
  const hook = ownEntry(options?.hooks, tableName);
3226
3220
 
3227
- if (hook?.beforeDelete) {
3228
- try {
3229
- await hook.beforeDelete(user, client);
3230
- } catch (err) {
3231
- return beforeHookFailure(err, options, { requestId, name: tableName, method: 'DELETE' }, deferred);
3232
- }
3233
- }
3234
-
3235
3221
  const conditions = filterResult.conditions;
3236
3222
 
3237
3223
  // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
3238
3224
  if (scope.condition) conditions.push(scope.condition);
3239
3225
 
3240
- // Lifecycle: a real DELETE, which the table's trigger turns into a stamp (it reports 0 rows).
3226
+ const ctx: LifecycleContext = { phase: 'delete', batch: null, user, client };
3227
+ let rows: Record<string, unknown>[];
3241
3228
  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.
3229
+ const deleted = await lifecycleDelete(db, table, tableName, conditions, options, hook?.beforeDelete
3230
+ ? { run: hook.beforeDelete.bind(hook), ctx } : undefined);
3231
+ rows = deleted.rows;
3254
3232
  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
-
3274
- // Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
3275
- if (isSoftDeleteTable(tableName, table, options)) {
3276
- const sdCol = getSoftDeleteColumn(tableName, table, options);
3277
- if (sdCol) {
3278
- // Only soft-delete records that aren't already deleted
3279
- conditions.push(isNull(sdCol));
3280
- const colName = options?.softDelete?.column || 'deletedAt';
3281
- let query: any = db.update(table).set({ [colName]: new Date() });
3282
-
3283
- if (conditions.length === 1) {
3284
- query = query.where(conditions[0]);
3285
- } else {
3286
- query = query.where(and(...conditions));
3287
- }
3288
-
3289
- const rows = await query.returning();
3290
-
3291
- if (hook?.afterDelete && rows.length > 0) {
3292
- const { afterDelete } = hook;
3293
- deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3294
- () => afterDelete.call(hook, rows, user, client));
3295
- }
3296
-
3297
- return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3298
- }
3299
- }
3300
-
3301
- // Hard delete (no soft-delete configured for this table)
3302
- let query: any = db.delete(table);
3303
-
3304
- if (conditions.length === 1) {
3305
- query = query.where(conditions[0]);
3233
+ if (batchKey) for (const [row, batch] of deleted.batchOf) row[batchKey] = batch;
3306
3234
  } else {
3307
- query = query.where(and(...conditions));
3308
- }
3309
-
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); });
3235
+ const sdCol = isSoftDeleteTable(tableName, table, options) && getSoftDeleteColumn(tableName, table, options);
3236
+ if (sdCol) conditions.push(isNull(sdCol));
3237
+ const query: any = sdCol
3238
+ ? db.update(table).set({ [options?.softDelete?.column || 'deletedAt']: new Date() })
3239
+ : db.delete(table);
3240
+ rows = await query.where(conditions.length === 1 ? conditions[0] : and(...conditions)).returning();
3241
+ // RETURNING proves the caller's write authority. A veto or throw leaves the caller's
3242
+ // transaction, so the statement rolls back before the response is made.
3243
+ if (hook?.beforeDelete) for (const row of rows) {
3244
+ let verdict: unknown;
3245
+ try { verdict = await hook.beforeDelete(row, ctx); } catch (err) { throw new BeforeHookThrew(err); }
3246
+ if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
3326
3247
  }
3327
3248
  }
3328
-
3329
3249
  if (hook?.afterDelete && rows.length > 0) {
3330
3250
  const { afterDelete } = hook;
3331
3251
  deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3332
- () => afterDelete.call(hook, rows, user, client));
3252
+ async () => { await afterDelete.call(hook, rows, ctx); });
3333
3253
  }
3334
-
3335
3254
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3336
3255
  }
3337
3256
 
3338
3257
  /**
3339
- * DELETE on a lifecycle table. The trigger stamps each row and cancels its delete, so DELETE
3258
+ * DELETE on a lifecycle table. The trigger marks each row deleted and cancels its removal, so DELETE
3340
3259
  * 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
3260
+ * matches (the pre-image), run beforeDelete on each, DELETE those keys only (with the same
3342
3261
  * conditions), then drop every row still live: RLS or the filter refused its delete. What is left is
3343
3262
  * 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
3263
+ * row as read; a rule that must hold on the current values belongs in deleteWhen, which the trigger
3264
+ * checks on the current row. Under READ COMMITTED a row another request soft-deletes between the read
3346
3265
  * and the DELETE is still listed (and reaches afterDelete): a false attribution, never a leak.
3347
3266
  */
3348
3267
  async function lifecycleDelete(
@@ -3351,7 +3270,7 @@ async function lifecycleDelete(
3351
3270
  tableName: string,
3352
3271
  conditions: SQL[],
3353
3272
  options?: HandlerOptions,
3354
- beforeStamp?: { run: BeforeLifecycleHook; ctx: LifecycleContext },
3273
+ beforeDelete?: { run: BeforeLifecycleHook; ctx: LifecycleContext },
3355
3274
  ): Promise<{ rows: Record<string, unknown>[]; batchOf: Map<Record<string, unknown>, string> }> {
3356
3275
  const sdCol = getSoftDeleteColumn(tableName, table, options);
3357
3276
  const pk = primaryKeyColumns(table);
@@ -3365,21 +3284,21 @@ async function lifecycleDelete(
3365
3284
  // No row lock: FOR UPDATE needs UPDATE privilege, which a delete-only role does not hold.
3366
3285
  const matched: Record<string, unknown>[] = await tx.select().from(table).where(and(...conditions, isNull(sdCol)));
3367
3286
  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) {
3287
+ // beforeDelete: once per matched root, before the DELETE, inside this transaction. A veto or a
3288
+ // throw leaves it, so nothing is soft-deleted.
3289
+ if (beforeDelete) {
3371
3290
  for (const row of matched) {
3372
3291
  let verdict: unknown;
3373
- try { verdict = await beforeStamp.run(row, beforeStamp.ctx); } catch (err) { throw new BeforeHookThrew(err); }
3292
+ try { verdict = await beforeDelete.run(row, beforeDelete.ctx); } catch (err) { throw new BeforeHookThrew(err); }
3374
3293
  if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
3375
3294
  }
3376
3295
  }
3377
- // The transition appends one `<JSON key> <batch>` line per stamped root to this setting (a
3296
+ // The transition appends one `<JSON key> <batch>` line per soft-deleted root to this setting (a
3378
3297
  // 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.
3298
+ // DELETE's lines are read back; informational, the lifecycle columns stay the truth.
3380
3299
  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.
3300
+ // Exactly the rows the hook saw: a row that starts to match while a hook runs is not soft-deleted.
3301
+ // The request filter stays, so a matched row edited out of it meanwhile is not soft-deleted either.
3383
3302
  const matchedKeys = matched.map(row => row[key]);
3384
3303
  await tx.delete(table).where(and(...conditions, isNull(sdCol), inArray(keyColumn, matchedKeys)));
3385
3304
  const stillLive: Record<string, unknown>[] = await tx
@@ -3393,12 +3312,12 @@ async function lifecycleDelete(
3393
3312
  try { byKey.set(String(JSON.parse(line.slice(0, -37))), line.slice(-36)); } catch { /* not a line the transition wrote */ }
3394
3313
  }
3395
3314
  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) {
3315
+ const deleted = matched.filter(row => !live.has(String(row[key])));
3316
+ for (const row of deleted) {
3398
3317
  const batch = byKey.get(String(row[key]));
3399
3318
  if (batch) batchOf.set(row, batch);
3400
3319
  }
3401
- return stamped;
3320
+ return deleted;
3402
3321
  });
3403
3322
  return { rows, batchOf };
3404
3323
  }
@@ -3445,7 +3364,7 @@ async function handleRestore(
3445
3364
  if (hook?.afterRestore && r && typeof r.batch === 'string') {
3446
3365
  try {
3447
3366
  await hook.afterRestore([{ [columnPropertyName(table, key.name) ?? key.name]: id }], {
3448
- phase: 'restore', source: 'api', batch: r.batch, user, client,
3367
+ phase: 'restore', batch: r.batch, user, client,
3449
3368
  counts: { restored: Number(r.restored), skipped: Number(r.skipped) },
3450
3369
  });
3451
3370
  } catch (err) {
@@ -26,11 +26,9 @@ export function isVeto(value: unknown): value is HookVeto {
26
26
 
27
27
  /** The one context every lifecycle hook receives. */
28
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. */
29
+ /** `delete`: a request-level delete. `restore`: a batch came back. `destroy`: physical removal. */
30
+ phase: 'delete' | 'restore' | 'destroy';
31
+ /** Request-level deletes use null. Restore and destroy can carry the row's existing batch. */
34
32
  batch: string | null;
35
33
  user: Record<string, unknown> | null;
36
34
  client: Record<string, unknown> | null;
@@ -46,8 +44,8 @@ export interface DestroyContext extends LifecycleContext {
46
44
  /** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
47
45
  export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
48
46
 
49
- /** A before hook of the lifecycle: called once per root row; returns nothing or a veto. */
47
+ /** Before commit, once per matched root row; returns nothing or a veto. Cascaded children run no hook: guard them with deleteWhen. */
50
48
  export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
51
49
 
52
- /** An after hook of the lifecycle: called after commit with the root rows of one batch. Best-effort. */
50
+ /** After commit, once per request with the root rows. Best-effort; cascaded children run no hook. */
53
51
  export type AfterLifecycleHook = (rows: Record<string, unknown>[], ctx: LifecycleContext) => void | Promise<void>;