@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 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.38",
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.102",
200
+ "@everystack/cli": "0.4.103",
201
201
  "@everystack/auth": "0.4.9"
202
202
  },
203
203
  "dependencies": {
204
- "@everystack/model": "0.4.23"
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 { ModelDescriptor, DestroyContext } from '@everystack/model';
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 context: DestroyContext = Object.freeze({ source: 'purge', user: null, client: null });
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; a hook throw rolls back the
149
- * removal. A successful hook can repeat if commit fails, so it must be idempotent. A skipped row appends another
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
- const deleted = rowsOf(await work.execute(sql`DELETE FROM ${table(model)} WHERE ${keyWhere(model, row)} RETURNING ${sql.join(model.primaryKey.map(column), sql`, `)}`));
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 { await model.hooks.beforeDestroy(hookRow(model, row), context); beforeRan = true; }
265
- catch (error) { beforeFailed = true; throw error; }
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!), context); }
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';