@everystack/api 0.4.11 → 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,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.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
+
26
+ ## [0.4.12] - 2026-10-23
27
+
28
+ ### BREAKING CHANGES
29
+
30
+ - lifecycle delete and restore hand the batch id to the caller
31
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
32
+ - one lifecycle hook API: (rows, ctx), veto(reason), beforeStamp
33
+
34
+ ### Added
35
+
36
+ - afterStamp and afterRestore hooks hand a lifecycle batch to app code
37
+
38
+ ### Fixed
39
+
40
+ - a lifecycle DELETE stamps only the rows beforeStamp saw
41
+
42
+ ### Documentation
43
+
44
+ - afterStamp, afterRestore and the batch in the restore response
45
+ - Extending lifecycle, the phase table, the veto and failure rules
46
+
7
47
  ## [0.4.11] - 2026-10-23
8
48
 
9
49
  ### Added
package/README.md CHANGED
@@ -99,8 +99,10 @@ 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) => {},
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
105
+ afterRestore: async (rows, ctx) => {}, // after commit, ctx.batch and ctx.counts
104
106
  },
105
107
  },
106
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.11",
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>",
@@ -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 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 delete 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>>;
@@ -290,18 +294,19 @@ export interface HookConfig {
290
294
  beforeUpdate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
291
295
  /** Called after UPDATE with all returned rows. Fire-and-forget. */
292
296
  afterUpdate?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
293
- /** Called before DELETE. Throw to abort. */
294
- beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
295
- /** Called after DELETE with all deleted rows. Fire-and-forget. */
296
- afterDelete?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
297
- /** After the hard DELETE statement and before commit, for each returned row. A throw
298
- * rolls back the database delete only. No transaction is passed; external effects
299
- * cannot be undone and must be idempotent. Lifecycle API deletes do not call this.
300
- * Treat the row as read-only: it is the same object later serialized into the response. */
301
- beforeDestroy?: DestroyHook;
302
- /** After a real removal commits, per returned row, best-effort. Must be idempotent.
303
- * Treat the row as read-only: it is the same object later serialized into the response. */
304
- afterDestroy?: DestroyHook;
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;
304
+ /** Lifecycle tables only: after `POST {table}/restore` COMMITS, with `[{ <key>: id }]` (the id as
305
+ * the request sent it), `ctx.batch` and `ctx.counts`. Not called for a row deleted before batches
306
+ * existed (batch null). Best-effort: a throw goes to onError and never changes the response. */
307
+ afterRestore?: AfterLifecycleHook;
308
+ /** Reserved for a future release: createHandler refuses it, so it is never silently ignored. */
309
+ beforeRestore?: never;
305
310
  }
306
311
 
307
312
  /** Cache-Control header configuration for different response categories. */
