@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 +21 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/client/index.ts +4 -1
- package/src/handler/index.ts +158 -35
- package/src/handler/veto.ts +53 -0
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.
|
|
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>",
|
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 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.
|
|
298
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
() =>
|
|
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),
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
3265
|
-
*
|
|
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
|
-
|
|
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
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
const
|
|
3282
|
-
const
|
|
3283
|
-
|
|
3284
|
-
const matched: Record<string, unknown>[] = await tx.select().from(table).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
|
-
|
|
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(
|
|
3386
|
+
.select({ [key]: keyColumn })
|
|
3289
3387
|
.from(table)
|
|
3290
|
-
.where(
|
|
3291
|
-
const
|
|
3292
|
-
|
|
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>;
|