@everystack/server 0.4.39 → 0.4.41

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,41 @@ 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.41] - 2026-10-31
8
+
9
+ ### Fixed
10
+
11
+ - destroyExpired links a module's raw models itself
12
+
13
+ ## [0.4.40] - 2026-10-30
14
+
15
+ ### BREAKING CHANGES
16
+
17
+ - destroy is one generated SQL function per lifecycle table; purgeExpired is removed
18
+ - only a lifecycle table may hold a foreign key into a lifecycle table
19
+
20
+ ### Added
21
+
22
+ - the apply preflight refuses a live catalog that breaks the soft delete
23
+ - destroyExpired counts skipped roots by reason and blocker
24
+ - describeSkipped renders a destroy result's skipped roots
25
+ - db:reconcile runs the live catalog check on every write path
26
+ - destroy runs under an app role; a stock destroy job
27
+
28
+ ### Fixed
29
+
30
+ - destroyExpired pages candidates on every path, so a call's retry work stays bounded
31
+ - review nits for lifecycle/testing
32
+ - consumer-named blockers cannot corrupt counts or log lines
33
+ - describeSkipped strips bidi overrides and line separators
34
+ - destroy eligibility follows the root
35
+ - the destroy job logs no error text and fails a failed run
36
+
37
+ ### Changed
38
+
39
+ - one delete hook pair; beforeStamp and afterStamp are folded into beforeDelete and afterDelete
40
+ - a destroy skip is an alarm, not an operator workflow
41
+
7
42
  ## [0.4.39] - 2026-10-23
8
43
 
9
44
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.39",
3
+ "version": "0.4.41",
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>",
@@ -126,10 +126,20 @@
126
126
  "types": "./src/restore-witness.ts",
127
127
  "default": "./src/restore-witness.ts"
128
128
  },
129
- "./purge": {
130
- "source": "./src/purge.ts",
131
- "types": "./src/purge.ts",
132
- "default": "./src/purge.ts"
129
+ "./destroy": {
130
+ "source": "./src/destroy.ts",
131
+ "types": "./src/destroy.ts",
132
+ "default": "./src/destroy.ts"
133
+ },
134
+ "./destroy-job": {
135
+ "source": "./src/destroy-job.ts",
136
+ "types": "./src/destroy-job.ts",
137
+ "default": "./src/destroy-job.ts"
138
+ },
139
+ "./destroy-log": {
140
+ "source": "./src/destroy-log.ts",
141
+ "types": "./src/destroy-log.ts",
142
+ "default": "./src/destroy-log.ts"
133
143
  }
134
144
  },
135
145
  "peerDependencies": {
@@ -197,11 +207,11 @@
197
207
  "sst": "4.13.1",
198
208
  "ts-jest": "29.4.9",
199
209
  "typescript": "5.9.3",
200
- "@everystack/cli": "0.4.103",
201
- "@everystack/auth": "0.4.9"
210
+ "@everystack/auth": "0.4.10",
211
+ "@everystack/cli": "0.4.105"
202
212
  },
203
213
  "dependencies": {
204
- "@everystack/model": "0.4.24"
214
+ "@everystack/model": "0.4.25"
205
215
  },