@@ -463,6 +468,8 @@ export interface HandlerOptions {
463
468
 
464
469
  /** The lifecycle machinery's timestamp column: fixed, whatever softDelete.column says. */
465
470
  const LIFECYCLE_DELETED_AT = 'deletedAt';
471
+ /** The SQL name of the lifecycle batch column (columnPropertyName maps it to the table's key). */
472
+ const LIFECYCLE_DELETED_BATCH = 'deleted_batch';
466
473
 
467
474
  function isLifecycleTable(tableName: string, options?: HandlerOptions): boolean {
468
475
  return options?.softDelete?.lifecycle?.includes(tableName) ?? false;
@@ -950,6 +957,30 @@ function declaredTables(options: HandlerOptions): Set<string> {
950
957
  return new Set(names.map(toSnakeCase));
951
958
  }
952
959
 
960
+ /**
961
+ * Refuse removed and reserved hook names so a guard cannot be silently ignored.
962
+ * Other unknown hook keys are ignored, as before.
963
+ */
964
+ function refuseReservedHooks(options: HandlerOptions | undefined): void {
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
+ }
978
+ if (hook && (hook as { beforeRestore?: unknown }).beforeRestore !== undefined) {
979
+ throw new Error(`[everystack/api] hooks.${tableName}.beforeRestore is reserved for a future release; remove it`);
980
+ }
981
+ }
982
+ }
983
+
953
984
  /**
954
985
  * Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
955
986
  * needs a user, so rowOwnership without auth is an error. An entry whose column does not
@@ -1336,6 +1367,11 @@ function mapIntegrityError(
1336
1367
  if (constraint !== undefined) body.constraint = constraint;
1337
1368
  if (code === '23502' && typeof fields.column === 'string') body.column = fields.column;
1338
1369
  }
1370
+ // lifecycle: { deleteWhen }'s guard (`<table>_lifecycle_delete_when`): a stable message, never a
1371
+ // row value. An author's constraintMessages entry for the name still wins.
1372
+ if (declared === undefined && code === '23514' && constraint?.endsWith('_lifecycle_delete_when')) {
1373
+ body.message = "Deleting this row is not allowed by its model's deleteWhen.";
1374
+ }
1339
1375
  // A failing trigger or FK action can name a different table than the URL.
1340
1376
  // Missing driver tables (and unknown RPC policies) use every configured list.
1341
1377
  const driverTable = typeof fields.table === 'string' ? fields.table : undefined;
@@ -1368,6 +1404,7 @@ function validateConstraintMessages(options?: HandlerOptions): void {
1368
1404
  }
1369
1405
 
1370
1406
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
1407
+ refuseReservedHooks(rawOptions);
1371
1408
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
1372
1409
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
1373
1410
  // multi-word table failed one gate or the other — both spellings 404'd. Collapse
@@ -1773,11 +1810,8 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1773
1810
  // 2xx; a throw (a failed commit) skips it, since the write did not happen.
1774
1811
  // A retry wrapper around db.transaction must reset `deferred` per attempt,
1775
1812
  // or the hooks of an aborted attempt would run too.
1776
- const destroyHook = ownEntry(options?.hooks, tableName);
1777
- const destroyTransaction = method === 'DELETE'
1778
- && !isLifecycleTable(tableName, options)
1779
- && !(isSoftDeleteTable(tableName, table, options) && getSoftDeleteColumn(tableName, table, options))
1780
- && Boolean(destroyHook?.beforeDestroy || destroyHook?.afterDestroy);
1813
+ const deleteTransaction = method === 'DELETE' && !isLifecycleTable(tableName, options)
1814
+ && Boolean(ownEntry(options?.hooks, tableName)?.beforeDelete);
1781
1815
  const deferred: Deferred[] = [];
1782
1816
  let response: Response;
1783
1817
  try {
@@ -1801,7 +1835,10 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1801
1835
  }
1802
1836
 
1803
1837
  return json({ message: `Method ${method} not allowed` }, 405);
1804
- }, 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);
1805
1842
  } finally {
1806
1843
  // emitFault never throws, so this cannot replace the error in flight.
1807
1844
  for (const item of deferred) if (item.always) await item.run();
@@ -1822,6 +1859,16 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
1822
1859
  }
1823
1860
  return response;
1824
1861
  } catch (error) {
1862
+ // A before hook's veto: a decision, not a fault. Its transaction has rolled back.
1863
+ if (error instanceof VetoRefusal) {
1864
+ if (isWrite) {
1865
+ await emitAudit(options?.audit, {
1866
+ kind: 'rest', name: tableName, user, client, result: 'deny',
1867
+ requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
1868
+ });
1869
+ }
1870
+ return json({ message: error.reason, code: 'vetoed' }, 409);
1871
+ }
1825
1872
  const appError = error instanceof HandlerError;
1826
1873
  const unavailable = !appError && isServiceUnavailable(error);
1827
1874
  const integrity = appError || unavailable ? undefined : mapIntegrityError(error, tableName, options);
@@ -2706,8 +2753,8 @@ function pickColumns(row: Record<string, unknown>, columns: string[]): Record<st
2706
2753
 
2707
2754
  /**
2708
2755
  * The response for a before-hook that threw. A HandlerError keeps its own status and
2709
- * message. An outage (the hook could not reach the database) answers the fixed 503,
2710
- * 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
2711
2758
  * never reaches the client. A 5xx is a fault and goes to onError; a 4xx HandlerError
2712
2759
  * is a refusal and does not. The fault is queued, not sent: with pgSettings the hook
2713
2760
  * ran inside the transaction, and a slow onError must not hold it open.
@@ -3171,98 +3218,51 @@ async function handleDelete(
3171
3218
 
3172
3219
  const hook = ownEntry(options?.hooks, tableName);
3173
3220
 
3174
- if (hook?.beforeDelete) {
3175
- try {
3176
- await hook.beforeDelete(user, client);
3177
- } catch (err) {
3178
- return beforeHookFailure(err, options, { requestId, name: tableName, method: 'DELETE' }, deferred);
3179
- }
3180
- }
3181
-
3182
3221
  const conditions = filterResult.conditions;
3183
3222
 
3184
3223
  // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
3185
3224
  if (scope.condition) conditions.push(scope.condition);
3186
3225
 
3187
- // 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>[];
3188
3228
  if (isLifecycleTable(tableName, options)) {
3189
- const rows = await lifecycleDelete(db, table, tableName, conditions, options);
3190
- if (hook?.afterDelete && rows.length > 0) {
3191
- const { afterDelete } = hook;
3192
- deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3193
- () => afterDelete.call(hook, rows, user, client));
3194
- }
3195
- return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3196
- }
3197
-
3198
- // Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
3199
- if (isSoftDeleteTable(tableName, table, options)) {
3200
- const sdCol = getSoftDeleteColumn(tableName, table, options);
3201
- if (sdCol) {
3202
- // Only soft-delete records that aren't already deleted
3203
- conditions.push(isNull(sdCol));
3204
- const colName = options?.softDelete?.column || 'deletedAt';
3205
- let query: any = db.update(table).set({ [colName]: new Date() });
3206
-
3207
- if (conditions.length === 1) {
3208
- query = query.where(conditions[0]);
3209
- } else {
3210
- query = query.where(and(...conditions));
3211
- }
3212
-
3213
- const rows = await query.returning();
3214
-
3215
- if (hook?.afterDelete && rows.length > 0) {
3216
- const { afterDelete } = hook;
3217
- deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3218
- () => afterDelete.call(hook, rows, user, client));
3219
- }
3220
-
3221
- return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3222
- }
3223
- }
3224
-
3225
- // Hard delete (no soft-delete configured for this table)
3226
- let query: any = db.delete(table);
3227
-
3228
- if (conditions.length === 1) {
3229
- query = query.where(conditions[0]);
3229
+ const deleted = await lifecycleDelete(db, table, tableName, conditions, options, hook?.beforeDelete
3230
+ ? { run: hook.beforeDelete.bind(hook), ctx } : undefined);
3231
+ rows = deleted.rows;
3232
+ const batchKey = columnPropertyName(table, LIFECYCLE_DELETED_BATCH);
3233
+ if (batchKey) for (const [row, batch] of deleted.batchOf) row[batchKey] = batch;
3230
3234
  } else {
3231
- query = query.where(and(...conditions));
3232
- }
3233
-
3234
- const rows: Record<string, unknown>[] = await query.returning();
3235
-
3236
- // RETURNING proves DELETE authority, including policies that differ from SELECT.
3237
- // The dispatcher keeps this statement and beforeDestroy in one caller transaction.
3238
- const ctx: DestroyContext = { source: 'api', user, client };
3239
- for (const row of rows) {
3240
- if (hook?.beforeDestroy) await hook.beforeDestroy(row, ctx);
3241
- }
3242
- if (hook?.afterDestroy) {
3243
- const { afterDestroy } = hook;
3244
- for (const row of rows) {
3245
- deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDestroy`, method: 'DELETE' },
3246
- () => Promise.resolve(afterDestroy.call(hook, row, ctx)));
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);
3247
3247
  }
3248
3248
  }
3249
-
3250
3249
  if (hook?.afterDelete && rows.length > 0) {
3251
3250
  const { afterDelete } = hook;
3252
3251
  deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
3253
- () => afterDelete.call(hook, rows, user, client));
3252
+ async () => { await afterDelete.call(hook, rows, ctx); });
3254
3253
  }
3255
-
3256
3254
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
3257
3255
  }
3258
3256
 
3259
3257
  /**
3260
- * 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
3261
3259
  * 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.
3260
+ * matches (the pre-image), run beforeDelete on each, DELETE those keys only (with the same
3261
+ * conditions), then drop every row still live: RLS or the filter refused its delete. What is left is
3262
+ * the pre-image of the rows this request soft-deleted. The read takes no lock, so a hook sees the
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
3265
+ * and the DELETE is still listed (and reaches afterDelete): a false attribution, never a leak.
3266
3266
  */
