@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/CHANGELOG.md +27 -0
- package/package.json +11 -3
- package/src/cache-control.ts +36 -0
- package/src/index.ts +7 -1
- package/src/kvs-metrics.ts +153 -0
- package/src/kvs.ts +326 -29
- package/src/plugin.ts +7 -1
- package/src/provision.ts +97 -1
- package/src/purge-graph.ts +91 -0
- package/src/purge-log.ts +63 -0
- package/src/purge.ts +337 -0
- package/src/role-chain.ts +26 -0
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
|
+
}
|
package/src/purge-log.ts
ADDED
|
@@ -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
|
+
}
|