@everystack/server 0.4.37 → 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 +35 -0
- package/package.json +11 -3
- package/src/cache-control.ts +36 -0
- package/src/index.ts +7 -1
- package/src/kvs-metrics.ts +153 -0
- package/src/kvs.ts +326 -29
- package/src/plugin.ts +7 -1
- package/src/provision.ts +97 -1
- package/src/purge-graph.ts +91 -0
- package/src/purge-log.ts +63 -0
- package/src/purge.ts +375 -0
- package/src/role-chain.ts +26 -0
package/src/purge.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
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
|
+
}
|
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;
|