206
216
  "scripts": {
207
217
  "test": "jest",
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The stock lifecycle destroy job: a scheduled handler around destroyExpired.
3
+ *
4
+ * Inert until the app sets a retention window (`after`). It runs on the app's least-privilege
5
+ * login (NOINHERIT, granted the app roles) and switches with SET LOCAL ROLE in each transaction,
6
+ * as the API does. Never the database master, never everystack_lifecycle. Logs are counts and
7
+ * table names only, never a row key.
8
+ */
9
+ import type { ModelDescriptor } from '@everystack/model';
10
+ import type { SessionCapableDb } from './db-session';
11
+ import { assertDestroyRole, describeSkipped, destroyExpired, overdueLifecycleRowsReadOnly, type DestroyFault, type DestroyResult } from './destroy';
12
+
13
+ export interface DestroyJobOptions {
14
+ models: ModelDescriptor[];
15
+ /** Retention in milliseconds after soft delete. Undefined leaves the job inert. */
16
+ after?: number;
17
+ /** Roots removed per table per run (destroyExpired's batch). */
18
+ batch?: number;
19
+ /** The application role each transaction switches to. Must manage the lifecycle tables. */
20
+ role?: string;
21
+ /** A handle on the least-privilege login, or a function that opens one on first use. */
22
+ db: SessionCapableDb | (() => SessionCapableDb);
23
+ /** Hook and destroy faults. Default: one log line with the table and phase, not the message. */
24
+ onError?: (fault: DestroyFault) => void | Promise<void>;
25
+ }
26
+
27
+ export interface DestroyJobEvent {
28
+ /** Count the overdue rows under the role; change nothing. */
29
+ dryRun?: boolean;
30
+ }
31
+
32
+ export type DestroyJobResult =
33
+ | { status: 'not_configured' }
34
+ | { status: 'dry_run'; overdue: Record<string, number> }
35
+ | { status: 'done'; perTable: DestroyResult['perTable']; skipped: string[] };
36
+
37
+ const log = (level: 'info' | 'warn', message: string, fields: Record<string, unknown> = {}): void => {
38
+ console.log(JSON.stringify({ timestamp: new Date().toISOString(), level, message, ...fields }));
39
+ };
40
+
41
+ /**
42
+ * Per-table counts for the log line. `failed` and `skippedBy` carry text a consumer's RAISE can
43
+ * choose (a trigger can raise a row key), so the line keeps only that a table failed.
44
+ */
45
+ const loggable = (perTable: DestroyResult['perTable']): Record<string, { removed: number; held: number; skipped: number; failed?: true }> =>
46
+ Object.fromEntries(Object.entries(perTable).map(([table, { removed, held, skipped, failed }]) =>
47
+ [table, failed === undefined ? { removed, held, skipped } : { removed, held, skipped, failed: true as const }]));
48
+
49
+ export function createDestroyJob(options: DestroyJobOptions): (event?: DestroyJobEvent) => Promise<DestroyJobResult> {
50
+ const role = options.role ?? 'admin';
51
+ assertDestroyRole(role);
52
+ const { models, after, batch } = options;
53
+ if (after !== undefined && (!Number.isFinite(after) || after <= 0)) throw new Error('retention must be positive milliseconds');
54
+ const connect = (): SessionCapableDb => typeof options.db === 'function' ? options.db() : options.db;
55
+ const onError = options.onError ?? ((fault: DestroyFault): void => log('warn', 'Lifecycle destroy fault', { table: fault.table, phase: fault.phase }));
56
+ return async (event) => {
57
+ if (after === undefined) {
58
+ log('info', 'Lifecycle destroy not configured: set a retention window (after) to enable it');
59
+ return { status: 'not_configured' };
60
+ }
61
+ if (event?.dryRun) {
62
+ const overdue = await overdueLifecycleRowsReadOnly(connect(), models, { after, role });
63
+ log('info', 'Lifecycle destroy dry run', { role, overdue });
64
+ return { status: 'dry_run', overdue };
65
+ }
66
+ const result = await destroyExpired(connect(), models, { after, batch, role, onError });
67
+ const skipped = describeSkipped(result);
68
+ const failed = Object.keys(result.perTable).filter(table => result.perTable[table].failed !== undefined);
69
+ log(failed.length > 0 || skipped.length > 0 ? 'warn' : 'info', 'Lifecycle destroy complete', { role, perTable: loggable(result.perTable), skipped });
70
+ // Fail the invocation so error alarms fire; name the tables only, the cause can carry a key.
71
+ if (failed.length > 0) throw new Error(`lifecycle destroy failed on ${failed.join(', ')}; the cause reaches onError`);
72
+ return { status: 'done', perTable: result.perTable, skipped };
73
+ };
74
+ }
@@ -0,0 +1,2 @@
1
+ /** The log is shared with the CLI's database builder without a server dependency. */
2
+ export { ENSURE_DESTROY_LOG_SQL } from '@everystack/model';
package/src/destroy.ts ADDED
@@ -0,0 +1,226 @@
1
+ /** Generated SQL owns removal; this module brackets it with root hooks. */
2
+ import { sql, type SQL } from 'drizzle-orm';
3
+ import { isVeto, lifecycleDestroyOrder, linkModels, pgType, type DestroyContext, type ModelDescriptor } from '@everystack/model';
4
+ import type { SessionCapableDb, SessionTx } from './db-session';
5
+
6
+ type Row = Record<string, unknown>;
7
+ const label = (m: ModelDescriptor): string => `${m.schema}.${m.table}`;
8
+ const table = (m: ModelDescriptor): SQL => sql`${sql.identifier(m.schema)}.${sql.identifier(m.table)}`;
9
+ const rowsOf = (result: unknown): Row[] => Array.isArray(result) ? result : (result as { rows: Row[] }).rows;
10
+
11
+ // The identifier rule withRole uses (db.ts): the only value ever interpolated into SET ROLE.
12
+ const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
13
+
14
+ /**
15
+ * Refuse a role the destroy path must not switch to. everystack_lifecycle is the private authority
16
+ * the generated function holds for one statement; a caller that can assume it can also
17
+ * unmark rows by hand, so a routine job never runs as it.
18
+ */
19
+ export function assertDestroyRole(role: string): void {
20
+ if (!ROLE_NAME.test(role)) throw new Error(`Invalid role name: ${JSON.stringify(role)}`);
21
+ if (role === 'everystack_lifecycle') throw new Error('destroy must not run as everystack_lifecycle: pass an application role that manages the tables (e.g. admin)');
22
+ }
23
+
24
+ /** SET LOCAL ROLE as a transaction's first statement, as withRole does. */
25
+ async function switchRole(tx: Pick<SessionTx, 'execute'>, role: string | undefined): Promise<void> {
26
+ if (role !== undefined) await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
27
+ }
28
+
29
+ /**
30
+ * Count expired lifecycle rows without invoking hooks or changing data. With `role`, exec must be
31
+ * a transaction: SET LOCAL outside one is a no-op, so the switch is checked before counting.
32
+ */
33
+ export async function overdueLifecycleRows(
34
+ exec: Pick<SessionTx, 'execute'>,
35
+ models: ModelDescriptor[],
36
+ options: { after: number; role?: string },
37
+ ): Promise<Record<string, number>> {
38
+ if (!Number.isFinite(options.after) || options.after <= 0) throw new Error('retention must be positive milliseconds');
39
+ const cutoff = new Date(Date.now() - options.after);
40
+ if (!Number.isFinite(cutoff.getTime())) throw new Error('retention exceeds clock range');
41
+ if (options.role !== undefined) {
42
+ assertDestroyRole(options.role);
43
+ await switchRole(exec, options.role);
44
+ const [row] = rowsOf(await exec.execute(sql`SELECT current_user AS role`));
45
+ if (row?.role !== options.role) throw new Error(`role ${options.role} was not set: overdueLifecycleRows with a role is not inside a transaction`);
46
+ }
47
+ const counts: Record<string, number> = {};
48
+ for (const model of models.filter(m => m.lifecycle)) {
49
+ const [row] = rowsOf(await exec.execute(sql`SELECT count(*) AS count FROM ${table(model)} WHERE deleted_at < ${cutoff.toISOString()}`));
50
+ const count = Number(row.count);
51
+ if (!Number.isSafeInteger(count) || count < 0) throw new Error(`invalid overdue count on ${label(model)}`);
52
+ counts[label(model)] = count;
53
+ }
54
+ return counts;
55
+ }
56
+
57
+ /** overdueLifecycleRows in one read-only transaction of its own, under `role` when given. */
58
+ export async function overdueLifecycleRowsReadOnly(
59
+ db: SessionCapableDb,
60
+ models: ModelDescriptor[],
61
+ options: { after: number; role?: string },
62
+ ): Promise<Record<string, number>> {
63
+ if (options.role !== undefined) assertDestroyRole(options.role);
64
+ return db.transaction(tx => overdueLifecycleRows(tx, models, options), { accessMode: 'read only' });
65
+ }
66
+
67
+ /**
68
+ * Why a root was skipped: 'referenced', a foreign key outside the declarations still references it;
69
+ * 'ineligible_live', a delete during destroy reached a live row (the tamper alarm). Every key into a
70
+ * lifecycle table is declared, so neither is reachable by normal writes.
71
+ */
72
+ export type DestroySkipReason = 'referenced' | 'ineligible_live';
73
+
74
+ export interface DestroyResult {
75
+ /**
76
+ * skipped: the roots the generated function returned as 'skipped' (db:doctor counts them overdue).
77
+ * skippedBy: the same roots counted by reason, then by blocker (schema.table.constraint for
78
+ * 'referenced', the child's schema.table otherwise). Counts only, never keys.
79
+ */
80
+ perTable: Record<string, { removed: number; held: number; skipped: number; skippedBy: Partial<Record<DestroySkipReason, Record<string, number>>>; failed?: string }>;
81
+ order: string[];
82
+ }
83
+
84
+ export interface DestroyFault {
85
+ table: string;
86
+ phase: 'beforeDestroy' | 'afterDestroy' | 'destroy';
87
+ error: unknown;
88
+ }
89
+
90
+ /** A blocker is name text a consumer's RAISE can set: no control, line-separator or bidi-override character reaches a log line. */
91
+ const printable = (text: string): string => text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, '');
92
+
93
+ /**
94
+ * One plain-text line per table and blocker of a destroyExpired result's skipped roots, with its
95
+ * count. Built from names and counts only; the result never carries a key. Under the declared design
96
+ * soft delete decides every consequence, so a skip means an out-of-band key or trigger. The blocker
97
+ * comes from the error PostgreSQL reported, so a consumer trigger that raises 23503 itself can
98
+ * choose it: a lead to check, not proof.
99
+ */
100
+ export function describeSkipped(result: DestroyResult): string[] {
101
+ const lines: string[] = [];
102
+ for (const [table, entry] of Object.entries(result.perTable)) {
103
+ for (const blockers of Object.values(entry.skippedBy)) {
104
+ for (const blocker of Object.keys(blockers!).sort()) {
105
+ lines.push(`${table}: destroy was blocked by ${printable(blocker)} (${blockers![blocker]} root(s)); `
106
+ + 'this should not happen under the declared design, check for an out-of-band key or trigger');
107
+ }
108
+ }
109
+ }
110
+ return lines;
111
+ }
112
+
113
+ const columnName = (key: string): string => key.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
114
+ const context = (row: Row): DestroyContext => Object.freeze({
115
+ phase: 'destroy', batch: row.deleted_batch as string | null ?? null, user: null, client: null,
116
+ });
117
+
118
+ /**
119
+ * Remove expired roots through their generated SQL functions, children first.
120
+ * Pass a database handle, never a transaction: every destroy needs its own top-level
121
+ * transaction. Hooks run for roots only; FK cascades remove children without hooks.
122
+ * A remaining restrict child defers its root (visible through overdueLifecycleRows). A root another
123
+ * foreign key still references is skipped and counted, and the rest of the batch is removed.
124
+ * Candidates are read a page of `batch` at a time and each call names its keys, so one call's
125
+ * retry work is bounded by batch however many roots stay pinned. `batch` caps the roots removed
126
+ * per table per run. Locked rows are skipped, so fewer than batch roots may return. Hooks may repeat if
127
+ * commit fails. afterDestroy runs after commit, best-effort: a crash can lose it.
128
+ * `role`: every transaction opens with SET LOCAL ROLE, so a least-privilege login (NOINHERIT,
129
+ * granted the app roles) runs it as a role that manages the tables. Never everystack_lifecycle.
130
+ * `models` may be a module's raw array: it is linked here (linkModels) before the destroy order is read.
131
+ */
132
+ export async function destroyExpired(
133
+ db: SessionCapableDb,
134
+ models: ModelDescriptor[],
135
+ options: {
136
+ after: number;
137
+ batch?: number;
138
+ now?: Date;
139
+ statementTimeoutMs?: number;
140
+ role?: string;
141
+ onError?: (fault: DestroyFault) => void | Promise<void>;
142
+ },
143
+ ): Promise<DestroyResult> {
144
+ if (options.role !== undefined) assertDestroyRole(options.role);
145
+ if (!Number.isFinite(options.after) || options.after <= 0) throw new Error('retention must be positive milliseconds');
146
+ const before = new Date((options.now?.getTime() ?? Date.now()) - options.after);
147
+ if (!Number.isFinite(before.getTime())) throw new Error('retention exceeds clock range');
148
+ const batch = options.batch ?? 1000;
149
+ const timeout = options.statementTimeoutMs ?? 60000;
150
+ for (const [name, value] of [['batch', batch], ['statementTimeoutMs', timeout]] as const) {
151
+ if (!Number.isInteger(value) || value <= 0 || value > 2147483647) throw new Error(`${name} must be a positive PostgreSQL integer`);
152
+ }
153
+ // A module's raw models still hold name references; linking an already linked set is a no-op.
154
+ const order = lifecycleDestroyOrder(linkModels(models));
155
+ const byName = new Map(models.map(m => [m.schema === 'public' ? m.table : label(m), m]));
156
+ const result: DestroyResult = { perTable: {}, order };
157
+ const report = async (fault: DestroyFault): Promise<void> => {
158
+ try { await options.onError?.(fault); } catch { /* Reporting must not stop removal of other roots. */ }
159
+ };
160
+ for (const name of order) {
161
+ const model = byName.get(name)!;
162
+ const entry: DestroyResult['perTable'][string] = { removed: 0, held: 0, skipped: 0, skippedBy: {} };
163
+ result.perTable[label(model)] = entry;
164
+ try {
165
+ const pk = columnName(model.primaryKey[0]);
166
+ const keyType = sql.raw(pgType(model.fields[model.primaryKey[0]].spec));
167
+ const hooks = model.hooks;
168
+ /** One generated destroy call in its own transaction; afterDestroy for the roots it removed. */
169
+ const destroy = async (allowed: Row[], limit: number): Promise<void> => {
170
+ const keys = sql`ARRAY[${sql.join(allowed.map(row => sql`${row[pk]}`), sql`, `)}]::${keyType}[]`;
171
+ const outcomes = await db.transaction(async tx => {
172
+ await switchRole(tx, options.role);
173
+ await tx.execute(sql.raw(`SET LOCAL statement_timeout = ${timeout}`));
174
+ return rowsOf(await tx.execute(sql`SELECT key, outcome, reason, blocker FROM ${sql.identifier(model.schema)}.${sql.identifier(`${model.table}_lifecycle_destroy`)}(${keys}, ${before.toISOString()}, ${limit})`));
175
+ });
176
+ const removed = outcomes.filter(row => row.outcome === 'removed');
177
+ entry.removed += removed.length;
178
+ entry.skipped += outcomes.length - removed.length;
179
+ for (const row of outcomes) {
180
+ if (row.outcome === 'removed') continue;
181
+ // Null prototype: a blocker is text a consumer's RAISE can choose ('__proto__', 'constructor').
182
+ const byBlocker = entry.skippedBy[row.reason as DestroySkipReason] ??= Object.create(null) as Record<string, number>;
183
+ const blocker = typeof row.blocker === 'string' ? row.blocker : '(unnamed)';
184
+ byBlocker[blocker] = (byBlocker[blocker] ?? 0) + 1;
185
+ }
186
+ if (!hooks?.afterDestroy) return;
187
+ const returned = new Set(removed.map(row => String(row.key)));
188
+ for (const row of allowed) {
189
+ if (!returned.has(String(row[pk]))) continue;
190
+ try { await hooks.afterDestroy(row, context(row)); }
191
+ catch (error) { await report({ table: label(model), phase: 'afterDestroy', error }); }
192
+ }
193
+ };
194
+ // Page past held and skipped roots, so the oldest stuck rows cannot starve the table. The
195
+ // cursor keeps deleted_at as text: a JavaScript Date drops its microseconds. Without hooks
196
+ // only the key is read.
197
+ const columns = hooks?.beforeDestroy || hooks?.afterDestroy ? sql`*` : sql.identifier(pk);
198
+ let cursor: { at: unknown; key: unknown } | undefined;
199
+ while (entry.removed < batch) {
200
+ const after = cursor ? sql`AND (deleted_at, ${sql.identifier(pk)}) > (${cursor.at}::timestamptz, ${cursor.key}::${keyType})` : sql``;
201
+ const page = await db.transaction(async tx => {
202
+ await switchRole(tx, options.role);
203
+ return rowsOf(await tx.execute(sql`SELECT ${columns}, deleted_at::text AS lc_cursor_at FROM ${table(model)} WHERE deleted_at IS NOT NULL AND deleted_at < ${before.toISOString()} ${after} ORDER BY deleted_at, ${sql.identifier(pk)} LIMIT ${batch}`));
204
+ }, { accessMode: 'read only' });
205
+ if (page.length === 0) break;
206
+ cursor = { at: page[page.length - 1].lc_cursor_at, key: page[page.length - 1][pk] };
207
+ const allowed: Row[] = [];
208
+ for (const { lc_cursor_at: _at, ...row } of page) {
209
+ try {
210
+ if (isVeto(await hooks?.beforeDestroy?.(row, context(row)))) { entry.held++; continue; }
211
+ allowed.push(row);
212
+ } catch (error) {
213
+ entry.held++;
214
+ await report({ table: label(model), phase: 'beforeDestroy', error });
215
+ }
216
+ }
217
+ if (allowed.length > 0) await destroy(allowed, batch - entry.removed);
218
+ if (page.length < batch) break;
219
+ }
220
+ } catch (error) {
221
+ entry.failed = error instanceof Error ? error.message : String(error);
222
+ await report({ table: label(model), phase: 'destroy', error });
223
+ }
224
+ }
225
+ return result;
226
+ }
package/src/plugin.ts CHANGED
@@ -1479,6 +1479,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1479
1479
  rebuild: p.rebuild,
