@everystack/cli 0.4.103 → 0.4.105

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.
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * The lifecycle triggers — derived objects GENERATED from every `lifecycle: true` model in a set.
3
3
  *
4
- * Per lifecycle table `<t>`, two functions and two triggers, built with the ordinary descriptors so
4
+ * Per lifecycle table `<t>`, generated functions and triggers use the ordinary descriptors so
5
5
  * compile, introspect, reconcile and fingerprint treat them like any declared object:
6
6
  *
7
7
  * - `<t>_lifecycle_transition` (BEFORE DELETE): instead of deleting, a SECURITY DEFINER function
8
- * owned by the lifecycle role stamps the four lifecycle columns and skips the delete. A row that
8
+ * owned by the lifecycle role sets the four lifecycle columns and skips the delete. A row that
9
9
  * is already deleted keeps its stamps. It refuses to run as a trigger on any other table: a
10
10
  * role holding EXECUTE could otherwise attach it to its own table and soft-delete `<t>` rows by id.
11
11
  * It hands the batch to a caller that cannot read it: for a top-level DELETE (trigger depth 1) it
12
12
  * appends `<JSON-quoted key> <batch>` as a line to the transaction-local setting
13
- * `everystack.lifecycle_batch`, one line per stamped root. Informational, never a gate: any role
13
+ * `everystack.lifecycle_batch`, one line per deleted root. Informational, never a gate: any role
14
14
  * can set that name, and the stamp columns stay the truth.
15
15
  * - `<t>_lifecycle_guard` (BEFORE INSERT OR UPDATE): refuses any direct write to those columns.
16
16
  * - `<t>_lifecycle_delete_when` (BEFORE UPDATE OF deleted_at, live -> deleted), only for
17
- * lifecycle: { deleteWhen }: refuses the stamp (23514) unless the predicate is TRUE for the row,
17
+ * lifecycle: { deleteWhen }: refuses the delete (23514) unless the predicate is TRUE for the row,
18
18
  * for a direct delete and a cascade alike. Its predicate rides on its own manifest only.
19
19
  * - `<t>_restore(p_id)`: undoes a delete for whoever may delete p_id. A SECURITY DEFINER
20
20
  * function owned by the lifecycle role, EXECUTE to exactly the roles with a delete rule. The
@@ -22,36 +22,37 @@
22
22
  * holds USAGE on that role (never MEMBER: the authenticator is a NOINHERIT member of every app
23
23
  * role) AND the role's delete predicate, read from the compiled DELETE policies, holds on the
24
24
  * row. Every refusal raises the same message, naming only the table and the id. It brings back
25
- * the whole cascade subtree that delete stamped, on p_id's authority alone: the delete took
25
+ * the whole cascade subtree that delete marked, on p_id's authority alone: the delete took
26
26
  * those rows whatever the deleter's rights over them, so its undo must too, or a user's
27
27
  * restore would leave the likes others put on their posts deleted for good. It never makes a
28
28
  * live row under a deleted parent (see restoreFunction), and it returns counts and the batch it
29
29
  * restored (`{ restored, skipped, batch }`; batch null for a legacy row), never ids.
30
30
  *
31
31
  * A lifecycle parent's hasMany `dependent` cannot ride the foreign key: its DELETE never removes the
32
- * row, so the FK never fires (relationDeleteActions compiles a lifecycle parent's 'cascade' to
33
- * RESTRICT). The cascade trigger and the transition carry it instead, generated from the MODEL SET:
32
+ * row, so the FK never fires. Physical removal follows the declared FK action. Soft deletion uses
33
+ * the cascade trigger and transition instead, generated from the MODEL SET:
34
34
  *
35
35
  * - `<t>_lifecycle_cascade` (AFTER UPDATE OF deleted_at, live -> deleted): copies the parent's four
36
- * stamps onto its live 'cascade' children, so the whole tree shares one batch, one xid and the
36
+ * lifecycle values onto its live 'cascade' children, so the whole tree shares one batch, one xid and the
37
37
  * deleter's sub. It runs as the lifecycle role, so the deleter needs no rights over the children.
38
- * It UPDATEs, never DELETEs: as the lifecycle role a child's transition is skipped, so a DELETE
39
- * would really delete. Depth comes from each child's own cascade trigger; a child reached twice
40
- * (a diamond) is already stamped the second time and the `deleted_at IS NULL` filter skips it.
38
+ * It UPDATEs, never DELETEs: a DELETE would give each child its own batch. Depth
39
+ * comes from each child's own cascade trigger; a child reached twice
40
+ * (a diamond) is already deleted the second time and the `deleted_at IS NULL` filter skips it.
41
41
  * - 'restrict': the transition of every table whose cascade subtree holds a 'restrict' parent,
