@everystack/server 0.4.37 → 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/provision.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * without a database; plugin.ts wires the real deps.
21
21
  */
22
22
 
23
- import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL, generateReaderRoleSQL } from './role-chain';
23
+ import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL, generateReaderRoleSQL, generateLifecycleRoleSQL, LIFECYCLE_ROLE } from './role-chain';
24
24
  import {
25
25
  pgVersionRefusal,
26
26
  ownerFactsSql,
@@ -107,6 +107,12 @@ export interface ProvisionPayload {
107
107
  searchPath?: string[];
108
108
  /** Create A/B rotation pairs for credential rotation without downtime. */
109
109
  rotationPairs?: boolean;
110
+ /**
111
+ * The Models declare a `lifecycle: true` model, so provision the reserved role it compiles to
112
+ * (everystack_lifecycle): NOLOGIN NOINHERIT NOBYPASSRLS, no grants, no members. Derived from the
113
+ * Models barrel by the CLI. Absent or false (an older CLI) = no role is created, as before.
114
+ */
115
+ lifecycleRole?: boolean;
110
116
  }
111
117
 
112
118
  /**
@@ -148,6 +154,20 @@ export interface ProvisionDeps {
148
154
  * not-existing (the role is being created now, so its password IS set).
149
155
  */
150
156
  roleHasLogin?: (role: string) => Promise<boolean>;
157
+ /**
158
+ * The lifecycle role's name. A TEST seam only: the role is cluster-level and a dev cluster may
159
+ * already hold the real one. No caller sets it; absent = everystack_lifecycle.
160
+ */
161
+ lifecycleRoleName?: string;
162
+ }
163
+
164
+ /** One direct member of the lifecycle role, with the options its membership carries. */
165
+ export interface LifecycleRoleMember {
166
+ role: string;
167
+ admin: boolean;
168
+ inherit: boolean;
169
+ /** Can SET ROLE to it (always true before PostgreSQL 16, which has no per-grant SET option). */
170
+ set: boolean;
151
171
  }
152
172
 
