@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 +40 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/client/index.ts +4 -1
- package/src/handler/index.ts +161 -119
- package/src/handler/veto.ts +51 -0
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
|
-
|
|
103
|
-
|
|
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.
|
|
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/client/index.ts
CHANGED
|
@@ -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 {
|
package/src/handler/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
/** After
|
|
298
|
-
*
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
1777
|
-
|
|
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
|
-
},
|
|
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.
|
|
2710
|
-
*
|
|
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
|
-
|
|
3226
|
+
const ctx: LifecycleContext = { phase: 'delete', batch: null, user, client };
|
|
3227
|
+
let rows: Record<string, unknown>[];
|
|
3188
3228
|
if (isLifecycleTable(tableName, options)) {
|
|
3189
|
-
const
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
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
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
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,
|
|
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
|
|
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),
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
3265
|
-
*
|
|
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
|
-
|
|
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
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
const
|
|
3282
|
-
const
|
|
3283
|
-
|
|
3284
|
-
const matched: Record<string, unknown>[] = await tx.select().from(table).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
|
-
|
|
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(
|
|
3305
|
+
.select({ [key]: keyColumn })
|
|
3289
3306
|
.from(table)
|
|
3290
|
-
.where(
|
|
3291
|
-
const
|
|
3292
|
-
|
|
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>;
|