@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 +19 -0
- package/README.md +3 -5
- package/package.json +1 -1
- package/src/handler/index.ts +67 -148
- package/src/handler/veto.ts +5 -7
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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>",
|
package/src/handler/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
/** After
|
|
302
|
-
*
|
|
303
|
-
|
|
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
|
|
980
|
-
*
|
|
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
|
|
1820
|
-
|
|
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
|
-
},
|
|
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.
|
|
2763
|
-
*
|
|
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
|
-
|
|
3226
|
+
const ctx: LifecycleContext = { phase: 'delete', batch: null, user, client };
|
|
3227
|
+
let rows: Record<string, unknown>[];
|
|
3241
3228
|
if (isLifecycleTable(tableName, options)) {
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
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
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
3345
|
-
* checks on the current row. Under READ COMMITTED a row another request
|
|
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
|
-
|
|
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
|
-
//
|
|
3369
|
-
// throw leaves it, so nothing is
|
|
3370
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3382
|
-
// The request filter stays, so a matched row edited out of it meanwhile is not
|
|
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
|
|
3397
|
-
for (const row of
|
|
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
|
|
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',
|
|
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) {
|
package/src/handler/veto.ts
CHANGED
|
@@ -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
|
-
/** `
|
|
30
|
-
phase: '
|
|
31
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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>;
|