42
- * after its stamp (so after the whole cascade), refuses (23503) while a LIVE child references a
43
- * row the delete stamped. One check, after everything is stamped: a child the same delete
44
- * stamped does not count, whatever order the cascade ran in (see restrictCheck).
42
+ * after its delete (so after the whole cascade), refuses (23503) while a LIVE child references a
43
+ * row the delete marked deleted. One check, after everything is marked deleted: a child the same delete
44
+ * marked deleted does not count, whatever order the cascade ran in (see restrictCheck).
45
45
  * The contract: 'restrict' permits a delete when its live children are deleted by the same cascade.
46
46
  * A multi-row DELETE runs one transition per row, each its own batch, in scan order: rows that
47
47
  * share a restrict child through different families can refuse depending on that order, never
48
48
  * accept a live child under a deleted parent.
49
49
  * Author triggers must not write the lifecycle columns: the check trusts that only delete and
50
50
  * restore set deleted_batch.
51
- * A trigger fired by a stamp or by purge runs inside these functions' pinned search_path
51
+ * A trigger fired by a soft delete or by destroy runs inside these functions' pinned search_path
52
52
  * (pg_catalog, pg_temp), so it must schema-qualify every table name.
53
- * A DELETE a consumer trigger issues on a lifecycle table during a stamp or purge is a soft delete
54
- * (TRANSITION_WHEN lets depth > 0 through), not a hard delete.
53
+ * During destroy, a DELETE (a consumer trigger's included) removes a row only if it is already
54
+ * soft-deleted and either before the call's cutoff or a cascade child whose declared cascade
55
+ * parent is gone; any other row raises 23503. Otherwise deletes are soft.
55
56
  *
56
57
  * Both refuse outside READ COMMITTED: a REPEATABLE READ snapshot cannot see a child committed after
57
58
  * it began, so the cascade would leave that child live, and the restrict count would miss it.
@@ -59,11 +60,12 @@
59
60
  * INSERT OR UPDATE OF the foreign key): a live row may not point at a deleted parent, and a
60
61
  * missing parent gets the same refusal, so the message never tells a hidden deleted row from no
61
62
  * row at all. It reads the parent FOR SHARE, which conflicts with the FOR NO KEY UPDATE a
62
- * concurrent stamp takes; the FK's own FOR KEY SHARE does not, so without it an insert racing a
63
+ * concurrent delete takes; the FK's own FOR KEY SHARE does not, so without it an insert racing a
63
64
  * cascade would land live.
64
65
  *
65
- * Every trigger but the cascade skips the lifecycle role, the one role that really deletes and
66
- * stamps. The role test lives in the trigger's WHEN clause, which PostgreSQL evaluates as the
66
+ * The guard and the parent check skip the lifecycle role, the one role that marks rows deleted. The
67
+ * transition does not: it tests only the destroy marker, so outside a destroy call that role's
68
+ * DELETE is soft too. The role test lives in the trigger's WHEN clause, which PostgreSQL evaluates as the
67
69
  * INVOKING user before it calls the function. Inside the definer function `current_user` is always
68
70
  * the lifecycle role, so the body never tests it. The transition's own UPDATE runs as that role, so
69
71
  * the guard skips it. The cascade must not skip it: its recursion IS the lifecycle role's UPDATE.
@@ -72,13 +74,20 @@
72
74
  * Every function here refuses to run as a trigger on any table but its own (42501), for the
73
75
  * transition's reason: EXECUTE, or a superuser's mistake, must not let it act on `<t>`'s children.
74
76
  *
77
+ * `<t>_lifecycle_destroy` removes eligible rows under a private, transaction-scoped authority: a
78
+ * destroy_log marker, holding the call's cutoff, that exists only while the call runs.
79
+ * `<t>_lifecycle_destroy_before` exposes only that cutoff (NULL without a marker) to the transition,
80
+ * which really removes a row only when it was soft-deleted before it (see eligibleOnly). No role but
81
+ * the log's owner can write or read it: the lifecycle role goes through everystack.destroy_log_mark,
82
+ * destroy_log_unmark and destroy_cutoff (packages/model/src/destroy-log.ts).
83
+ *
75
84
  * Computed from the models every time, never stored on a module.
76
85
  */
77
86
 
78
- import { can, defineFunction, trigger, sql, parseQualified, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE } from '@everystack/model';
87
+ import { can, defineFunction, trigger, sql, parseQualified, lifecycleDestroyOrder, relationDeleteActions, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE } from '@everystack/model';
79
88
  import type { DerivedDescriptor, ModelDescriptor, Relation, FunctionDescriptor, TriggerDefinition, TriggerSpec } from '@everystack/model';
80
- import { pgType, toSnakeCase } from './schema-compile';
81
- import { deleteRulePredicates } from './authz-compile';
89
+ import { pgType, toSnakeCase, modelForeignKeys } from './schema-compile';
90
+ import { allRowsManageRoles, deleteRulePredicates } from './authz-compile';
82
91
  import { canonHash, canonOfFunction } from './lifecycle-canon';