153
173
  export interface ProvisionResult {
@@ -202,6 +222,23 @@ export interface ProvisionResult {
202
222
  * says nothing about it. Same non-fatal contract as ENACT: never throws, never aborts.
203
223
  */
204
224
  publicSchemaPosture?: PublicSchemaPosture;
225
+ /**
226
+ * The reserved lifecycle role. Absent when the payload did not ask for it. `created` = false means
227
+ * it existed and its attributes were re-asserted. `members` are its direct members, reported and
228
+ * never revoked: a member can SET ROLE to it, and that passes the lifecycle guard. Null when this
229
+ * caller cannot read (no `query` dep).
230
+ */
231
+ lifecycleRole?: {
232
+ role: string;
233
+ created: boolean | null;
234
+ members: LifecycleRoleMember[] | null;
235
+ /**
236
+ * Elevated attributes an existing role carries that provision does not strip (CREATEROLE,
237
+ * CREATEDB, REPLICATION), for the CLI to name. SUPERUSER is never here: it is refused.
238
+ * Null when this caller cannot read.
239
+ */
240
+ attributes: string[] | null;
241
+ };
205
242
  error?: string;
206
243
  }
207
244
 
@@ -238,8 +275,11 @@ export async function runProvision(
238
275
  let roleSql: string;
239
276
  let adminSql: string | null = null;
240
277
  let readerSql: string | null = null;
278
+ let lifecycleSql: string | null = null;
279
+ const lifecycleRole = deps.lifecycleRoleName ?? LIFECYCLE_ROLE;
241
280
  try {
242
281
  roleSql = generateRoleChainSQL({ loginRole, appRoles });
282
+ if (payload.lifecycleRole === true) lifecycleSql = generateLifecycleRoleSQL(lifecycleRole);
243
283
  if (adminPassword) adminSql = generateAdminRoleSQL({ adminRole });
244
284
  if (readerPassword) readerSql = generateReaderRoleSQL({ readerRole });
245
285
  } catch (err: any) {
@@ -256,6 +296,30 @@ export async function runProvision(
256
296
  const adminExisted = !!adminPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(admin));
257
297
  const readerExisted = !!readerPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(reader));
258
298
 
299
+ // The lifecycle role's live state, read BEFORE any DDL so a refusal leaves nothing half-done.
300
+ let lifecycleLive: { existed: boolean; attributes: string[] } | null = null;
301
+ if (lifecycleSql && deps.query) {
302
+ const [row] = await deps.query(
303
+ `SELECT rolsuper, rolcreaterole, rolcreatedb, rolreplication FROM pg_roles WHERE rolname = '${lifecycleRole}'`,
304
+ );
305
+ const on = (v: unknown): boolean => v === true || v === 't' || v === 'true';
306
+ if (row && on(row.rolsuper)) {
307
+ // Not fixed by the ALTER: PostgreSQL 16 refuses NOSUPERUSER from a non-superuser, and a
308
+ // superuser owning SECURITY DEFINER functions makes every one of them run as a superuser.
309
+ throw new Error(
310
+ `the lifecycle role '${lifecycleRole}' exists with SUPERUSER — refused before any change. ` +
311
+ `As a superuser run: ALTER ROLE ${lifecycleRole} NOSUPERUSER; (or drop the role and re-run db:provision).`,
312
+ );
313
+ }
314
+ lifecycleLive = {
315
+ existed: !!row,
316
+ attributes: row
317
+ ? ([['CREATEROLE', row.rolcreaterole], ['CREATEDB', row.rolcreatedb], ['REPLICATION', row.rolreplication]] as const)
318
+ .filter(([, v]) => on(v)).map(([name]) => name)
319
+ : [],
320
+ };
321
+ }
322
+
259
323
  await deps.execute(roleSql);
260
324
 
261
325
  const result: ProvisionResult = {
@@ -265,6 +329,18 @@ export async function runProvision(
265
329
  passwordSet: false,
266
330
  };
267
331
 
332
+ // The reserved lifecycle role — a NOLOGIN role with no password, so it sits with the role chain,
333
+ // ahead of every password and well clear of the password-to-secret window.
334
+ if (lifecycleSql) {
335
+ await deps.execute(lifecycleSql);
336
+ result.lifecycleRole = {
337
+ role: lifecycleRole,
338
+ created: lifecycleLive ? !lifecycleLive.existed : null,
339
+ members: deps.query ? await lifecycleRoleMembers(lifecycleRole, deps.query) : null,
340
+ attributes: lifecycleLive ? lifecycleLive.attributes : null,
341
+ };
342
+ }
343
+
268
344
  // The role-default search_path (search-path-ownership): set it on the login role now that it
269
345
  // exists. The right layer — a clean connection secret, bare refs resolved for every direct
270
346
  // connection, and it persists through the API's SET ROLE. No-op for an all-public app.
@@ -387,6 +463,26 @@ export async function runProvision(
387
463
  return result;
388
464
  }
389
465
 
466
+ /**
467
+ * The direct members of the lifecycle role, from pg_auth_members. `to_jsonb(m)` reads the
468
+ * PostgreSQL 16 per-grant options without naming columns an older server lacks; before 16 every
469
+ * membership could SET ROLE and INHERIT followed the member, so a missing option reads that way.
470
+ */
471
+ async function lifecycleRoleMembers(role: string, query: (sql: string) => Promise<any[]>): Promise<LifecycleRoleMember[]> {
472
+ const rows = await query(
473
+ `SELECT r.rolname AS member, m.admin_option AS admin,
474
+ COALESCE((to_jsonb(m) ->> 'inherit_option')::boolean, r.rolinherit) AS inherit,
475
+ COALESCE((to_jsonb(m) ->> 'set_option')::boolean, true) AS set
476
+ FROM pg_auth_members m
477
+ JOIN pg_roles g ON g.oid = m.roleid
478
+ JOIN pg_roles r ON r.oid = m.member
479
+ WHERE g.rolname = '${role}'
480
+ ORDER BY r.rolname`,
481
+ );
482
+ const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
483
+ return rows.map((r) => ({ role: String(r.member), admin: truthy(r.admin), inherit: truthy(r.inherit), set: truthy(r.set) }));
484
+ }
485
+
390
486
  /**
391
487
  * Converge declared ownership — ENACT (Gap 1) then ADOPT (Gap 2) — as a step SEPARATE from and
392
488
  * AFTER credential provisioning + the secret-store write (see runProvision's closing note). By the
@@ -0,0 +1,91 @@
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
+ }
@@ -0,0 +1,63 @@
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 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
+ }