@everystack/api 0.4.11 → 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,27 @@ 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
+
7
28
  ## [0.4.11] - 2026-10-23
8
29
 
9
30
  ### Added
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.11",
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>",
@@ -84,7 +84,7 @@ export interface QueryBuilder {
84
84
  update(data: Record<string, unknown>): Promise<ClientResponse<unknown[]>>;
85
85
  remove(): Promise<ClientResponse<unknown[]>>;
86
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 }`. */
87
+ * POSTs `{baseUrl}/{table}/restore` with `{ id }`; the data is `{ restored, skipped, batch }`. */
88
88
  restore(id: string | number): Promise<ClientResponse<RestoreResult>>;
89
89
  }
90
90
 
@@ -92,6 +92,9 @@ export interface QueryBuilder {
92
92
  export interface RestoreResult {
93
93
  restored: number;
94
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;
95
98
  }
96
99
 
97
100
  export interface Client {
@@ -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
@@ -272,15 +285,6 @@ export interface SoftDeleteConfig {
272
285
  lifecycle?: string[];
273
286
  }
274
287
 
275
- /** Context for a real removal. External effects must be idempotent across retries. */
276
- export interface DestroyContext {
277
- source: 'purge' | 'api';
278
- user: Record<string, unknown> | null;
279
- client: Record<string, unknown> | null;
280
- }
281
-
282
- export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | Promise<void>;
283
-
284
288
  export interface HookConfig {
285
289
  /** Called before INSERT. Can modify the body (e.g., inject userId). Throw to abort. */
286
290
  beforeCreate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
@@ -294,14 +298,33 @@ export interface HookConfig {
294
298
  beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
295
299
  /** Called after DELETE with all deleted rows. Fire-and-forget. */
296
300
  afterDelete?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
297
- /** After the hard DELETE statement and before commit, for each returned row. A throw
298
- * rolls back the database delete only. No transaction is passed; external effects
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
299
304
  * cannot be undone and must be idempotent. Lifecycle API deletes do not call this.
300
305
  * Treat the row as read-only: it is the same object later serialized into the response. */
301
306
  beforeDestroy?: DestroyHook;
302
307
  /** After a real removal commits, per returned row, best-effort. Must be idempotent.
303
308
  * Treat the row as read-only: it is the same object later serialized into the response. */
304
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;
305
328
  }
306
329
 
307
330
  /** Cache-Control header configuration for different response categories. */
@@ -463,6 +486,8 @@ export interface HandlerOptions {
463
486
 
464
487
  /** The lifecycle machinery's timestamp column: fixed, whatever softDelete.column says. */
465
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';
466
491
 
467
492
  function isLifecycleTable(tableName: string, options?: HandlerOptions): boolean {
468
493
  return options?.softDelete?.lifecycle?.includes(tableName) ?? false;
@@ -950,6 +975,18 @@ function declaredTables(options: HandlerOptions): Set<string> {
950
975
  return new Set(names.map(toSnakeCase));
951
976
  }
952
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
+
953
990
  /**
954
991
  * Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
955
992
  * needs a user, so rowOwnership without auth is an error. An entry whose column does not
@@ -1336,6 +1373,11 @@ function mapIntegrityError(
1336
1373
  if (constraint !== undefined) body.constraint = constraint;
1337
1374
  if (code === '23502' && typeof fields.column === 'string') body.column = fields.column;
1338
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
+ }
1339
1381
  // A failing trigger or FK action can name a different table than the URL.
1340
1382
  // Missing driver tables (and unknown RPC policies) use every configured list.
1341
1383
  const driverTable = typeof fields.table === 'string' ? fields.table : undefined;
@@ -1368,6 +1410,7 @@ function validateConstraintMessages(options?: HandlerOptions): void {
1368
1410
  }
1369
1411
 
1370
1412
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
1413
+ refuseReservedHooks(rawOptions);
1371
1414
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
1372
1415
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
1373
1416
  // multi-word table failed one gate or the other — both spellings 404'd. Collapse
@@ -1822,6 +1865,16 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1822
1865
  }
1823
1866
  return response;
1824
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
+ }
1825
1878
  const appError = error instanceof HandlerError;
1826
1879
  const unavailable = !appError && isServiceUnavailable(error);
1827
1880
  const integrity = appError || unavailable ? undefined : mapIntegrityError(error, tableName, options);
@@ -3186,12 +3239,35 @@ async function handleDelete(
3186
3239
 
3187
3240
  // Lifecycle: a real DELETE, which the table's trigger turns into a stamp (it reports 0 rows).
3188
3241
  if (isLifecycleTable(tableName, options)) {
3189
- const rows = await lifecycleDelete(db, table, tableName, conditions, 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;
3190
3256
  if (hook?.afterDelete && rows.length > 0) {
3191
3257
  const { afterDelete } = hook;
3192
3258
  deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3193
3259
  () => afterDelete.call(hook, rows, user, client));
3194
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
+ }
3195
3271
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3196
3272
  }
3197
3273
 
@@ -3235,15 +3311,18 @@ async function handleDelete(
3235
3311
 
3236
3312
  // RETURNING proves DELETE authority, including policies that differ from SELECT.
3237
3313
  // The dispatcher keeps this statement and beforeDestroy in one caller transaction.
3238
- const ctx: DestroyContext = { source: 'api', user, client };
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 };
3239
3316
  for (const row of rows) {
3240
- if (hook?.beforeDestroy) await hook.beforeDestroy(row, ctx);
3317
+ if (!hook?.beforeDestroy) break;
3318
+ const verdict = await hook.beforeDestroy(row, ctx);
3319
+ if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
3241
3320
  }
3242
3321
  if (hook?.afterDestroy) {
3243
3322
  const { afterDestroy } = hook;
3244
3323
  for (const row of rows) {
3245
3324
  deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDestroy`, method: 'DELETE' },
