@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 +35 -0
- package/package.json +18 -8
- package/src/destroy-job.ts +74 -0
- package/src/destroy-log.ts +2 -0
- package/src/destroy.ts +226 -0
- package/src/plugin.ts +3 -0
- package/src/provision.ts +4 -0
- package/src/purge-graph.ts +0 -91
- package/src/purge-log.ts +0 -63
- package/src/purge.ts +0 -375
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.
|
|
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
|
-
"./
|
|
130
|
-
"source": "./src/
|
|
131
|
-
"types": "./src/
|
|
132
|
-
"default": "./src/
|
|
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/
|
|
201
|
-
"@everystack/
|
|
210
|
+
"@everystack/auth": "0.4.10",
|
|
211
|
+
"@everystack/cli": "0.4.105"
|
|
202
212
|
},
|
|
203
213
|
"dependencies": {
|
|
204
|
-
"@everystack/model": "0.4.
|
|
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
|
+
}
|
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,
|
package/src/purge-graph.ts
DELETED
|
@@ -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
|
-
}
|