3267
3267
  async function lifecycleDelete(
3268
3268
  db: DrizzleDb,
@@ -3270,27 +3270,56 @@ async function lifecycleDelete(
3270
3270
  tableName: string,
3271
3271
  conditions: SQL[],
3272
3272
  options?: HandlerOptions,
3273
- ): Promise<Record<string, unknown>[]> {
3273
+ beforeDelete?: { run: BeforeLifecycleHook; ctx: LifecycleContext },
3274
+ ): Promise<{ rows: Record<string, unknown>[]; batchOf: Map<Record<string, unknown>, string> }> {
3274
3275
  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);
3276
+ const pk = primaryKeyColumns(table);
3277
+ const key = pk.length === 1 ? columnPropertyName(table, pk[0].name) : undefined;
3278
+ if (!sdCol || key === undefined) {
3279
+ throw new Error(`[everystack/api] softDelete.lifecycle.${tableName}: the table needs a ${LIFECYCLE_DELETED_AT} column and a single-column primary key`);
3280
+ }
3281
+ const keyColumn = getTableColumns(table)[key];
3282
+ const batchOf = new Map<Record<string, unknown>, string>();
3283
+ const rows = await db.transaction(async (tx: DrizzleDb) => {
3284
+ // No row lock: FOR UPDATE needs UPDATE privilege, which a delete-only role does not hold.
3285
+ const matched: Record<string, unknown>[] = await tx.select().from(table).where(and(...conditions, isNull(sdCol)));
3285
3286
  if (matched.length === 0) return [];
3286
- await tx.delete(table).where(where);
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) {
3290
+ for (const row of matched) {
3291
+ let verdict: unknown;
3292
+ try { verdict = await beforeDelete.run(row, beforeDelete.ctx); } catch (err) { throw new BeforeHookThrew(err); }
3293
+ if (isVeto(verdict)) throw new VetoRefusal(verdict.reason);
3294
+ }
3295
+ }
3296
+ // The transition appends one `<JSON key> <batch>` line per soft-deleted root to this setting (a
3297
+ // caller that is not a manage role cannot read deleted_batch). Cleared first, so only this
3298
+ // DELETE's lines are read back; informational, the lifecycle columns stay the truth.
3299
+ await tx.execute(sql`SELECT pg_catalog.set_config('everystack.lifecycle_batch', '', true)`);
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.
3302
+ const matchedKeys = matched.map(row => row[key]);
3303
+ await tx.delete(table).where(and(...conditions, isNull(sdCol), inArray(keyColumn, matchedKeys)));
3287
3304
  const stillLive: Record<string, unknown>[] = await tx
3288
- .select(Object.fromEntries(keys.map(key => [key, getTableColumns(table)[key]])))
3305
+ .select({ [key]: keyColumn })
3289
3306
  .from(table)
3290
- .where(where);
3291
- const live = new Set(stillLive.map(keyOf));
3292
- return matched.filter(row => !live.has(keyOf(row)));
3307
+ .where(and(isNull(sdCol), inArray(keyColumn, matchedKeys)));
3308
+ const lines = await tx.execute(sql`SELECT pg_catalog.current_setting('everystack.lifecycle_batch', true) AS v`) as unknown as Array<{ v: string | null }>;
3309
+ const byKey = new Map<string, string>();
3310
+ for (const line of String(lines[0]?.v ?? '').split('\n')) {
3311
+ if (line.length < 38) continue;
3312
+ try { byKey.set(String(JSON.parse(line.slice(0, -37))), line.slice(-36)); } catch { /* not a line the transition wrote */ }
3313
+ }
3314
+ const live = new Set(stillLive.map(row => String(row[key])));
3315
+ const deleted = matched.filter(row => !live.has(String(row[key])));
3316
+ for (const row of deleted) {
3317
+ const batch = byKey.get(String(row[key]));
3318
+ if (batch) batchOf.set(row, batch);
3319
+ }
3320
+ return deleted;
3293
3321
  });