3246
- () => Promise.resolve(afterDestroy.call(hook, row, ctx)));
3325
+ async () => { await afterDestroy.call(hook, row, ctx); });
3247
3326
  }
3248
3327
  }
3249
3328
 
@@ -3259,10 +3338,12 @@ async function handleDelete(
3259
3338
  /**
3260
3339
  * DELETE on a lifecycle table. The trigger stamps each row and cancels its delete, so DELETE
3261
3340
  * reports 0 rows and RETURNING has nothing. In one transaction: read the live rows the request
3262
- * matches (the pre-image), DELETE with the same conditions, then drop every row still live: RLS
3263
- * refused its delete. What is left is the pre-image of the rows this request soft-deleted.
3264
- * Under READ COMMITTED a row another request stamps between the read and the DELETE is still
3265
- * listed (and reaches afterDelete): a false attribution, never a leak, since the caller could read it.
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.
3266
3347
  */
3267
3348
  async function lifecycleDelete(
3268
3349
  db: DrizzleDb,
@@ -3270,27 +3351,56 @@ async function lifecycleDelete(
3270
3351
  tableName: string,
3271
3352
  conditions: SQL[],
3272
3353
  options?: HandlerOptions,
3273
- ): Promise<Record<string, unknown>[]> {
3354
+ beforeStamp?: { run: BeforeLifecycleHook; ctx: LifecycleContext },
3355
+ ): Promise<{ rows: Record<string, unknown>[]; batchOf: Map<Record<string, unknown>, string> }> {
3274
3356
  const sdCol = getSoftDeleteColumn(tableName, table, options);
3275
- const keys = primaryKeyColumns(table)
3276
- .map(column => columnPropertyName(table, column.name))
3277
- .filter((key): key is string => key !== undefined);
3278
- if (!sdCol || keys.length === 0) {
3279
- throw new Error(`[everystack/api] softDelete.lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a primary key`);
3280
- }
3281
- const where = and(...conditions, isNull(sdCol))!;
3282
- const keyOf = (row: Record<string, unknown>): string => JSON.stringify(keys.map(key => row[key]));
3283
- return db.transaction(async (tx: DrizzleDb) => {
3284
- const matched: Record<string, unknown>[] = await tx.select().from(table).where(where);
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)));
3285
3367
  if (matched.length === 0) return [];
3286
- await tx.delete(table).where(where);
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)));
3287
3385
  const stillLive: Record<string, unknown>[] = await tx
3288
- .select(Object.fromEntries(keys.map(key => [key, getTableColumns(table)[key]])))
3386
+ .select({ [key]: keyColumn })
3289
3387
  .from(table)
3290
- .where(where);
3291
- const live = new Set(stillLive.map(keyOf));
3292
- return matched.filter(row => !live.has(keyOf(row)));
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;
3293
3402
  });
3403
+ return { rows, batchOf };
3294
3404
  }
3295
3405
 
3296
3406
  /**
@@ -3329,6 +3439,19 @@ async function handleRestore(
3329
3439
  const rows = await tx.execute(sql`SELECT ${fn}(CAST(${id} AS ${sql.raw(keyType)})) AS result`);
3330
3440
  return (rows as unknown as Array<{ result: unknown }>)[0]?.result;
3331
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
+ }
3332
3455
  return json(result, 200);
3333
3456
  } catch (error) {
3334
3457
  // Code and message from the one object that carries the code: a wrapper's message can quote the query.
@@ -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>;