@everystack/server 0.4.36 → 0.4.38

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/src/purge.ts ADDED
@@ -0,0 +1,337 @@
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 type { ModelDescriptor, 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
+
10
+ /** A destroy hook failed after physical removal committed. */
11
+ export interface PurgeHookFault {
12
+ kind: 'hook';
13
+ name: string;
14
+ method: 'DELETE';
15
+ status: 500;
16
+ table: string;
17
+ key: string;
18
+ message: string;
19
+ stack?: string;
20
+ }
21
+ export interface PurgeOptions {
22
+ /** Retention in milliseconds, strictly positive. */
23
+ after: number;
24
+ /** Maximum rows physically purged across all tables. */
25
+ batch: number;
26
+ /** One clock for both selection and receipts. Defaults to the run's start. */
27
+ now?: Date;
28
+ /** Elapsed-time budget, checked between rows; a current transaction finishes. Default 30000. */
29
+ budgetMs?: number;
30
+ /** Cooldown for skipped or failed keys, in milliseconds. Default one hour. Zero disables it. */
31
+ retryAfterMs?: number;
32
+ /** After-commit hook faults; a sink failure cannot undo removal or stop the run. */
33
+ onError?: (fault: PurgeHookFault) => void | Promise<void>;
34
+ }
35
+ export interface PurgeCounts { purged: number; skipped: number; failed: number; }
36
+ export interface PurgeResult {
37
+ runId: string;
38
+ perTable: Record<string, PurgeCounts>;
39
+ noHook: string[];
40
+ skipped: { table: string; key: string; reason: string }[];
41
+ afterDestroyErrors: number;
42
+ afterDestroyFailures: { table: string; key: string; message: string }[];
43
+ incomplete?: true;
44
+ /** Why no rows were purged, when the run removed none. */
45
+ reason?: string;
46
+ }
47
+
48
+ type Row = Record<string, unknown>;
49
+ 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 });
51
+ const param = (value: unknown): unknown => value instanceof Date ? value.toISOString() : typeof value === 'bigint' ? value.toString() : value;
52
+ const snake = (name: string): string => name.replace(/[A-Z]/g, c => '_' + c.toLowerCase());
53
+ const label = (m: ModelDescriptor): string => `${m.schema}.${m.table}`;
54
+ const table = (m: ModelDescriptor): SQL => sql`${sql.identifier(m.schema)}.${sql.identifier(m.table)}`;
55
+ const column = (key: string): SQL => sql`${sql.identifier(snake(key))}`;
56
+ const rowsOf = (result: unknown): Row[] => Array.isArray(result) ? result : (result as { rows: Row[] }).rows;
57
+ function fieldParam(model: ModelDescriptor, key: string, value: unknown): unknown {
58
+ const spec = model.fields[key].spec;
59
+ if (spec.isArray) return sql`(jsonb_populate_record(NULL::${table(model)}, jsonb_build_object(${snake(key)}::text, ${JSON.stringify(value)}::jsonb))).${column(key)}`;
60
+ const type = spec.type;
61
+ return type === 'json' || type === 'jsonb' ? JSON.stringify(value) : param(value);
62
+ }
63
+
64
+ /** Keep timestamp key identity independent of the session timezone without losing microseconds. */
65
+ function keyValue(model: ModelDescriptor, key: string, row: Row): unknown {
66
+ const value = row[snake(key)];
67
+ if (value instanceof Uint8Array) return '\\x' + Buffer.from(value).toString('hex');
68
+ const spec = model.fields[key].spec;
69
+ if (spec.isArray || spec.type === 'json' || spec.type === 'jsonb') return JSON.stringify(value);
70
+ const type = spec.type === 'pgType' ? spec.pgTypeName : spec.type;
71
+ if (!/^(timestamptz|timestamp(?:\(\d+\))? with time zone)$/i.test(type ?? '')) return param(value);
72
+ if (value === 'infinity' || value === '-infinity') return value;
73
+ const date = value instanceof Date ? value : new Date(String(value));
74
+ if (!Number.isFinite(date.getTime())) throw new Error(`purge cannot normalize timestamp key on ${label(model)}`);
75
+ const iso = date.toISOString();
76
+ const fraction = typeof value === 'string' ? value.match(/\.(\d+)/)?.[1] : undefined;
77
+ const precise = (fraction ?? iso.match(/\.(\d+)Z$/)![1]).replace(/0+$/, '').padEnd(3, '0');
78
+ return iso.replace(/\.\d{3}Z$/, `.${precise}Z`);
79
+ }
80
+ const keyValues = (m: ModelDescriptor, row: Row): unknown[] => m.primaryKey.map(k => keyValue(m, k, row));
81
+ function keyOf(model: ModelDescriptor, row: Row): string {
82
+ const values = keyValues(model, row);
83
+ if (values.length !== 1) return JSON.stringify(values);
84
+ return typeof values[0] === 'object' ? JSON.stringify(values[0]) : String(values[0]);
85
+ }
86
+ const keyWhere = (m: ModelDescriptor, row: Row): SQL => sql.join(m.primaryKey.map(k => sql`${column(k)} = ${fieldParam(m, k, row[snake(k)])}`), sql` AND `);
87
+ const hookRow = (m: ModelDescriptor, row: Row): Row => Object.fromEntries(Object.keys(m.fields).map(k => [k, row[snake(k)]]));
88
+
89
+ /** Compare keys as their native type while guarding casts from other tables' receipts. */
90
+ function cooldownKeyMatches(model: ModelDescriptor): SQL {
91
+ const key = model.primaryKey[0];
92
+ const spec = model.fields[key].spec;
93
+ const type = spec.type === 'pgType' ? spec.pgTypeName ?? '' : spec.type;
94
+ const candidate = sql`${table(model)}.${column(key)}`;
95
+ const ownKey = sql`CASE WHEN receipt."table" = ${label(model)} THEN receipt."key" END`;
96
+ if (spec.isArray) {
97
+ return sql`(jsonb_populate_record(NULL::${table(model)}, jsonb_build_object(${snake(key)}::text, ${ownKey}::jsonb))).${column(key)} = ${candidate}`;
98
+ }
99
+ if (type === 'json' || type === 'jsonb') return sql`${ownKey}::jsonb = ${candidate}`;
100
+ if (/^(timestamptz|timestamp(?:\(\d+\))? with time zone)$/i.test(type)) return sql`${ownKey}::timestamptz = ${candidate}`;
101
+ if (/^timestamp(?:\(\d+\))?(?: without time zone)?$/i.test(type)) return sql`${ownKey}::timestamp = ${candidate}`;
102
+ if (type === 'real') return sql`${ownKey}::real = ${candidate}`;
103
+ if (type === 'doublePrecision' || type === 'double precision') return sql`${ownKey}::double precision = ${candidate}`;
104
+ if (type === 'bytea') return sql`${ownKey}::bytea = ${candidate}`;
105
+ if (type === 'date') return sql`${ownKey}::date = ${candidate}`;
106
+ return sql`receipt."key" = ${candidate}::text`;
107
+ }
108
+
109
+ /** Every declared lifecycle edge orders the child before the parent, nullify included. */
110
+ function orderedModels(models: ModelDescriptor[]): ModelDescriptor[] {
111
+ const lifecycle = models.filter(m => m.lifecycle);
112
+ const set = new Set(lifecycle);
113
+ const visiting = new Set<ModelDescriptor>();
114
+ const visited = new Set<ModelDescriptor>();
115
+ const ordered: ModelDescriptor[] = [];
116
+ const visit = (m: ModelDescriptor): void => {
117
+ if (visiting.has(m)) throw new Error(`purge relation cycle at ${label(m)}`);
118
+ if (visited.has(m)) return;
119
+ visiting.add(m);
120
+ for (const rel of Object.values(m.relations)) {
121
+ if (rel.kind === 'hasMany' && rel.dependent) {
122
+ const child = rel.target();
123
+ if (child.lifecycle && !set.has(child)) throw new Error(`purge needs declared child ${label(child)}`);
124
+ if (set.has(child)) visit(child);
125
+ }
126
+ }
127
+ visiting.delete(m); visited.add(m); ordered.push(m);
128
+ };
129
+ for (const m of lifecycle) {
130
+ if (m.primaryKey.length !== 1) throw new Error(`purge needs a single-column primary key on ${label(m)}`);
131
+ visit(m);
132
+ }
133
+ return ordered;
134
+ }
135
+
136
+ async function appendReceipt(tx: SessionTx, runId: string, now: Date, receipt: Receipt): Promise<void> {
137
+ const { model, row, outcome, hook, detail } = receipt;
138
+ await tx.execute(sql`SET LOCAL ROLE everystack_lifecycle`);
139
+ await tx.execute(sql`INSERT INTO everystack.purge_log
140
+ (run_id, "table", "key", deleted_batch, deleted_at, purged_at, actor, outcome, hook, detail)
141
+ VALUES (${runId}, ${label(model)}, ${keyOf(model, row)}, ${row.deleted_batch}, ${param(row.deleted_at)}, ${now.toISOString()}, 'purge', ${outcome}, ${hook}, ${detail ?? null})`);
142
+ }
143
+
144
+ /**
145
+ * Purge expired lifecycle rows, children first, in one transaction per selected row.
146
+ * The executor is the existing ops Drizzle database (execute/transaction); its caller
147
+ * 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
150
+ * skipped receipt on each retry. Plain children with ON DELETE CASCADE are removed by
151
+ * referential integrity; they receive neither a destroy hook nor a purge receipt.
152
+ * The receipt's hook status describes beforeDestroy. afterDestroy is best-effort after
153
+ * commit and cannot change that append-only receipt or undo the physical removal.
154
+ */
155
+ export async function purgeExpired(exec: SessionTx, models: ModelDescriptor[], options: PurgeOptions): Promise<PurgeResult> {
156
+ const started = performance.now();
157
+ const after = options.after;
158
+ const batch = options.batch;
159
+ const budgetMs = options.budgetMs ?? 30_000;
160
+ const retryAfterMs = options.retryAfterMs ?? 3_600_000;
161
+ if (!Number.isFinite(budgetMs) || budgetMs < 0) throw new Error('purge budgetMs must be nonnegative milliseconds');
162
+ if (!Number.isFinite(retryAfterMs) || retryAfterMs < 0) throw new Error('purge retryAfterMs must be nonnegative milliseconds');
163
+ const outOfBudget = (): boolean => performance.now() - started >= budgetMs;
164
+ if (!Number.isFinite(after) || after <= 0) throw new Error('purge after must be positive milliseconds');
165
+ if (!Number.isSafeInteger(batch) || batch <= 0) throw new Error('purge batch must be a positive integer');
166
+ const now = new Date(options.now?.getTime() ?? Date.now());
167
+ const cutoff = new Date(now.getTime() - after);
168
+ if (!Number.isFinite(now.getTime()) || !Number.isFinite(cutoff.getTime())) throw new Error('purge needs a valid clock');
169
+ const retryCutoff = new Date(now.getTime() - retryAfterMs);
170
+ if (!Number.isFinite(retryCutoff.getTime())) throw new Error('purge retryAfterMs exceeds clock range');
171
+ const ordered = orderedModels(models);
172
+ const result: PurgeResult = { runId: randomUUID(), perTable: {}, noHook: [], skipped: [], afterDestroyErrors: 0, afterDestroyFailures: [] };
173
+ let purged = 0;
174
+ const finish = (incomplete = false): PurgeResult => {
175
+ if (incomplete) result.incomplete = true;
176
+ if (purged === 0) result.reason = incomplete ? 'time budget exhausted'
177
+ : result.skipped[0]?.reason ?? 'no expired lifecycle rows';
178
+ return result;
179
+ };
180
+ if (outOfBudget()) return finish(true);
181
+ const unsupported = await unsupportedPurgeGraphs(exec, models);
182
+ for (const model of ordered) {
183
+ const name = label(model);
184
+ const counts = result.perTable[name] = { purged: 0, skipped: 0, failed: 0 };
185
+ const unsupportedReason = unsupported.get(name);
186
+ if (unsupportedReason) {
187
+ // '*' is a table-level refusal, not an attempted row or an audit receipt.
188
+ result.skipped.push({ table: name, key: '*', reason: unsupportedReason });
189
+ continue;
190
+ }
191
+ const attempted = new Set<string>();
192
+ let cursor: Row | undefined;
193
+ while (purged < batch) {
194
+ if (outOfBudget()) return finish(true);
195
+ const tuple = sql.join([sql`deleted_at`, ...model.primaryKey.map(column)], sql`, `);
196
+ const cursorValues = cursor ? sql.join([sql`${param(cursor.deleted_at)}`, ...model.primaryKey.map(k => sql`${fieldParam(model, k, cursor![snake(k)])}`)], sql`, `) : undefined;
197
+ const cooldown = retryAfterMs > 0 ? sql`AND NOT EXISTS (
198
+ SELECT 1 FROM everystack.purge_log AS receipt
199
+ WHERE receipt."table" = ${name} AND ${cooldownKeyMatches(model)}
200
+ AND receipt.outcome IN ('skipped', 'failed') AND receipt.purged_at > ${retryCutoff.toISOString()}
201
+ )` : sql``;
202
+ const page = rowsOf(await exec.execute(sql`SELECT * FROM ${table(model)}
203
+ WHERE deleted_at <= ${cutoff.toISOString()} ${cooldown} ${cursorValues ? sql`AND (${tuple}) > (${cursorValues})` : sql``}
204
+ ORDER BY ${tuple} LIMIT ${Math.min(100, batch - purged)}`));
205
+ if (!page.length) {
206
+ if (retryAfterMs > 0 && purged === 0 && result.skipped.length === 0) {
207
+ const expired = rowsOf(await exec.execute(sql`SELECT 1 FROM ${table(model)} WHERE deleted_at <= ${cutoff.toISOString()} LIMIT 1`));
208
+ if (expired.length) result.skipped.push({ table: name, key: '*', reason: 'retry cooldown' });
209
+ }
210
+ break;
211
+ }
212
+ for (const candidate of page) {
213
+ cursor = candidate;
214
+ if (outOfBudget()) return finish(true);
215
+ const candidateKey = keyOf(model, candidate);
216
+ if (attempted.has(candidateKey)) continue;
217
+ attempted.add(candidateKey);
218
+ let selected: Row | undefined;
219
+ let beforeFailed = false;
220
+ let beforeRan = false;
221
+ let skippedReason: string | undefined;
222
+ let failureReason: string | undefined;
223
+ const removed = await exec.transaction(async tx => {
224
+ const [isolation] = rowsOf(await tx.execute(sql`SHOW transaction_isolation`));
225
+ if (isolation?.transaction_isolation !== 'read committed') throw new Error('purge requires READ COMMITTED');
226
+ const locked = rowsOf(await tx.execute(sql`SELECT * FROM ${table(model)}
227
+ WHERE ${keyWhere(model, candidate)} AND deleted_at <= ${cutoff.toISOString()} FOR UPDATE SKIP LOCKED`));
228
+ selected = locked[0];
229
+ if (!selected) return false;
230
+ const row = selected;
231
+ // Selection precedes the row lock. Another run may have committed a failure since then.
232
+ if (retryAfterMs > 0) {
233
+ const cooling = rowsOf(await tx.execute(sql`SELECT 1 FROM ${table(model)}
234
+ WHERE ${keyWhere(model, row)} AND EXISTS (
235
+ SELECT 1 FROM everystack.purge_log AS receipt
236
+ WHERE receipt."table" = ${name} AND ${cooldownKeyMatches(model)}
237
+ AND receipt.outcome IN ('skipped', 'failed') AND receipt.purged_at > ${retryCutoff.toISOString()}
238
+ )`));
239
+ if (cooling.length) { skippedReason = 'retry cooldown'; return false; }
240
+ }
241
+ try {
242
+ // Keep the row lock outside the savepoint, including while recording a failed attempt.
243
+ return await tx.transaction(async work => {
244
+ for (const rel of Object.values(model.relations)) {
245
+ const child = rel.kind === 'hasMany' && (rel.dependent === 'cascade' || rel.dependent === 'restrict') ? rel.target() : undefined;
246
+ if (!child?.lifecycle) continue;
247
+ const reference = rel.references ?? model.primaryKey[0];
248
+ const remains = rowsOf(await work.execute(sql`SELECT 1 FROM ${table(child)} WHERE ${column(rel.column)} = ${fieldParam(model, reference, row[snake(reference)])} LIMIT 1`));
249
+ if (remains.length) {
250
+ skippedReason = `remaining lifecycle child ${label(child)}`;
251
+ await appendReceipt(work, result.runId, now, { model, row, outcome: 'skipped', hook: 'none', detail: skippedReason });
252
+ return false;
253
+ }
254
+ }
255
+ const caller = model.hooks?.beforeDestroy
256
+ ? rowsOf(await work.execute(sql`SELECT current_user AS role`))[0] : undefined;
257
+ 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`, `)}`));
259
+ if (deleted.length !== 1 || keyOf(model, deleted[0]) !== keyOf(model, row)) throw new Error('purge DELETE did not return the locked key');
260
+ // Refuse deferred constraints before invoking a hook with external effects.
261
+ await work.execute(sql`SET CONSTRAINTS ALL IMMEDIATE`);
262
+ if (caller) await work.execute(sql`SET LOCAL ROLE ${sql.identifier(String(caller.role))}`);
263
+ if (model.hooks?.beforeDestroy) {
264
+ try { await model.hooks.beforeDestroy(hookRow(model, row), context); beforeRan = true; }
265
+ catch (error) { beforeFailed = true; throw error; }
266
+ }
267
+ await appendReceipt(work, result.runId, now, { model, row, outcome: 'purged', hook: beforeRan ? 'ran' : 'none' });
268
+ return true;
269
+ });
270
+ } catch (error) {
271
+ failureReason = error instanceof Error ? error.message : String(error);
272
+ await appendReceipt(tx, result.runId, now, { model, row, outcome: 'failed', hook: beforeFailed ? 'failed' : beforeRan ? 'ran' : 'none', detail: failureReason });
273
+ return false;
274
+ }
275
+ });
276
+ if (failureReason !== undefined) {
277
+ counts.failed++;
278
+ result.skipped.push({ table: name, key: keyOf(model, selected!), reason: failureReason });
279
+ continue;
280
+ }
281
+ if (skippedReason) {
282
+ counts.skipped++;
283
+ result.skipped.push({ table: name, key: keyOf(model, candidate), reason: skippedReason });
284
+ }
285
+ if (!removed) {
286
+ if (!selected) {
287
+ counts.skipped++;
288
+ result.skipped.push({ table: name, key: candidateKey, reason: 'row locked or no longer expired' });
289
+ }
290
+ continue;
291
+ }
292
+ purged++; counts.purged++;
293
+ if (!model.hooks?.beforeDestroy && !model.hooks?.afterDestroy && !result.noHook.includes(name)) result.noHook.push(name);
294
+ if (model.hooks?.afterDestroy) {
295
+ try { await model.hooks.afterDestroy(hookRow(model, selected!), context); }
296
+ catch (error) {
297
+ // Formatting an arbitrary rejection must not lose a committed outcome.
298
+ let message = 'unreadable error';
299
+ let stack: string | undefined;
300
+ try { message = String(error instanceof Error ? error.message : error); } catch { /* Keep the fallback. */ }
301
+ try { stack = error instanceof Error ? error.stack : undefined; } catch { /* Stack is optional. */ }
302
+ const key = keyOf(model, selected!);
303
+ result.afterDestroyErrors++;
304
+ result.afterDestroyFailures.push({ table: name, key, message });
305
+ try {
306
+ await options.onError?.({ kind: 'hook', name: `${name}.afterDestroy`, method: 'DELETE', status: 500,
307
+ table: name, key, message: `after-hook failed after the write: ${message}`,
308
+ stack });
309
+ } catch { /* The observability sink cannot replace a committed outcome. */ }
310
+ }
311
+ }
312
+ if (purged >= batch) break;
313
+ }
314
+ }
315
+ if (purged >= batch) break;
316
+ }
317
+ return finish();
318
+ }
319
+
320
+ /** Count expired lifecycle rows without invoking hooks or changing data. */
321
+ export async function overdueLifecycleRows(
322
+ exec: Pick<SessionTx, 'execute'>,
323
+ models: ModelDescriptor[],
324
+ options: { after: number },
325
+ ): Promise<Record<string, number>> {
326
+ if (!Number.isFinite(options.after) || options.after <= 0) throw new Error('purge after must be positive milliseconds');
327
+ const cutoff = new Date(Date.now() - options.after);
328
+ if (!Number.isFinite(cutoff.getTime())) throw new Error('purge after exceeds clock range');
329
+ const counts: Record<string, number> = {};
330
+ for (const model of models.filter(m => m.lifecycle)) {
331
+ const [row] = rowsOf(await exec.execute(sql`SELECT count(*) AS count FROM ${table(model)} WHERE deleted_at <= ${cutoff.toISOString()}`));
332
+ const count = Number(row.count);
333
+ if (!Number.isSafeInteger(count) || count < 0) throw new Error(`invalid overdue count on ${label(model)}`);
334
+ counts[label(model)] = count;
335
+ }
336
+ return counts;
337
+ }
package/src/role-chain.ts CHANGED
@@ -168,6 +168,32 @@ export function generateReaderRoleSQL(options: ReaderRoleOptions = {}): string {
168
168
  ].join('\n') + '\n';
169
169
  }
170
170
 
171
+ /**
172
+ * The reserved role `lifecycle: true` models compile grants and policies to, and that owns their
173
+ * SECURITY DEFINER functions. Equal to LIFECYCLE_OWNER_ROLE in @everystack/model (this package
174
+ * keeps no dependency on it).
175
+ */
176
+ export const LIFECYCLE_ROLE = 'everystack_lifecycle';
177
+
178
+ /**
179
+ * Generate idempotent DDL for the reserved lifecycle role: create it if absent, then re-assert
180
+ * its attributes so a hand-made role is brought into line.
181
+ *
182
+ * - NOLOGIN — no one connects as it. It is a function owner and a grantee, not an identity.
183
+ * - NOINHERIT — it never ambiently gains another role's privileges.
184
+ * - NOBYPASSRLS — its reads and writes go through the policies the compiler writes for it.
185
+ *
186
+ * It grants the role NOTHING and grants it TO no one. Membership is SET ROLE, and SET ROLE to it
187
+ * passes the lifecycle guard, so no tool hands membership out here (db:provision reports members).
188
+ */
189
+ export function generateLifecycleRoleSQL(role: string = LIFECYCLE_ROLE): string {
190
+ assertIdent(role, 'lifecycle role');
191
+ return [
192
+ `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE ${role} NOLOGIN NOINHERIT NOBYPASSRLS; END IF; END $$;`,
193
+ `ALTER ROLE ${role} WITH NOLOGIN NOINHERIT NOBYPASSRLS;`,
194
+ ].join('\n') + '\n';
195
+ }
196
+
171
197
  export interface ReaderGrantOptions {
172
198
  /** The SELECT-only login the grants are made TO. Default 'reader'. */
173
199
  readerRole?: string;