3322
+ return { rows, batchOf };
3294
3323
  }
3295
3324
 
3296
3325
  /**
@@ -3329,6 +3358,19 @@ async function handleRestore(
3329
3358
  const rows = await tx.execute(sql`SELECT ${fn}(CAST(${id} AS ${sql.raw(keyType)})) AS result`);
3330
3359
  return (rows as unknown as Array<{ result: unknown }>)[0]?.result;
3331
3360
  });
3361
+ // After the commit (executeWithSettings has returned). Best-effort: never changes the response.
3362
+ const hook = ownEntry(options?.hooks, tableName);
3363
+ const r = result as { restored?: unknown; skipped?: unknown; batch?: unknown } | null;
3364
+ if (hook?.afterRestore && r && typeof r.batch === 'string') {
3365
+ try {
3366
+ await hook.afterRestore([{ [columnPropertyName(table, key.name) ?? key.name]: id }], {
3367
+ phase: 'restore', batch: r.batch, user, client,
3368
+ counts: { restored: Number(r.restored), skipped: Number(r.skipped) },
3369
+ });
3370
+ } catch (err) {
3371
+ await reportHookFault(options?.onError, { requestId, name: `${tableName}.afterRestore`, method: 'POST' }, err);
3372
+ }
3373
+ }
3332
3374
  return json(result, 200);
3333
3375
  } catch (error) {
3334
3376
  // Code and message from the one object that carries the code: a wrapper's message can quote the query.
@@ -0,0 +1,51 @@
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
+ /** `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. */
32
+ batch: string | null;
33
+ user: Record<string, unknown> | null;
34
+ client: Record<string, unknown> | null;
35
+ /** Restore only: what the restore function reported. */
36
+ counts?: { restored: number; skipped: number };
37
+ }
38
+
39
+ /** Context for a real removal. External effects must be idempotent across retries. */
40
+ export interface DestroyContext extends LifecycleContext {
41
+ phase: 'destroy';
42
+ }
43
+
44
+ /** beforeDestroy may return {@link veto}; afterDestroy's return value is ignored. */
45
+ export type DestroyHook = (row: Record<string, unknown>, ctx: DestroyContext) => void | HookVeto | Promise<void | HookVeto>;
46
+
47
+ /** Before commit, once per matched root row; returns nothing or a veto. Cascaded children run no hook: guard them with deleteWhen. */
48
+ export type BeforeLifecycleHook = (row: Record<string, unknown>, ctx: LifecycleContext) => void | HookVeto | Promise<void | HookVeto>;
49
+
50
+ /** After commit, once per request with the root rows. Best-effort; cascaded children run no hook. */
51
+ export type AfterLifecycleHook = (rows: Record<string, unknown>[], ctx: LifecycleContext) => void | Promise<void>;