@everystack/server 0.4.38 → 0.4.39
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 +8 -0
- package/package.json +3 -3
- package/src/purge.ts +46 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to `@everystack/server` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.39] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
|
|
12
|
+
- one lifecycle hook API: (rows, ctx), veto(reason), beforeStamp
|
|
13
|
+
- deleteWhen spliced on its own lines; a deleted reader is not granted deleted_by
|
|
14
|
+
|
|
7
15
|
## [0.4.38] - 2026-10-23
|
|
8
16
|
|
|
9
17
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.39",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -197,11 +197,11 @@
|
|
|
197
197
|
"sst": "4.13.1",
|
|
198
198
|
"ts-jest": "29.4.9",
|
|
199
199
|
"typescript": "5.9.3",
|
|
200
|
-
"@everystack/cli": "0.4.
|
|
200
|
+
"@everystack/cli": "0.4.103",
|
|
201
201
|
"@everystack/auth": "0.4.9"
|
|
202
202
|
},
|
|
203
203
|
"dependencies": {
|
|
204
|
-
"@everystack/model": "0.4.
|
|
204
|
+
"@everystack/model": "0.4.24"
|
|
205
205
|
},
|
|
206
206
|
"scripts": {
|
|
207
207
|
"test": "jest",
|
package/src/purge.ts
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { performance } from 'node:perf_hooks';
|
|
4
4
|
import { sql, type SQL } from 'drizzle-orm';
|
|
5
|
-
import type
|
|
5
|
+
import { isVeto, type ModelDescriptor, type DestroyContext } from '@everystack/model';
|
|
6
6
|
import type { SessionTx } from './db-session';
|
|
7
7
|
import { unsupportedPurgeGraphs } from './purge-graph';
|
|
8
8
|
export { ENSURE_PURGE_LOG_SQL } from './purge-log';
|
|
9
|
+
export { veto, isVeto } from '@everystack/model';
|
|
10
|
+
export type { HookVeto, LifecycleContext, DestroyContext, DestroyHook } from '@everystack/model';
|
|
9
11
|
|
|
10
12
|
/** A destroy hook failed after physical removal committed. */
|
|
13
|
+
/** A row lifecycle: { deleteWhen } no longer allows: kept, recorded, never cooled down. */
|
|
14
|
+
const PROTECTED_BY_DELETE_WHEN = 'protected by deleteWhen';
|
|
15
|
+
/** beforeDestroy returned veto(reason): the row is kept with a `skipped` receipt `held: <reason>`, never cooled down. */
|
|
16
|
+
const HELD = 'held: ';
|
|
17
|
+
/** Thrown inside the removal savepoint to roll it back when beforeDestroy vetoes. */
|
|
18
|
+
class HeldByHook extends Error {}
|
|
19
|
+
|
|
11
20
|
export interface PurgeHookFault {
|
|
12
21
|
kind: 'hook';
|
|
13
22
|
name: string;
|
|
@@ -47,7 +56,9 @@ export interface PurgeResult {
|
|
|
47
56
|
|
|
48
57
|
type Row = Record<string, unknown>;
|
|
49
58
|
type Receipt = { model: ModelDescriptor; row: Row; outcome: 'purged' | 'skipped' | 'failed'; hook: 'ran' | 'none' | 'failed'; detail?: string };
|
|
50
|
-
const
|
|
59
|
+
const contextFor = (row: Row): DestroyContext => Object.freeze({
|
|
60
|
+
phase: 'destroy', source: 'purge', batch: typeof row.deleted_batch === 'string' ? row.deleted_batch : null, user: null, client: null,
|
|
61
|
+
});
|
|
51
62
|
const param = (value: unknown): unknown => value instanceof Date ? value.toISOString() : typeof value === 'bigint' ? value.toString() : value;
|
|
52
63
|
const snake = (name: string): string => name.replace(/[A-Z]/g, c => '_' + c.toLowerCase());
|
|
53
64
|
const label = (m: ModelDescriptor): string => `${m.schema}.${m.table}`;
|
|
@@ -145,8 +156,10 @@ async function appendReceipt(tx: SessionTx, runId: string, now: Date, receipt: R
|
|
|
145
156
|
* Purge expired lifecycle rows, children first, in one transaction per selected row.
|
|
146
157
|
* The executor is the existing ops Drizzle database (execute/transaction); its caller
|
|
147
158
|
* must read the selected tables and purge_log, and SET ROLE to everystack_lifecycle. Hooks run outside
|
|
148
|
-
* that role after DELETE and immediate constraint checks
|
|
149
|
-
*
|
|
159
|
+
* that role after DELETE and immediate constraint checks, with `{ phase: 'destroy', source: 'purge',
|
|
160
|
+
* batch: <the row's deleted_batch> }`. A hook throw rolls back the removal (failed receipt, retry
|
|
161
|
+
* cooldown). A returned `veto(reason)` rolls it back too, as a decision: a `skipped` receipt with
|
|
162
|
+
* detail `held: <reason>`, no cooldown, so the next run asks again. A successful hook can repeat if commit fails, so it must be idempotent. A skipped row appends another
|
|
150
163
|
* skipped receipt on each retry. Plain children with ON DELETE CASCADE are removed by
|
|
151
164
|
* referential integrity; they receive neither a destroy hook nor a purge receipt.
|
|
152
165
|
* The receipt's hook status describes beforeDestroy. afterDestroy is best-effort after
|
|
@@ -198,6 +211,8 @@ export async function purgeExpired(exec: SessionTx, models: ModelDescriptor[], o
|
|
|
198
211
|
SELECT 1 FROM everystack.purge_log AS receipt
|
|
199
212
|
WHERE receipt."table" = ${name} AND ${cooldownKeyMatches(model)}
|
|
200
213
|
AND receipt.outcome IN ('skipped', 'failed') AND receipt.purged_at > ${retryCutoff.toISOString()}
|
|
214
|
+
AND receipt.detail IS DISTINCT FROM ${PROTECTED_BY_DELETE_WHEN}
|
|
215
|
+
AND NOT (receipt.outcome = 'skipped' AND coalesce(receipt.detail, '') LIKE ${HELD + '%'})
|
|
201
216
|
)` : sql``;
|
|
202
217
|
const page = rowsOf(await exec.execute(sql`SELECT * FROM ${table(model)}
|
|
203
218
|
WHERE deleted_at <= ${cutoff.toISOString()} ${cooldown} ${cursorValues ? sql`AND (${tuple}) > (${cursorValues})` : sql``}
|
|
@@ -220,6 +235,7 @@ export async function purgeExpired(exec: SessionTx, models: ModelDescriptor[], o
|
|
|
220
235
|
let beforeRan = false;
|
|
221
236
|
let skippedReason: string | undefined;
|
|
222
237
|
let failureReason: string | undefined;
|
|
238
|
+
let held: string | undefined;
|
|
223
239
|
const removed = await exec.transaction(async tx => {
|
|
224
240
|
const [isolation] = rowsOf(await tx.execute(sql`SHOW transaction_isolation`));
|
|
225
241
|
if (isolation?.transaction_isolation !== 'read committed') throw new Error('purge requires READ COMMITTED');
|
|
@@ -235,6 +251,8 @@ export async function purgeExpired(exec: SessionTx, models: ModelDescriptor[], o
|
|
|
235
251
|
SELECT 1 FROM everystack.purge_log AS receipt
|
|
236
252
|
WHERE receipt."table" = ${name} AND ${cooldownKeyMatches(model)}
|
|
237
253
|
AND receipt.outcome IN ('skipped', 'failed') AND receipt.purged_at > ${retryCutoff.toISOString()}
|
|
254
|
+
AND receipt.detail IS DISTINCT FROM ${PROTECTED_BY_DELETE_WHEN}
|
|
255
|
+
AND NOT (receipt.outcome = 'skipped' AND coalesce(receipt.detail, '') LIKE ${HELD + '%'})
|
|
238
256
|
)`));
|
|
239
257
|
if (cooling.length) { skippedReason = 'retry cooldown'; return false; }
|
|
240
258
|
}
|
|
@@ -255,19 +273,39 @@ export async function purgeExpired(exec: SessionTx, models: ModelDescriptor[], o
|
|
|
255
273
|
const caller = model.hooks?.beforeDestroy
|
|
256
274
|
? rowsOf(await work.execute(sql`SELECT current_user AS role`))[0] : undefined;
|
|
257
275
|
await work.execute(sql`SET LOCAL ROLE everystack_lifecycle`);
|
|
258
|
-
|
|
276
|
+
// lifecycle: { deleteWhen } — a drift guard, not the main gate: the stamp already passed the
|
|
277
|
+
// guard trigger, but the predicate's columns may have changed since. A row it no longer
|
|
278
|
+
// allows stays, with a `skipped` receipt that sets no retry cooldown (the next run looks again).
|
|
279
|
+
// On lines of its own: a trailing `--` comment in the predicate must not hide RETURNING.
|
|
280
|
+
const guard = model.deleteWhen !== undefined ? sql` AND (\n${sql.raw(model.deleteWhen)}\n)` : sql``;
|
|
281
|
+
const deleted = rowsOf(await work.execute(sql`DELETE FROM ${table(model)} WHERE ${keyWhere(model, row)}${guard} RETURNING ${sql.join(model.primaryKey.map(column), sql`, `)}`));
|
|
282
|
+
if (model.deleteWhen !== undefined && deleted.length === 0) {
|
|
283
|
+
skippedReason = PROTECTED_BY_DELETE_WHEN;
|
|
284
|
+
await appendReceipt(work, result.runId, now, { model, row, outcome: 'skipped', hook: 'none', detail: skippedReason });
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
259
287
|
if (deleted.length !== 1 || keyOf(model, deleted[0]) !== keyOf(model, row)) throw new Error('purge DELETE did not return the locked key');
|
|
260
288
|
// Refuse deferred constraints before invoking a hook with external effects.
|
|
261
289
|
await work.execute(sql`SET CONSTRAINTS ALL IMMEDIATE`);
|
|
262
290
|
if (caller) await work.execute(sql`SET LOCAL ROLE ${sql.identifier(String(caller.role))}`);
|
|
263
291
|
if (model.hooks?.beforeDestroy) {
|
|
264
|
-
try {
|
|
265
|
-
|
|
292
|
+
try {
|
|
293
|
+
const verdict = await model.hooks.beforeDestroy(hookRow(model, row), contextFor(row));
|
|
294
|
+
beforeRan = true;
|
|
295
|
+
if (isVeto(verdict)) held = HELD + verdict.reason;
|
|
296
|
+
} catch (error) { beforeFailed = true; throw error; }
|
|
297
|
+
// A veto rolls back the removal like a throw, but it is a decision, not a failure.
|
|
298
|
+
if (held !== undefined) throw new HeldByHook(held);
|
|
266
299
|
}
|
|
267
300
|
await appendReceipt(work, result.runId, now, { model, row, outcome: 'purged', hook: beforeRan ? 'ran' : 'none' });
|
|
268
301
|
return true;
|
|
269
302
|
});
|
|
270
303
|
} catch (error) {
|
|
304
|
+
if (error instanceof HeldByHook) {
|
|
305
|
+
skippedReason = held;
|
|
306
|
+
await appendReceipt(tx, result.runId, now, { model, row, outcome: 'skipped', hook: 'ran', detail: held });
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
271
309
|
failureReason = error instanceof Error ? error.message : String(error);
|
|
272
310
|
await appendReceipt(tx, result.runId, now, { model, row, outcome: 'failed', hook: beforeFailed ? 'failed' : beforeRan ? 'ran' : 'none', detail: failureReason });
|
|
273
311
|
return false;
|
|
@@ -292,7 +330,7 @@ export async function purgeExpired(exec: SessionTx, models: ModelDescriptor[], o
|
|
|
292
330
|
purged++; counts.purged++;
|
|
293
331
|
if (!model.hooks?.beforeDestroy && !model.hooks?.afterDestroy && !result.noHook.includes(name)) result.noHook.push(name);
|
|
294
332
|
if (model.hooks?.afterDestroy) {
|
|
295
|
-
try { await model.hooks.afterDestroy(hookRow(model, selected!),
|
|
333
|
+
try { await model.hooks.afterDestroy(hookRow(model, selected!), contextFor(selected!)); }
|
|
296
334
|
catch (error) {
|
|
297
335
|
// Formatting an arbitrary rejection must not lose a committed outcome.
|
|
298
336
|
let message = 'unreadable error';
|