1480
1480
  overwriteDrift: p.overwriteDrift,
1481
1481
  only: p.only,
1482
+ // The lifecycle catalog check's roles, as sent. executeReconcile refuses lifecycle-owned
1483
+ // objects when they are absent or malformed (an older CLI): fail closed, never skipped.
1484
+ applicationRoles: p.applicationRoles,
1482
1485
  actor: p.actor ?? null,
1483
1486
  gitRef: p.gitRef ?? null,
1484
1487
  });
package/src/provision.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * without a database; plugin.ts wires the real deps.
21
21
  */
22
22
 
23
+ import { ENSURE_DESTROY_LOG_SQL } from './destroy-log';
23
24
  import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL, generateReaderRoleSQL, generateLifecycleRoleSQL, LIFECYCLE_ROLE } from './role-chain';
24
25
  import {
25
26
  pgVersionRefusal,
@@ -333,6 +334,9 @@ export async function runProvision(
333
334
  // ahead of every password and well clear of the password-to-secret window.
334
335
  if (lifecycleSql) {
335
336
  await deps.execute(lifecycleSql);
337
+ for (const statement of ENSURE_DESTROY_LOG_SQL) {
338
+ await deps.execute(statement.replace(/\beverystack_lifecycle\b/g, lifecycleRole));
339
+ }
336
340
  result.lifecycleRole = {
337
341
  role: lifecycleRole,
338
342
  created: lifecycleLive ? !lifecycleLive.existed : null,
@@ -1,91 +0,0 @@
1
- /** Refuse physical removal when live foreign keys exceed the declared lifecycle graph. */
2
- import { sql } from 'drizzle-orm';
3
- import type { ModelDescriptor, Relation } from '@everystack/model';
4
- import type { SessionTx } from './db-session';
5
-
6
- interface ForeignKey {
7
- parent_schema: string;
8
- parent_table: string;
9
- child_schema: string;
10
- child_table: string;
11
- parent_columns: string[];
12
- child_columns: string[];
13
- action: string;
14
- child_lifecycle: boolean;
15
- }
16
- const label = (m: ModelDescriptor): string => `${m.schema}.${m.table}`;
17
- const parentOf = (f: ForeignKey): string => `${f.parent_schema}.${f.parent_table}`;
18
- const childOf = (f: ForeignKey): string => `${f.child_schema}.${f.child_table}`;
19
- const snake = (name: string): string => name.replace(/[A-Z]/g, c => '_' + c.toLowerCase());
20
- const expectedAction = (rel: Relation, liveLifecycle: boolean): string => rel.dependent === 'nullify' ? 'n'
21
- : rel.dependent === 'cascade' && !liveLifecycle && !rel.target().lifecycle ? 'c' : 'r';
22
-
23
- function matches(fk: ForeignKey, parent: ModelDescriptor, rel: Relation): boolean {
24
- return rel.kind === 'hasMany' && Boolean(rel.dependent)
25
- && parentOf(fk) === label(parent) && childOf(fk) === label(rel.target())
26
- && fk.child_columns.length === 1 && fk.child_columns[0] === snake(rel.column)
27
- && fk.parent_columns.length === 1 && fk.parent_columns[0] === snake(rel.references ?? parent.primaryKey[0]);
28
- }
29
-
30
- /** Catalog read only. A refusal has no hook, DELETE or receipt, even for eligible rows. */
31
- export async function unsupportedPurgeGraphs(exec: SessionTx, models: ModelDescriptor[]): Promise<Map<string, string>> {
32
- const raw: unknown = await exec.execute(sql`
33
- SELECT pn.nspname AS parent_schema, p.relname AS parent_table,
34
- cn.nspname AS child_schema, c.relname AS child_table, f.confdeltype AS action,
35
- ARRAY(SELECT a.attname FROM unnest(f.confkey) WITH ORDINALITY k(num,pos)
36
- JOIN pg_catalog.pg_attribute a ON a.attrelid=p.oid AND a.attnum=k.num ORDER BY k.pos) AS parent_columns,
37
- ARRAY(SELECT a.attname FROM unnest(f.conkey) WITH ORDINALITY k(num,pos)
38
- JOIN pg_catalog.pg_attribute a ON a.attrelid=c.oid AND a.attnum=k.num ORDER BY k.pos) AS child_columns,
39
- EXISTS (SELECT 1 FROM pg_catalog.pg_trigger t
40
- JOIN pg_catalog.pg_proc fn ON fn.oid=t.tgfoid
41
- JOIN pg_catalog.pg_roles r ON r.oid=fn.proowner
42
- WHERE t.tgrelid=c.oid AND NOT t.tgisinternal
43
- AND (t.tgtype & 2)=2 AND (t.tgtype & 8)=8
44
- AND r.rolname='everystack_lifecycle') AS child_lifecycle
45
- FROM pg_catalog.pg_constraint f
46
- JOIN pg_catalog.pg_class p ON p.oid=f.confrelid
47
- JOIN pg_catalog.pg_namespace pn ON pn.oid=p.relnamespace
48
- JOIN pg_catalog.pg_class c ON c.oid=f.conrelid
49
- JOIN pg_catalog.pg_namespace cn ON cn.oid=c.relnamespace
50
- WHERE f.contype='f'`);
51
- const fks = (Array.isArray(raw) ? raw : (raw as { rows: ForeignKey[] }).rows) as ForeignKey[];
52
- const byParent = new Map<string, ForeignKey[]>();
53
- for (const fk of fks) byParent.set(parentOf(fk), [...(byParent.get(parentOf(fk)) ?? []), fk]);
54
- const declared = new Map(models.map(m => [label(m), m]));
55
- const badActions = new Map<string, string>();
56
- for (const model of models) {
57
- const root = label(model);
58
- for (const rel of Object.values(model.relations)) {
59
- if (rel.kind !== 'hasMany' || !rel.dependent) continue;
60
- const edges = (byParent.get(root) ?? []).filter(fk => matches(fk, model, rel));
61
- if (!edges.length || edges.some(fk => fk.action !== expectedAction(rel, fk.child_lifecycle))) {
62
- badActions.set(root, `unsupported graph: ${label(rel.target())} foreign key disagrees with declared ${rel.dependent}`);
63
- }
64
- }
65
- }
66
- const refused = new Map<string, string>();
67
- for (const model of models.filter(m => m.lifecycle)) {
68
- const root = label(model);
69
- const visited = new Set<string>();
70
- const walk = (parent: string): string | undefined => {
71
- const mismatch = badActions.get(parent);
72
- if (mismatch) return mismatch;
73
- if (visited.has(parent)) return;
74
- visited.add(parent);
75
- for (const fk of byParent.get(parent) ?? []) {
76
- const child = childOf(fk);
77
- const descriptor = declared.get(parent);
78
- const rel = descriptor && Object.values(descriptor.relations).find(r => matches(fk, descriptor, r));
79
- if (fk.child_lifecycle && (!rel || fk.action !== expectedAction(rel, fk.child_lifecycle))) {
80
- return `unsupported graph: path ${root} reaches undeclared lifecycle edge ${parent} -> ${child}`;
81
- }
82
- if (!['c', 'n', 'd'].includes(fk.action)) continue;
83
- const deeper = walk(child);
84
- if (deeper) return deeper;
85
- }
86
- };
87
- const reason = walk(root);
88
- if (reason) refused.set(root, reason);
89
- }
90
- return refused;
91
- }
package/src/purge-log.ts DELETED
@@ -1,63 +0,0 @@
1
- /**
2
- * The purge ledger contains keys and outcomes, never row payloads. Run this ensure
3
- * as the table owner before purging. Only the lifecycle role may append receipts.
4
- * Mutation guards also refuse a writer with direct privileges. The trusted owner
5
- * can still disable these triggers; this is not a boundary against that owner.
6
- */
7
- export const ENSURE_PURGE_LOG_SQL: string[] = [
8
- 'CREATE SCHEMA IF NOT EXISTS everystack',
9
- `CREATE TABLE IF NOT EXISTS everystack.purge_log (
10
- run_id uuid NOT NULL,
11
- "table" text NOT NULL,
12
- "key" text NOT NULL,
13
- deleted_batch uuid,
14
- deleted_at timestamptz NOT NULL,
15
- purged_at timestamptz NOT NULL,
16
- actor text,
17
- outcome text NOT NULL CHECK (outcome IN ('purged', 'skipped', 'failed')),
18
- hook text NOT NULL CHECK (hook IN ('ran', 'none', 'failed')),
19
- detail text
20
- )`,
21
- 'CREATE INDEX IF NOT EXISTS purge_log_key_time ON everystack.purge_log ("table", "key", purged_at)',
22
- // Typed key comparisons still need an index that bounds the recent receipt window.
23
- 'CREATE INDEX IF NOT EXISTS purge_log_table_time ON everystack.purge_log ("table", purged_at)',
24
- `CREATE OR REPLACE FUNCTION everystack.purge_log_refuse_mutation() RETURNS trigger
25
- LANGUAGE plpgsql SET search_path = pg_catalog AS $guard$
26
- BEGIN
27
- RAISE EXCEPTION 'everystack.purge_log is append-only' USING ERRCODE = '42501';
28
- END
29
- $guard$`,
30
- `CREATE OR REPLACE TRIGGER purge_log_no_mutation BEFORE UPDATE OR DELETE ON everystack.purge_log
31
- FOR EACH ROW EXECUTE FUNCTION everystack.purge_log_refuse_mutation()`,
32
- `CREATE OR REPLACE TRIGGER purge_log_no_truncate BEFORE TRUNCATE ON everystack.purge_log
33
- FOR EACH STATEMENT EXECUTE FUNCTION everystack.purge_log_refuse_mutation()`,
34
- // PUBLIC function defaults would become reachable after the new schema USAGE grant.
35
- 'REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA everystack FROM PUBLIC',
36
- 'REVOKE ALL PRIVILEGES ON TABLE everystack.purge_log FROM PUBLIC',
37
- `DO $acl$
38
- DECLARE r record;
39
- BEGIN
40
- FOR r IN
41
- SELECT DISTINCT roles.rolname
42
- FROM (
43
- SELECT acl.grantee
44
- FROM pg_catalog.pg_class c CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
45
- WHERE c.oid = 'everystack.purge_log'::regclass
46
- UNION
47
- SELECT acl.grantee
48
- FROM pg_catalog.pg_attribute a CROSS JOIN LATERAL pg_catalog.aclexplode(a.attacl) acl
49
- WHERE a.attrelid = 'everystack.purge_log'::regclass AND a.attnum > 0 AND NOT a.attisdropped
50
- ) grants JOIN pg_catalog.pg_roles roles ON roles.oid = grants.grantee
51
- WHERE grants.grantee <> 0 AND roles.oid <> (
52
- SELECT c.relowner FROM pg_catalog.pg_class c WHERE c.oid = 'everystack.purge_log'::regclass
53
- )
54
- LOOP
55
- EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES ON TABLE everystack.purge_log FROM %I', r.rolname);
56
- EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES (run_id, "table", "key", deleted_batch, deleted_at, purged_at, actor, outcome, hook, detail) ON TABLE everystack.purge_log FROM %I', r.rolname);
57
- END LOOP;
58
- END
59
- $acl$`,
60
- 'REVOKE ALL PRIVILEGES (run_id, "table", "key", deleted_batch, deleted_at, purged_at, actor, outcome, hook, detail) ON TABLE everystack.purge_log FROM PUBLIC',
61
- 'GRANT USAGE ON SCHEMA everystack TO everystack_lifecycle',
62
- 'GRANT INSERT ON TABLE everystack.purge_log TO everystack_lifecycle',
63
- ];
package/src/purge.ts DELETED
@@ -1,375 +0,0 @@
1
- /** Real lifecycle removal. Provision ENSURE_PURGE_LOG_SQL on the owner connection first. */
2
- import { randomUUID } from 'node:crypto';
3
- import { performance } from 'node:perf_hooks';
4
- import { sql, type SQL } from 'drizzle-orm';
5
- import { isVeto, type ModelDescriptor, type DestroyContext } from '@everystack/model';
6
- import type { SessionTx } from './db-session';
7
- import { unsupportedPurgeGraphs } from './purge-graph';
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';
11
-
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
-
20
- export interface PurgeHookFault {
21
- kind: 'hook';
22
- name: string;
23
- method: 'DELETE';
24
- status: 500;
25
- table: string;
26
- key: string;
27
- message: string;
28
- stack?: string;
29
- }
30
- export interface PurgeOptions {
31
- /** Retention in milliseconds, strictly positive. */
32
- after: number;
33
- /** Maximum rows physically purged across all tables. */
34
- batch: number;
35
- /** One clock for both selection and receipts. Defaults to the run's start. */
36
- now?: Date;
37
- /** Elapsed-time budget, checked between rows; a current transaction finishes. Default 30000. */
38
- budgetMs?: number;
39
- /** Cooldown for skipped or failed keys, in milliseconds. Default one hour. Zero disables it. */
40
- retryAfterMs?: number;
41
- /** After-commit hook faults; a sink failure cannot undo removal or stop the run. */
42
- onError?: (fault: PurgeHookFault) => void | Promise<void>;
43
- }
44
- export interface PurgeCounts { purged: number; skipped: number; failed: number; }
45
- export interface PurgeResult {
46
- runId: string;
47
- perTable: Record<string, PurgeCounts>;
48
- noHook: string[];
49
- skipped: { table: string; key: string; reason: string }[];
50
- afterDestroyErrors: number;
51
- afterDestroyFailures: { table: string; key: string; message: string }[];
52
- incomplete?: true;
53
- /** Why no rows were purged, when the run removed none. */
54
- reason?: string;
55
- }
56
-
57
- type Row = Record<string, unknown>;
58
- type Receipt = { model: ModelDescriptor; row: Row; outcome: 'purged' | 'skipped' | 'failed'; hook: 'ran' | 'none' | 'failed'; detail?: string };
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
- });
62
- const param = (value: unknown): unknown => value instanceof Date ? value.toISOString() : typeof value === 'bigint' ? value.toString() : value;
63
- const snake = (name: string): string => name.replace(/[A-Z]/g, c => '_' + c.toLowerCase());
64
- const label = (m: ModelDescriptor): string => `${m.schema}.${m.table}`;
65
- const table = (m: ModelDescriptor): SQL => sql`${sql.identifier(m.schema)}.${sql.identifier(m.table)}`;
66
- const column = (key: string): SQL => sql`${sql.identifier(snake(key))}`;
67
- const rowsOf = (result: unknown): Row[] => Array.isArray(result) ? result : (result as { rows: Row[] }).rows;
68
- function fieldParam(model: ModelDescriptor, key: string, value: unknown): unknown {
69
- const spec = model.fields[key].spec;
70
- if (spec.isArray) return sql`(jsonb_populate_record(NULL::${table(model)}, jsonb_build_object(${snake(key)}::text, ${JSON.stringify(value)}::jsonb))).${column(key)}`;
71
- const type = spec.type;
72
- return type === 'json' || type === 'jsonb' ? JSON.stringify(value) : param(value);
73
- }
74
-
75
- /** Keep timestamp key identity independent of the session timezone without losing microseconds. */
76
- function keyValue(model: ModelDescriptor, key: string, row: Row): unknown {
77
- const value = row[snake(key)];
78
- if (value instanceof Uint8Array) return '\\x' + Buffer.from(value).toString('hex');
79
- const spec = model.fields[key].spec;
80
- if (spec.isArray || spec.type === 'json' || spec.type === 'jsonb') return JSON.stringify(value);
81
- const type = spec.type === 'pgType' ? spec.pgTypeName : spec.type;
82
- if (!/^(timestamptz|timestamp(?:\(\d+\))? with time zone)$/i.test(type ?? '')) return param(value);
83
- if (value === 'infinity' || value === '-infinity') return value;
84
- const date = value instanceof Date ? value : new Date(String(value));
85
- if (!Number.isFinite(date.getTime())) throw new Error(`purge cannot normalize timestamp key on ${label(model)}`);
86
- const iso = date.toISOString();
87
- const fraction = typeof value === 'string' ? value.match(/\.(\d+)/)?.[1] : undefined;
88
- const precise = (fraction ?? iso.match(/\.(\d+)Z$/)![1]).replace(/0+$/, '').padEnd(3, '0');
89
- return iso.replace(/\.\d{3}Z$/, `.${precise}Z`);
90
- }
91
- const keyValues = (m: ModelDescriptor, row: Row): unknown[] => m.primaryKey.map(k => keyValue(m, k, row));
92
- function keyOf(model: ModelDescriptor, row: Row): string {
93
- const values = keyValues(model, row);
94
- if (values.length !== 1) return JSON.stringify(values);
95
- return typeof values[0] === 'object' ? JSON.stringify(values[0]) : String(values[0]);
96
- }
97
- const keyWhere = (m: ModelDescriptor, row: Row): SQL => sql.join(m.primaryKey.map(k => sql`${column(k)} = ${fieldParam(m, k, row[snake(k)])}`), sql` AND `);
98
- const hookRow = (m: ModelDescriptor, row: Row): Row => Object.fromEntries(Object.keys(m.fields).map(k => [k, row[snake(k)]]));
99
-
100
- /** Compare keys as their native type while guarding casts from other tables' receipts. */
101
- function cooldownKeyMatches(model: ModelDescriptor): SQL {
102
- const key = model.primaryKey[0];
103
- const spec = model.fields[key].spec;
104
- const type = spec.type === 'pgType' ? spec.pgTypeName ?? '' : spec.type;
105
- const candidate = sql`${table(model)}.${column(key)}`;
106
- const ownKey = sql`CASE WHEN receipt."table" = ${label(model)} THEN receipt."key" END`;
107
- if (spec.isArray) {
108
- return sql`(jsonb_populate_record(NULL::${table(model)}, jsonb_build_object(${snake(key)}::text, ${ownKey}::jsonb))).${column(key)} = ${candidate}`;
109
- }
110
- if (type === 'json' || type === 'jsonb') return sql`${ownKey}::jsonb = ${candidate}`;
111
- if (/^(timestamptz|timestamp(?:\(\d+\))? with time zone)$/i.test(type)) return sql`${ownKey}::timestamptz = ${candidate}`;
112
- if (/^timestamp(?:\(\d+\))?(?: without time zone)?$/i.test(type)) return sql`${ownKey}::timestamp = ${candidate}`;
113
- if (type === 'real') return sql`${ownKey}::real = ${candidate}`;
114
- if (type === 'doublePrecision' || type === 'double precision') return sql`${ownKey}::double precision = ${candidate}`;
115
- if (type === 'bytea') return sql`${ownKey}::bytea = ${candidate}`;
116
- if (type === 'date') return sql`${ownKey}::date = ${candidate}`;
117
- return sql`receipt."key" = ${candidate}::text`;
118
- }
119
-
120
- /** Every declared lifecycle edge orders the child before the parent, nullify included. */
121
- function orderedModels(models: ModelDescriptor[]): ModelDescriptor[] {
122
- const lifecycle = models.filter(m => m.lifecycle);
123
- const set = new Set(lifecycle);
124
- const visiting = new Set<ModelDescriptor>();
125
- const visited = new Set<ModelDescriptor>();
126
- const ordered: ModelDescriptor[] = [];
127
- const visit = (m: ModelDescriptor): void => {
128
- if (visiting.has(m)) throw new Error(`purge relation cycle at ${label(m)}`);
129
- if (visited.has(m)) return;
130
- visiting.add(m);
131
- for (const rel of Object.values(m.relations)) {
132
- if (rel.kind === 'hasMany' && rel.dependent) {
133
- const child = rel.target();
134
- if (child.lifecycle && !set.has(child)) throw new Error(`purge needs declared child ${label(child)}`);
135
- if (set.has(child)) visit(child);
136
- }
137
- }
138
- visiting.delete(m); visited.add(m); ordered.push(m);
139
- };
140
- for (const m of lifecycle) {
141
- if (m.primaryKey.length !== 1) throw new Error(`purge needs a single-column primary key on ${label(m)}`);
142
- visit(m);
143
- }
144
- return ordered;
145
- }
146
-
147
- async function appendReceipt(tx: SessionTx, runId: string, now: Date, receipt: Receipt): Promise<void> {
148
- const { model, row, outcome, hook, detail } = receipt;
149
- await tx.execute(sql`SET LOCAL ROLE everystack_lifecycle`);
150
- await tx.execute(sql`INSERT INTO everystack.purge_log
151
- (run_id, "table", "key", deleted_batch, deleted_at, purged_at, actor, outcome, hook, detail)
152
- VALUES (${runId}, ${label(model)}, ${keyOf(model, row)}, ${row.deleted_batch}, ${param(row.deleted_at)}, ${now.toISOString()}, 'purge', ${outcome}, ${hook}, ${detail ?? null})`);
153
- }
154
-
155
- /**
156
- * Purge expired lifecycle rows, children first, in one transaction per selected row.
157
- * The executor is the existing ops Drizzle database (execute/transaction); its caller
158
- * must read the selected tables and purge_log, and SET ROLE to everystack_lifecycle. Hooks run outside
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
163
- * skipped receipt on each retry. Plain children with ON DELETE CASCADE are removed by
164
- * referential integrity; they receive neither a destroy hook nor a purge receipt.
165
- * The receipt's hook status describes beforeDestroy. afterDestroy is best-effort after
166
- * commit and cannot change that append-only receipt or undo the physical removal.
167
- */
168
- export async function purgeExpired(exec: SessionTx, models: ModelDescriptor[], options: PurgeOptions): Promise<PurgeResult> {
169
- const started = performance.now();
170
- const after = options.after;
171
- const batch = options.batch;
172
- const budgetMs = options.budgetMs ?? 30_000;
173
- const retryAfterMs = options.retryAfterMs ?? 3_600_000;
174
- if (!Number.isFinite(budgetMs) || budgetMs < 0) throw new Error('purge budgetMs must be nonnegative milliseconds');
175
- if (!Number.isFinite(retryAfterMs) || retryAfterMs < 0) throw new Error('purge retryAfterMs must be nonnegative milliseconds');
176
- const outOfBudget = (): boolean => performance.now() - started >= budgetMs;
177
- if (!Number.isFinite(after) || after <= 0) throw new Error('purge after must be positive milliseconds');
178
- if (!Number.isSafeInteger(batch) || batch <= 0) throw new Error('purge batch must be a positive integer');
179
- const now = new Date(options.now?.getTime() ?? Date.now());
180
- const cutoff = new Date(now.getTime() - after);
181
- if (!Number.isFinite(now.getTime()) || !Number.isFinite(cutoff.getTime())) throw new Error('purge needs a valid clock');
182
- const retryCutoff = new Date(now.getTime() - retryAfterMs);
183
- if (!Number.isFinite(retryCutoff.getTime())) throw new Error('purge retryAfterMs exceeds clock range');
184
- const ordered = orderedModels(models);
185
- const result: PurgeResult = { runId: randomUUID(), perTable: {}, noHook: [], skipped: [], afterDestroyErrors: 0, afterDestroyFailures: [] };
186
- let purged = 0;
187
- const finish = (incomplete = false): PurgeResult => {
188
- if (incomplete) result.incomplete = true;
189
- if (purged === 0) result.reason = incomplete ? 'time budget exhausted'
190
- : result.skipped[0]?.reason ?? 'no expired lifecycle rows';
191
- return result;
192
- };
193
- if (outOfBudget()) return finish(true);
194
- const unsupported = await unsupportedPurgeGraphs(exec, models);
195
- for (const model of ordered) {
196
- const name = label(model);
197
- const counts = result.perTable[name] = { purged: 0, skipped: 0, failed: 0 };
198
- const unsupportedReason = unsupported.get(name);
199
- if (unsupportedReason) {
200
- // '*' is a table-level refusal, not an attempted row or an audit receipt.
201
- result.skipped.push({ table: name, key: '*', reason: unsupportedReason });
202
- continue;
203
- }
204
- const attempted = new Set<string>();
205
- let cursor: Row | undefined;
206
- while (purged < batch) {
207
- if (outOfBudget()) return finish(true);
208
- const tuple = sql.join([sql`deleted_at`, ...model.primaryKey.map(column)], sql`, `);
209
- const cursorValues = cursor ? sql.join([sql`${param(cursor.deleted_at)}`, ...model.primaryKey.map(k => sql`${fieldParam(model, k, cursor![snake(k)])}`)], sql`, `) : undefined;
210
- const cooldown = retryAfterMs > 0 ? sql`AND NOT EXISTS (
211
- SELECT 1 FROM everystack.purge_log AS receipt
212
- WHERE receipt."table" = ${name} AND ${cooldownKeyMatches(model)}
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 + '%'})
216
- )` : sql``;
217
- const page = rowsOf(await exec.execute(sql`SELECT * FROM ${table(model)}
218
- WHERE deleted_at <= ${cutoff.toISOString()} ${cooldown} ${cursorValues ? sql`AND (${tuple}) > (${cursorValues})` : sql``}
219
- ORDER BY ${tuple} LIMIT ${Math.min(100, batch - purged)}`));
220
- if (!page.length) {
221
- if (retryAfterMs > 0 && purged === 0 && result.skipped.length === 0) {
222
- const expired = rowsOf(await exec.execute(sql`SELECT 1 FROM ${table(model)} WHERE deleted_at <= ${cutoff.toISOString()} LIMIT 1`));
223
- if (expired.length) result.skipped.push({ table: name, key: '*', reason: 'retry cooldown' });
224
- }
225
- break;
226
- }
227
- for (const candidate of page) {
228
- cursor = candidate;
229
- if (outOfBudget()) return finish(true);
230
- const candidateKey = keyOf(model, candidate);
231
- if (attempted.has(candidateKey)) continue;
232
- attempted.add(candidateKey);
233
- let selected: Row | undefined;
234
- let beforeFailed = false;
235
- let beforeRan = false;
236
- let skippedReason: string | undefined;
237
- let failureReason: string | undefined;
238
- let held: string | undefined;
239
- const removed = await exec.transaction(async tx => {
240
- const [isolation] = rowsOf(await tx.execute(sql`SHOW transaction_isolation`));
241
- if (isolation?.transaction_isolation !== 'read committed') throw new Error('purge requires READ COMMITTED');
242
- const locked = rowsOf(await tx.execute(sql`SELECT * FROM ${table(model)}
243
- WHERE ${keyWhere(model, candidate)} AND deleted_at <= ${cutoff.toISOString()} FOR UPDATE SKIP LOCKED`));
244
- selected = locked[0];
245
- if (!selected) return false;
246
- const row = selected;
247
- // Selection precedes the row lock. Another run may have committed a failure since then.
248
- if (retryAfterMs > 0) {
249
- const cooling = rowsOf(await tx.execute(sql`SELECT 1 FROM ${table(model)}
250
- WHERE ${keyWhere(model, row)} AND EXISTS (
251
- SELECT 1 FROM everystack.purge_log AS receipt
252
- WHERE receipt."table" = ${name} AND ${cooldownKeyMatches(model)}
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 + '%'})
256
- )`));
257
- if (cooling.length) { skippedReason = 'retry cooldown'; return false; }
258
- }
259
- try {
260
- // Keep the row lock outside the savepoint, including while recording a failed attempt.
261
- return await tx.transaction(async work => {
262
- for (const rel of Object.values(model.relations)) {
263
- const child = rel.kind === 'hasMany' && (rel.dependent === 'cascade' || rel.dependent === 'restrict') ? rel.target() : undefined;
264
- if (!child?.lifecycle) continue;
265
- const reference = rel.references ?? model.primaryKey[0];
266
- const remains = rowsOf(await work.execute(sql`SELECT 1 FROM ${table(child)} WHERE ${column(rel.column)} = ${fieldParam(model, reference, row[snake(reference)])} LIMIT 1`));
267
- if (remains.length) {
268
- skippedReason = `remaining lifecycle child ${label(child)}`;
269
- await appendReceipt(work, result.runId, now, { model, row, outcome: 'skipped', hook: 'none', detail: skippedReason });
270
- return false;
271
- }
272
- }
273
- const caller = model.hooks?.beforeDestroy
274
- ? rowsOf(await work.execute(sql`SELECT current_user AS role`))[0] : undefined;
275
- await work.execute(sql`SET LOCAL ROLE everystack_lifecycle`);
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
- }
287
- if (deleted.length !== 1 || keyOf(model, deleted[0]) !== keyOf(model, row)) throw new Error('purge DELETE did not return the locked key');
288
- // Refuse deferred constraints before invoking a hook with external effects.
289
- await work.execute(sql`SET CONSTRAINTS ALL IMMEDIATE`);
290
- if (caller) await work.execute(sql`SET LOCAL ROLE ${sql.identifier(String(caller.role))}`);
291
- if (model.hooks?.beforeDestroy) {
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);
299
- }
300
- await appendReceipt(work, result.runId, now, { model, row, outcome: 'purged', hook: beforeRan ? 'ran' : 'none' });
301
- return true;
302
- });
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
- }
309
- failureReason = error instanceof Error ? error.message : String(error);
310
- await appendReceipt(tx, result.runId, now, { model, row, outcome: 'failed', hook: beforeFailed ? 'failed' : beforeRan ? 'ran' : 'none', detail: failureReason });
311
- return false;
312
- }
313
- });
314
- if (failureReason !== undefined) {
315
- counts.failed++;
316
- result.skipped.push({ table: name, key: keyOf(model, selected!), reason: failureReason });
317
- continue;
318
- }
319
- if (skippedReason) {
320
- counts.skipped++;
321
- result.skipped.push({ table: name, key: keyOf(model, candidate), reason: skippedReason });
322
- }
323
- if (!removed) {
324
- if (!selected) {
325
- counts.skipped++;
326
- result.skipped.push({ table: name, key: candidateKey, reason: 'row locked or no longer expired' });
327
- }
328
- continue;
329
- }
330
- purged++; counts.purged++;
331
- if (!model.hooks?.beforeDestroy && !model.hooks?.afterDestroy && !result.noHook.includes(name)) result.noHook.push(name);
332
- if (model.hooks?.afterDestroy) {
333
- try { await model.hooks.afterDestroy(hookRow(model, selected!), contextFor(selected!)); }
334
- catch (error) {
335
- // Formatting an arbitrary rejection must not lose a committed outcome.
336
- let message = 'unreadable error';
337
- let stack: string | undefined;
338
- try { message = String(error instanceof Error ? error.message : error); } catch { /* Keep the fallback. */ }
339
- try { stack = error instanceof Error ? error.stack : undefined; } catch { /* Stack is optional. */ }
340
- const key = keyOf(model, selected!);
341
- result.afterDestroyErrors++;
342
- result.afterDestroyFailures.push({ table: name, key, message });
343
- try {
344
- await options.onError?.({ kind: 'hook', name: `${name}.afterDestroy`, method: 'DELETE', status: 500,
345
- table: name, key, message: `after-hook failed after the write: ${message}`,
346
- stack });
347
- } catch { /* The observability sink cannot replace a committed outcome. */ }
348
- }
349
- }
350
- if (purged >= batch) break;
351
- }
352
- }
353
- if (purged >= batch) break;
354
- }
355
- return finish();
356
- }
357
-
358
- /** Count expired lifecycle rows without invoking hooks or changing data. */
359
- export async function overdueLifecycleRows(
360
- exec: Pick<SessionTx, 'execute'>,
361
- models: ModelDescriptor[],
362
- options: { after: number },
363
- ): Promise<Record<string, number>> {
364
- if (!Number.isFinite(options.after) || options.after <= 0) throw new Error('purge after must be positive milliseconds');
365
- const cutoff = new Date(Date.now() - options.after);
366
- if (!Number.isFinite(cutoff.getTime())) throw new Error('purge after exceeds clock range');
367
- const counts: Record<string, number> = {};
368
- for (const model of models.filter(m => m.lifecycle)) {
369
- const [row] = rowsOf(await exec.execute(sql`SELECT count(*) AS count FROM ${table(model)} WHERE deleted_at <= ${cutoff.toISOString()}`));
370
- const count = Number(row.count);
371
- if (!Number.isSafeInteger(count) || count < 0) throw new Error(`invalid overdue count on ${label(model)}`);
372
- counts[label(model)] = count;
373
- }
374
- return counts;
375
- }