83
92
 
84
93
  /** Exact inputs read by the lifecycle builders, serializable without model references. */
@@ -88,6 +97,8 @@ export interface LifecycleTable {
88
97
  pk: { column: string; type: string };
89
98
  unique: boolean;
90
99
  auth: Record<string, string>;
100
+ /** The trusted audience for physical removal, independent of delete predicates. */
101
+ manageRoles?: string[];
91
102
  }
92
103
  export interface LifecycleEdge {
93
104
  parent: string;
@@ -95,12 +106,26 @@ export interface LifecycleEdge {
95
106
  column: string;
96
107
  dependent: 'cascade' | 'restrict';
97
108
  }
109
+ export interface LifecycleDestroyReference {
110
+ child: { schema: string; table: string };
111
+ columns: string[];
112
+ refColumns: string[];
113
+ }
98
114
  export interface LifecycleDeclaration {
99
115
  tables: Record<string, LifecycleTable>;
100
116
  edges: LifecycleEdge[];
117
+ /** All declared inbound restrict/no-action FKs of this root; absent on older recorded inputs. */
118
+ destroyReferences?: LifecycleDestroyReference[];
119
+ /**
120
+ * The keys this root declares 'nullify' (`schema.table` of the child, its column). No builder reads
121
+ * them: a soft delete leaves the parent row, so a nullify needs nothing until destroy's SET NULL.
122
+ * They record the declaration for the live catalog check. Always present (empty when the root
123
+ * declares none): its absence marks a manifest from a CLI that predates the record.
124
+ */
125
+ nullify?: Array<{ child: string; column: string }>;
101
126
  }
102
127
  export const MANIFEST_VERSION = 1;
103
- export const LIFECYCLE_ROLES = ['transition', 'guard', 'restore', 'cascade', 'parent', 'delete_when'] as const;
128
+ export const LIFECYCLE_ROLES = ['transition', 'guard', 'restore', 'cascade', 'parent', 'delete_when', 'destroy_before', 'destroy'] as const;
104
129
  export type LifecycleRole = typeof LIFECYCLE_ROLES[number];
105
130
  export interface LifecycleManifest {
106
131
  everystack: 'lifecycle';
@@ -142,13 +167,43 @@ const pkType = (m: LifecycleTable): string => m.pk.type;
142
167
  const hasUnique = (m: LifecycleTable): boolean => m.unique;
143
168
  const [AT, BY, BATCH, XID] = LIFECYCLE_COLUMNS.map((c) => ident(toSnakeCase(c)));
144
169
  const SKIP_LIFECYCLE_ROLE = sql`CURRENT_USER <> ${literal(LIFECYCLE_OWNER_ROLE)}`;
170
+ /** The HINT of the transition's refusal of a live row (the tamper alarm); destroy matches it, never message text. */
171
+ const INELIGIBLE_LIVE = 'everystack:ineligible_live';
172
+
145
173
  /**
146
- * The transition skips only a TOP-LEVEL DELETE by the lifecycle role: purge's real removal. A DELETE
147
- * issued from inside a trigger (a consumer trigger fired by a stamp or by purge runs as the lifecycle
148
- * role, because the generated functions are SECURITY DEFINER) is soft-deleted like any other. The
149
- * WHEN is evaluated at the depth of the DELETE statement itself, so purge's top-level DELETE sees 0.
174
+ * The transition's first decision, before any soft-delete work. During a destroy call (the
175
+ * destroy_log marker of this transaction holds the call's cutoff), a lifecycle DELETE really removes
176
+ * a row only if it is already soft-deleted AND either before that cutoff (the test the call applies
177
+ * to its roots) or a cascade child whose declared cascade parent is gone: soft delete decided its
178
+ * fate with the root's, so eligibility follows the root, whatever the child's own stamp. The parent
179
+ * check is exact: a BEFORE DELETE fired by the parent's ON DELETE CASCADE runs after the parent row is
180
+ * removed, and a non-NULL key with no parent row can only mean a removal in this transaction. Any
181
+ * other row raises 23503 (foreign_key_violation): a live row (HINT `everystack:ineligible_live`, the
182
+ * tamper alarm) or a soft-deleted one that is neither expired nor cascading (a consumer trigger may
183
+ * not erase it early). The destroy function retries each root alone and reports one that still
184
+ * raises as 'skipped', so nothing is removed for it and no child is left under a removed parent. The
185
+ * message names this table, never a row. Without a marker the cutoff is NULL and the row takes the
186
+ * soft-delete path, whoever issues the DELETE, the lifecycle role included. Application roles must
187
+ * not own these tables, be superusers, or inherit or assume the lifecycle or manage roles: owners
188
+ * can disable triggers.
150
189
  */
151
- const TRANSITION_WHEN = sql`CURRENT_USER <> ${literal(LIFECYCLE_OWNER_ROLE)} OR pg_catalog.pg_trigger_depth() > 0`;
190
+ function eligibleOnly(model: LifecycleTable, cascadeParents: readonly LifecycleDependent[]): string {
191
+ const parentGone = cascadeParents.map((d) => `
192
+ OR (OLD.${ident(d.column)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${qualified(d.parent)} p WHERE p.${pkOf(d.parent)} = OLD.${ident(d.column)}))`).join('');
193
+ return `lc.cutoff := ${ident(model.schema)}.${ident(model.table + '_lifecycle_destroy_before')}();
194
+ IF lc.cutoff IS NOT NULL THEN
195
+ IF OLD.${AT} IS NOT NULL AND (OLD.${AT} < lc.cutoff${parentGone}) THEN
196
+ RETURN OLD;
197
+ END IF;
198
+ IF OLD.${AT} IS NULL THEN
199
+ RAISE EXCEPTION USING ERRCODE = '23503', MESSAGE = ${literal(`destroy refused: a ${labelOf(model)} row is not eligible for destroy (it is live; only deleted rows are removed)`)},
200
+ SCHEMA = ${literal(model.schema)}, TABLE = ${literal(model.table)}, HINT = ${literal(INELIGIBLE_LIVE)};
201
+ END IF;
202
+ RAISE EXCEPTION USING ERRCODE = '23503', MESSAGE = ${literal(
203
+ `destroy refused: a ${labelOf(model)} row is not eligible for destroy (it is neither deleted before the call's cutoff nor removed with its cascade parent)`,
204
+ )}, SCHEMA = ${literal(model.schema)}, TABLE = ${literal(model.table)};
205
+ END IF;`;
206
+ }
152
207
 
153
208
  /** The first statement of every generated trigger function: run only as a trigger on `model`. */
154
209
  function ownTableOnly(model: LifecycleTable, fnName: string): string {
@@ -170,7 +225,7 @@ interface ModelDependent {
170
225
 
171
226
  /**
172
227
  * Every 'cascade' and 'restrict' dependent of a lifecycle parent in the set, sorted by parent,
173
- * child and column so the generated bodies are byte-stable. 'nullify' needs nothing until purge:
228
+ * child and column so the generated bodies are byte-stable. 'nullify' needs nothing until destroy:
174
229
  * a soft delete leaves the parent row, so the child's reference stays valid. A 'cascade' or
175
230
  * 'restrict' onto a plain child is refused by relationDeleteActions; it is skipped here, never
176
231
  * half-generated.
@@ -212,12 +267,14 @@ DECLARE
212
267
  claims text := pg_catalog.current_setting('request.jwt.claims', true);
213
268
  actor text;
214
269
  batch uuid := pg_catalog.gen_random_uuid();
270
+ cutoff timestamptz;
215
271
  ${check ? `${check.declare}\n` : ''}BEGIN
216
272
  IF TG_TABLE_SCHEMA <> ${literal(model.schema)} OR TG_TABLE_NAME <> ${literal(model.table)} THEN
217
273
  RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = ${literal(
218
274
  `${fn('transition')} belongs to ${label} and refuses to run as a trigger on `,
219
275
  )} || TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME;
220
276
  END IF;
277
+ ${eligibleOnly(model, lifecycleParents(model, dependents).filter((d) => d.dependent === 'cascade'))}
221
278
  ${check ? ` ${readCommittedOnly(model)}\n` : ''} IF lc.claims IS NOT NULL AND lc.claims <> '' THEN
222
279
  lc.actor := lc.claims::pg_catalog.jsonb ->> 'sub';
223
280
  END IF;
@@ -265,8 +322,9 @@ END`,
265
322
  transition,
266
323
  guard,
267
324
  restoreFunction(model, dependents, decl),
325
+ ...destroyFunctions(model, dependents, decl),
268
326
  lifecycleTrigger(`${model.table}_lifecycle_transition`, {
269
- on: model, timing: 'before', events: ['delete'], forEach: 'row', condition: TRANSITION_WHEN, execute: transition,
327
+ on: model, timing: 'before', events: ['delete'], forEach: 'row', execute: transition,
270
328
  }),
271
329
  lifecycleTrigger(`${model.table}_lifecycle_guard`, {
272
330
  on: model, timing: 'before', events: ['insert', 'update'], forEach: 'row', condition: skipLifecycleRole, execute: guard,
@@ -277,6 +335,133 @@ END`,
277
335
  ];
278
336
  }
279
337
 
338
+ /**
339
+ * Physical removal runs inside one call that writes its destroy_log marker (with p_before, the cutoff
340
+ * every transition checks) first and removes it before it returns, so the log is empty at rest. The
341
+ * marker functions refuse to run inside a trigger (55000), so the call must be a top-level statement:
342
+ * a nested call, and a consumer trigger's attempt to remove or rewrite the marker, are refused. If the
343
+ * marker is gone at the end anyway (code the log's owner wrote removed it), a cascaded child may have
344
+ * been soft-deleted under a removed root, so the call raises 55000 and rolls back whole. Any remaining
345
+ * declared restrict/no-action foreign key defers its root, dead children included; a live blocker leaves it overdue for
346
+ * db:doctor to report. lifecycleDestroyOrder orders declared dependents; callers order ordinary
347
+ * FK children first themselves. Self-restrict
348
+ * trees converge one level per call. Fewer than p_max_rows may return: concurrent locks are skipped.
349
+ * The caller sets statement_timeout BEFORE invoking and passes the SAME p_before used to select
350
+ * candidates: a row restored and re-deleted after that cutoff is skipped, so stale hook approval
351
+ * cannot destroy it. A key the deferral cannot see (a NO ACTION key into a cascaded child, or one
352
+ * from a table outside the model set) raises foreign_key_violation. The set DELETE then rolls back
353
+ * and each candidate is retried alone; a root that still raises is returned as 'skipped' and the
354
+ * rest are removed. A skipped row carries reason and blocker (NULL on 'removed'), built from the
355
+ * error's name fields only: 'referenced' (a foreign key; blocker = the referencing
356
+ * schema.table.constraint, as PostgreSQL names it), or the transition's HINT 'ineligible_live'
357
+ * (a live row; blocker = that table's schema.table). The retry walks past skipped roots until p_max_rows are removed, so the oldest
358
+ * pinned roots cannot starve the table. Only that error is caught: a lock timeout or any other
359
+ * error rolls the call back. The retry costs one subtransaction per candidate it tries, so it runs
360
+ * only after the set DELETE failed.
361
+ * Application roles must not own lifecycle tables, be superusers, or inherit
362
+ * or assume everystack_lifecycle or a manage role; a table owner can disable triggers.
363
+ */
364
+ function destroyFunctions(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration): FunctionDescriptor[] {
365
+ const name = `${model.schema}.${model.table}_lifecycle_destroy`;
366
+ const table = qualified(model);
367
+ const pk = pkOf(model);
368
+ const candidateKey = `lc_candidate.${pk}`;
369
+ // Older manifests recorded only hasMany dependents. Preserve their exact regeneration.
370
+ const references = decl.destroyReferences ?? dependents.filter(d => d.parent === model && d.dependent === 'restrict')
371
+ .map(d => ({ child: d.child, columns: [d.column], refColumns: [model.pk.column] }));
372
+ const restrict = references.map(reference => {
373
+ const child = qualified(reference.child);
374
+ const match = reference.columns.map((column, i) => `${child}.${ident(column)} = lc_candidate.${ident(reference.refColumns[i])}`).join(' AND ');
375
+ return ` AND NOT EXISTS (SELECT 1 FROM ${child} WHERE ${match})`;
376
+ }).join('\n');
377
+ const parameter = (key: string): string => `${ident(model.table + '_lifecycle_destroy')}.${key}`;
378
+ const destroyBefore = defineFunction(`${model.schema}.${model.table}_lifecycle_destroy_before`, {
379
+ returns: 'timestamptz', language: 'sql', volatility: 'stable', security: 'definer',
380
+ // No grant: the only caller is the transition, a definer function the same role owns.
381
+ // `internal` tells db:check that is reviewed and permanent, not an undeclared audience.
382
+ internal: true,
383
+ owner: LIFECYCLE_OWNER_ROLE, searchPath: SEARCH_PATH,
384
+ body: sql`SELECT everystack.destroy_cutoff()`,
385
+ });
386
+ const destroy = defineFunction(name, {
387
+ args: [
388
+ { name: 'p_keys', type: `${pkType(model)}[]`, default: 'NULL' },
389
+ { name: 'p_before', type: 'timestamptz', default: 'pg_catalog.now()' },
390
+ { name: 'p_max_rows', type: 'integer', default: '1000' },
391
+ ],
392
+ returns: `TABLE(key ${pkType(model)}, outcome text, reason text, blocker text)`, language: 'plpgsql', security: 'definer',
393
+ lockTimeout: '5s',
394
+ owner: LIFECYCLE_OWNER_ROLE, searchPath: SEARCH_PATH,
395
+ abilities: (model.manageRoles ?? []).map(role => can('execute', { role })),
396
+ body: sql`
397
+ #variable_conflict use_column
398
+ <<lc>>
399
+ DECLARE
400
+ candidate ${pkType(model)};
401
+ removed_keys ${pkType(model)}[] := '{}';
402
+ skipped_keys ${pkType(model)}[] := '{}';
403
+ skipped_reasons text[] := '{}';
404
+ skipped_blockers text[] := '{}';
405
+ err_schema text;
406
+ err_table text;
407
+ err_constraint text;
408
+ err_hint text;
409
+ BEGIN
410
+ IF p_max_rows IS NULL OR p_max_rows < 1 THEN
411
+ RAISE EXCEPTION USING ERRCODE = '22023', MESSAGE = 'p_max_rows must be a positive integer';
412
+ END IF;
413
+ PERFORM everystack.destroy_log_mark(${parameter('p_before')});
414
+ BEGIN
415
+ WITH lc_gone AS (
416
+ DELETE FROM ${table}
417
+ WHERE ${pk} IN (
418
+ SELECT ${candidateKey} FROM ${table} AS lc_candidate
419
+ WHERE lc_candidate.${AT} IS NOT NULL AND lc_candidate.${AT} < ${parameter('p_before')}
420
+ AND (${parameter('p_keys')} IS NULL OR ${candidateKey} = ANY(${parameter('p_keys')}))
421
+ ${restrict ? `${restrict}\n` : ''} ORDER BY lc_candidate.${AT}, ${candidateKey}
422
+ LIMIT ${parameter('p_max_rows')} FOR UPDATE SKIP LOCKED
423
+ ) RETURNING ${pk}
424
+ ) SELECT COALESCE(pg_catalog.array_agg(lc_gone.${pk}), '{}') INTO lc.removed_keys FROM lc_gone;
425
+ EXCEPTION WHEN foreign_key_violation THEN
426
+ FOR lc.candidate IN
427
+ SELECT ${candidateKey} FROM ${table} AS lc_candidate
428
+ WHERE lc_candidate.${AT} IS NOT NULL AND lc_candidate.${AT} < ${parameter('p_before')}
429
+ AND (${parameter('p_keys')} IS NULL OR ${candidateKey} = ANY(${parameter('p_keys')}))
430
+ ${restrict ? `${restrict}\n` : ''} ORDER BY lc_candidate.${AT}, ${candidateKey}
431
+ FOR UPDATE SKIP LOCKED
432
+ LOOP
433
+ EXIT WHEN pg_catalog.cardinality(lc.removed_keys) >= ${parameter('p_max_rows')};
434
+ BEGIN
435
+ DELETE FROM ${table} WHERE ${pk} = lc.candidate;
436
+ IF FOUND THEN lc.removed_keys := lc.removed_keys || lc.candidate; END IF;
437
+ EXCEPTION WHEN foreign_key_violation THEN
438
+ -- Names only: MESSAGE_TEXT and the DETAIL of a foreign key error carry a key value.
439
+ GET STACKED DIAGNOSTICS lc.err_schema = SCHEMA_NAME, lc.err_table = TABLE_NAME,
440
+ lc.err_constraint = CONSTRAINT_NAME, lc.err_hint = PG_EXCEPTION_HINT;
441
+ lc.skipped_keys := lc.skipped_keys || lc.candidate;
442
+ lc.skipped_reasons := pg_catalog.array_append(lc.skipped_reasons, CASE lc.err_hint
443
+ WHEN ${literal(INELIGIBLE_LIVE)} THEN 'ineligible_live'
444
+ ELSE 'referenced' END);
445
+ -- A consumer's RAISE can set any text: each part is capped at an identifier's length.
446
+ lc.skipped_blockers := pg_catalog.array_append(lc.skipped_blockers, NULLIF(pg_catalog.concat_ws('.',
447
+ NULLIF(pg_catalog.left(lc.err_schema, 63), ''), NULLIF(pg_catalog.left(lc.err_table, 63), ''),
448
+ NULLIF(pg_catalog.left(lc.err_constraint, 63), '')), ''));
449
+ END;
450
+ END LOOP;
451
+ END;
452
+ IF NOT everystack.destroy_log_unmark() THEN
453
+ RAISE EXCEPTION USING ERRCODE = '55000', MESSAGE = ${literal(
454
+ `destroy of ${labelOf(model)} failed: the destroy_log marker was removed during the call, so a cascaded child may have been soft-deleted instead of removed; nothing was destroyed`,
455
+ )};
456
+ END IF;
457
+ RETURN QUERY SELECT lc_key, 'removed'::text, NULL::text, NULL::text FROM pg_catalog.unnest(lc.removed_keys) AS lc_key
458
+ UNION ALL SELECT lc_skip.k, 'skipped'::text, lc_skip.r, lc_skip.b
459
+ FROM ROWS FROM (pg_catalog.unnest(lc.skipped_keys), pg_catalog.unnest(lc.skipped_reasons), pg_catalog.unnest(lc.skipped_blockers)) AS lc_skip(k, r, b);
460
+ END`,
461
+ });
462
+ return [destroyBefore, destroy];
463
+ }
464
+
280
465
  /** Refuse outside READ COMMITTED (55000): an older snapshot cannot see a related row committed since. */
281
466
  function readCommittedOnly(model: LifecycleTable, op: 'delete' | 'restore' = 'delete'): string {
282
467
  const what = op === 'delete'
@@ -303,7 +488,7 @@ function lifecycleParents(model: LifecycleTable, dependents: readonly LifecycleD
303
488
 
304
489
  /**
305
490
  * `<t>_restore(p_id)`: restore p_id, then every row of its 'cascade' subtree that the same delete
306
- * stamped (same deleted_batch), on the ROOT's authority: whoever may delete p_id may undo what that
491
+ * marked deleted (same deleted_batch), on the ROOT's authority: whoever may delete p_id may undo what that
307
492
  * delete did, rows the caller could not delete included.
308
493
  *
309
494
  * Invariant kept: no live row has a deleted lifecycle parent. The parent-live trigger skips the
@@ -526,7 +711,7 @@ function lifecycleTriggerFunction(name: string, body: string) {
526
711
  });
527
712
  }
528
713
 
529
- /** `<t>_lifecycle_cascade`: stamp each live 'cascade' child with the parent's four values. */
714
+ /** `<t>_lifecycle_cascade`: mark each live 'cascade' child with the parent's four values. */
530
715
  function cascadeObjects(model: LifecycleTable, children: readonly LifecycleDependent[]): LifecycleObject[] {
531
716
  if (children.length === 0) return [];
532
717
  const name = `${model.schema}.${model.table}_lifecycle_cascade`;
@@ -557,10 +742,10 @@ END`);
557
742
 
558
743
  /**
559
744
  * The 'restrict' check, run by the transition AFTER its own stamp, so after the whole cascade
560
- * below it has stamped (AFTER ROW triggers fire at the end of that UPDATE). A 'restrict' edge
561
- * P -> C refuses the delete when a LIVE C row references a P row this delete stamped: the same
745
+ * below it has marked rows deleted (AFTER ROW triggers fire at the end of that UPDATE). A 'restrict' edge
746
+ * P -> C refuses the delete when a LIVE C row references a P row this delete marked deleted: the same
562
747
  * check whether P is the deleted row or a row its cascade reached, and independent of the order
563
- * the cascade stamped in, because a C row the same delete stamped is no longer live.
748
+ * the cascade ran in, because a C row the same delete marked deleted is no longer live.
564
749
  *
565
750
  * The stamped P rows are found the way restore finds a batch: from OLD's key, down the 'cascade'
566
751
  * edges, keeping rows of THIS batch, one set per table, grown from the frontier. Only tables on a
@@ -637,14 +822,14 @@ ${checks}`;
637
822
 
638
823
  /**
639
824
  * `<t>_lifecycle_delete_when` — lifecycle: { deleteWhen }'s guard. BEFORE UPDATE OF deleted_at on
640
- * the live -> deleted stamp, so it fires for the row's own stamp (the transition's UPDATE) and for
641
- * a parent's cascade stamp alike (a cascaded child's BEFORE DELETE transition never runs). The
825
+ * the live -> deleted stamp, so it fires for the row's own delete (the transition's UPDATE) and for
826
+ * a parent's cascade alike (a cascaded child's BEFORE DELETE transition never runs). The
642
827
  * predicate is read with bare column names against the row as it still stands (a BEFORE trigger:
643
- * the table holds the old values), one primary-key lookup per stamped row. The predicate sits on
828
+ * the table holds the old values), one primary-key lookup per deleted row. The predicate sits on
644
829
  * lines of its own, so a trailing `--` comment in it ends only its own line. Anything but TRUE
645
830
  * refuses with 23514 and the constraint name, naming the table and never a row value; the whole
646
831
  * statement rolls back, so one guarded child refuses its parent's delete. No role test: only the
647
- * lifecycle role stamps (the guard), and restore and purge never make this transition.
832
+ * lifecycle role marks rows deleted (the guard), and restore and destroy never make this transition.
648
833
  */
649
834
  function deleteWhenObjects(model: LifecycleTable, deleteWhen: string | undefined): LifecycleObject[] {
650
835
  if (deleteWhen === undefined) return [];
@@ -684,6 +869,8 @@ END`);
684
869
  /**
685
870
  * `<t>_lifecycle_parent` on a lifecycle child: a live row may not reference a deleted parent of a
686
871
  * 'cascade' or 'restrict' relation. Only a lifecycle parent can be deleted and still be there.
872
+ * The 23503 names the child table and `<t>_lifecycle_parent` as its constraint (names only), so a
873
+ * destroy root it skips reports a named blocker.
687
874
  */
688
875
  function parentLiveObjects(model: LifecycleTable, parents: readonly LifecycleDependent[]): LifecycleObject[] {
689
876
  const unique = lifecycleParents(model, parents);
@@ -698,7 +885,7 @@ function parentLiveObjects(model: LifecycleTable, parents: readonly LifecycleDep
698
885
  IF NOT FOUND OR lc.parent_deleted IS NOT NULL THEN
699
886
  RAISE EXCEPTION USING ERRCODE = '23503', MESSAGE = ${literal(
700
887
  `${labelOf(model)}: a live row may not reference a missing or deleted ${labelOf(d.parent)} row (${d.column})`,
701
- )};
888
+ )}, SCHEMA = ${literal(model.schema)}, TABLE = ${literal(model.table)}, CONSTRAINT = ${literal(`${model.table}_lifecycle_parent`)};
702
889
  END IF;
703
890
  lc.parent_deleted := NULL;
704
891
  END IF;`;
@@ -742,6 +929,8 @@ export function declOf(model: ModelDescriptor, dependents: readonly ModelDepende
742
929
  || m.constraints.some(c => c.kind === 'unique' || (c.kind === 'index' && c.isUnique)),
743
930
  // Role insertion order is a builder input: its branches must stay byte-identical.
744
931
  auth: deleteRulePredicates(m),
932
+ // Destroy ignores row ownership, so only a manage that already covers every row may run it.
933
+ manageRoles: allRowsManageRoles(m),
745
934
  };
746
935
  }
747
936
  return { tables, edges: relevant.map(d => ({ parent: labelOf(d.parent),
@@ -764,14 +953,48 @@ export function regenerateLifecycle(model: string, decl: LifecycleDeclaration, d
764
953
  return lifecycleObjects(root, dependents, decl, deleteWhen);
765
954
  }
766
955
 
956
+ /**
957
+ * The manifest a generated lifecycle function carries, from its descriptor's `comment` (the models
958
+ * path) or its compiled COMMENT attachment (a db:reconcile payload). Undefined for any other object.
959
+ */
960
+ export function lifecycleManifestOf(object: { kind: string; owner?: string; comment?: string; attachments?: readonly { kind: string; sql: string }[] }): LifecycleManifest | undefined {
961
+ if (object.kind !== 'function' || object.owner !== LIFECYCLE_OWNER_ROLE) return undefined;
962
+ let text = object.comment;
963
+ if (text === undefined) {
964
+ const attached = object.attachments?.find(a => a.kind === 'comment')?.sql.match(/ IS '((?:[^']|'')*)'$/s);
965
+ text = attached?.[1].replace(/''/g, "'");
966
+ }
967
+ if (text === undefined) return undefined;
968
+ try {
969
+ const manifest = JSON.parse(text) as LifecycleManifest;
970
+ return manifest?.everystack === 'lifecycle' ? manifest : undefined;
971
+ } catch {
972
+ return undefined;
973
+ }
974
+ }
975
+
767
976
  /** The generated lifecycle objects, with comments stamped by the ordinary reconciler. */
768
977
  export function lifecycleDerived(models: readonly ModelDescriptor[]): DerivedDescriptor[] {
978
+ lifecycleDestroyOrder(models);
769
979
  const dependents = lifecycleDependents(models);
770
980
  const byName = new Map(models.map(model => [labelOf(model), model]));
981
+ const actions = relationDeleteActions(models);
982
+ const foreignKeys = [...new Set(models)].flatMap(child => modelForeignKeys(child, actions).map(fk => ({ child, fk })));
771
983
  return [...new Set(models)].filter(m => m.lifecycle === true)
772
984
  .sort((a, b) => labelOf(a) < labelOf(b) ? -1 : labelOf(a) > labelOf(b) ? 1 : 0)
773
985
  .flatMap(model => {
774
986
  const decl = declOf(model, dependents);
987
+ const refName = model.schema === 'public' ? model.table : labelOf(model);
988
+ const references = foreignKeys.filter(({ fk }) => (fk.refTable === refName || fk.refTable === labelOf(model))
989
+ && (fk.onDelete === undefined || fk.onDelete === 'restrict' || fk.onDelete === 'no action'))
990
+ .map(({ child, fk }) => ({ child: { schema: child.schema, table: child.table }, columns: fk.columns, refColumns: fk.refColumns }));
991
+ const key = (reference: LifecycleDestroyReference): string => JSON.stringify([reference.child.schema, reference.child.table, reference.columns, reference.refColumns]);
992
+ decl.destroyReferences = [...new Map(references.map(reference => [key(reference), reference])).values()]
993
+ .sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
994
+ const nullify = (Object.values(model.relations) as Relation[]).filter(rel => rel.kind === 'hasMany' && rel.dependent === 'nullify')
995
+ .map(rel => ({ child: labelOf(rel.target()), column: toSnakeCase(rel.column) }))
996
+ .sort((a, b) => (`${a.child}\u0000${a.column}` < `${b.child}\u0000${b.column}` ? -1 : 1));
997
+ decl.nullify = nullify;
775
998
  const generated = regenerateLifecycle(labelOf(model), decl, model.deleteWhen);
776
999
  for (const object of generated) {
777
1000
  if (object.kind !== 'function') continue;