@everystack/cli 0.4.100 → 0.4.103

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +91 -14
  5. package/src/cli/authz-contract.ts +29 -14
  6. package/src/cli/authz-derive.ts +284 -30
  7. package/src/cli/authz-reconcile.ts +6 -4
  8. package/src/cli/commands/db-check.ts +40 -0
  9. package/src/cli/commands/db-generate.ts +10 -9
  10. package/src/cli/commands/db-plan.ts +32 -13
  11. package/src/cli/commands/db-pull.ts +155 -63
  12. package/src/cli/commands/db-reconcile.ts +22 -3
  13. package/src/cli/commands/db-sync.ts +6 -5
  14. package/src/cli/commands/db.ts +91 -8
  15. package/src/cli/commands/update.ts +3 -2
  16. package/src/cli/db-build.ts +1 -0
  17. package/src/cli/declared-derived.ts +3 -1
  18. package/src/cli/declared-diff.ts +5 -1
  19. package/src/cli/derived-apply.ts +116 -10
  20. package/src/cli/derived-grants.ts +4 -2
  21. package/src/cli/derived-introspect.ts +23 -3
  22. package/src/cli/derived-plan.ts +37 -1
  23. package/src/cli/derived-render.ts +33 -34
  24. package/src/cli/edge-plan.ts +92 -9
  25. package/src/cli/generated-artifacts.ts +22 -0
  26. package/src/cli/generated-names.ts +108 -0
  27. package/src/cli/host-url.ts +48 -27
  28. package/src/cli/index-lock.ts +65 -0
  29. package/src/cli/index.ts +5 -5
  30. package/src/cli/lifecycle-canon.ts +37 -0
  31. package/src/cli/lifecycle-derived.ts +787 -0
  32. package/src/cli/lifecycle-pull.ts +252 -0
  33. package/src/cli/lifecycle-verify.ts +238 -0
  34. package/src/cli/migration-compile.ts +11 -5
  35. package/src/cli/migration-generate.ts +24 -3
  36. package/src/cli/model-api.ts +9 -0
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +232 -76
  39. package/src/cli/plain-output.ts +211 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +64 -18
  42. package/src/cli/schema-diff.ts +166 -31
  43. package/src/cli/schema-fingerprint.ts +40 -6
  44. package/src/cli/schema-introspect.ts +97 -4
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. package/src/cli/ts-template.ts +52 -0
@@ -0,0 +1,787 @@
1
+ /**
2
+ * The lifecycle triggers — derived objects GENERATED from every `lifecycle: true` model in a set.
3
+ *
4
+ * Per lifecycle table `<t>`, two functions and two triggers, built with the ordinary descriptors so
5
+ * compile, introspect, reconcile and fingerprint treat them like any declared object:
6
+ *
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
9
+ * is already deleted keeps its stamps. It refuses to run as a trigger on any other table: a
10
+ * role holding EXECUTE could otherwise attach it to its own table and soft-delete `<t>` rows by id.
11
+ * It hands the batch to a caller that cannot read it: for a top-level DELETE (trigger depth 1) it
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
14
+ * can set that name, and the stamp columns stay the truth.
15
+ * - `<t>_lifecycle_guard` (BEFORE INSERT OR UPDATE): refuses any direct write to those columns.
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,
18
+ * for a direct delete and a cascade alike. Its predicate rides on its own manifest only.
19
+ * - `<t>_restore(p_id)`: undoes a delete for whoever may delete p_id. A SECURITY DEFINER
20
+ * function owned by the lifecycle role, EXECUTE to exactly the roles with a delete rule. The
21
+ * caller is the SET ROLE role (session_user when none); a role branch passes when the caller
22
+ * holds USAGE on that role (never MEMBER: the authenticator is a NOINHERIT member of every app
23
+ * role) AND the role's delete predicate, read from the compiled DELETE policies, holds on the
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
26
+ * those rows whatever the deleter's rights over them, so its undo must too, or a user's
27
+ * restore would leave the likes others put on their posts deleted for good. It never makes a
28
+ * live row under a deleted parent (see restoreFunction), and it returns counts and the batch it
29
+ * restored (`{ restored, skipped, batch }`; batch null for a legacy row), never ids.
30
+ *
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:
34
+ *
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
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.
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).
45
+ * The contract: 'restrict' permits a delete when its live children are deleted by the same cascade.
46
+ * A multi-row DELETE runs one transition per row, each its own batch, in scan order: rows that
47
+ * share a restrict child through different families can refuse depending on that order, never
48
+ * accept a live child under a deleted parent.
49
+ * Author triggers must not write the lifecycle columns: the check trusts that only delete and
50
+ * restore set deleted_batch.
51
+ * A trigger fired by a stamp or by purge runs inside these functions' pinned search_path
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.
55
+ *
56
+ * Both refuse outside READ COMMITTED: a REPEATABLE READ snapshot cannot see a child committed after
57
+ * it began, so the cascade would leave that child live, and the restrict count would miss it.
58
+ * - `<t>_lifecycle_parent` on a lifecycle child of a 'cascade' or 'restrict' relation (BEFORE
59
+ * INSERT OR UPDATE OF the foreign key): a live row may not point at a deleted parent, and a
60
+ * missing parent gets the same refusal, so the message never tells a hidden deleted row from no
61
+ * 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
+ * cascade would land live.
64
+ *
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
67
+ * INVOKING user before it calls the function. Inside the definer function `current_user` is always
68
+ * the lifecycle role, so the body never tests it. The transition's own UPDATE runs as that role, so
69
+ * the guard skips it. The cascade must not skip it: its recursion IS the lifecycle role's UPDATE.
70
+ * Only the lifecycle role can change deleted_at (the guard), so the cascade fires on nothing else.
71
+ *
72
+ * Every function here refuses to run as a trigger on any table but its own (42501), for the
73
+ * transition's reason: EXECUTE, or a superuser's mistake, must not let it act on `<t>`'s children.
74
+ *
75
+ * Computed from the models every time, never stored on a module.
76
+ */
77
+
78
+ import { can, defineFunction, trigger, sql, parseQualified, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE } from '@everystack/model';
79
+ import type { DerivedDescriptor, ModelDescriptor, Relation, FunctionDescriptor, TriggerDefinition, TriggerSpec } from '@everystack/model';
80
+ import { pgType, toSnakeCase } from './schema-compile';
81
+ import { deleteRulePredicates } from './authz-compile';
82
+ import { canonHash, canonOfFunction } from './lifecycle-canon';
83
+
84
+ /** Exact inputs read by the lifecycle builders, serializable without model references. */
85
+ export interface LifecycleTable {
86
+ schema: string;
87
+ table: string;
88
+ pk: { column: string; type: string };
89
+ unique: boolean;
90
+ auth: Record<string, string>;
91
+ }
92
+ export interface LifecycleEdge {
93
+ parent: string;
94
+ child: string;
95
+ column: string;
96
+ dependent: 'cascade' | 'restrict';
97
+ }
98
+ export interface LifecycleDeclaration {
99
+ tables: Record<string, LifecycleTable>;
100
+ edges: LifecycleEdge[];
101
+ }
102
+ export const MANIFEST_VERSION = 1;
103
+ export const LIFECYCLE_ROLES = ['transition', 'guard', 'restore', 'cascade', 'parent', 'delete_when'] as const;
104
+ export type LifecycleRole = typeof LIFECYCLE_ROLES[number];
105
+ export interface LifecycleManifest {
106
+ everystack: 'lifecycle';
107
+ v: number;
108
+ role: LifecycleRole;
109
+ model: string;
110
+ decl: LifecycleDeclaration;
111
+ /** The `delete_when` function's own input: lifecycle: { deleteWhen }'s predicate text. Carried on
112
+ * that function's manifest only, so a guard added later changes no other function's manifest. */
113
+ deleteWhen?: string;
114
+ defHash: string;
115
+ }
116
+ type TableName = Pick<LifecycleTable, 'schema' | 'table'>;
117
+ export interface LifecycleTrigger extends TriggerSpec { kind: 'trigger'; on: LifecycleTable; }
118
+ export type LifecycleObject = FunctionDescriptor | LifecycleTrigger;
119
+ interface LifecycleDependent { parent: LifecycleTable; child: TableName; column: string; dependent: 'cascade' | 'restrict'; }
120
+
121
+ /** Restore has a distinct naming pattern; unknown family names are never guessed. */
122
+ export function lifecycleRole(name: string, model: string): LifecycleRole | undefined {
123
+ for (const role of LIFECYCLE_ROLES) {
124
+ if (name === (role === 'restore' ? `${model}_restore` : `${model}_lifecycle_${role}`)) return role;
125
+ }
126
+ return undefined;
127
+ }
128
+ function lifecycleTrigger(name: string, def: TriggerDefinition & { on: LifecycleTable }): LifecycleTrigger {
129
+ return { ...trigger(name, def), kind: 'trigger', on: def.on };
130
+ }
131
+
132
+ /** Search path for the generated functions: pg_temp explicit and last, so it is never searched first. */
133
+ const SEARCH_PATH = ['pg_catalog', 'pg_temp'];
134
+
135
+ const ident = (name: string): string => `"${name.replace(/"/g, '""')}"`;
136
+ const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
137
+
138
+ const qualified = (m: TableName): string => `${ident(m.schema)}.${ident(m.table)}`;
139
+ const labelOf = (m: TableName): string => `${m.schema}.${m.table}`;
140
+ const pkOf = (m: LifecycleTable): string => ident(m.pk.column);
141
+ const pkType = (m: LifecycleTable): string => m.pk.type;
142
+ const hasUnique = (m: LifecycleTable): boolean => m.unique;
143
+ const [AT, BY, BATCH, XID] = LIFECYCLE_COLUMNS.map((c) => ident(toSnakeCase(c)));
144
+ const SKIP_LIFECYCLE_ROLE = sql`CURRENT_USER <> ${literal(LIFECYCLE_OWNER_ROLE)}`;
145
+ /**
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.
150
+ */
151
+ const TRANSITION_WHEN = sql`CURRENT_USER <> ${literal(LIFECYCLE_OWNER_ROLE)} OR pg_catalog.pg_trigger_depth() > 0`;
152
+
153
+ /** The first statement of every generated trigger function: run only as a trigger on `model`. */
154
+ function ownTableOnly(model: LifecycleTable, fnName: string): string {
155
+ return `IF TG_TABLE_SCHEMA <> ${literal(model.schema)} OR TG_TABLE_NAME <> ${literal(model.table)} THEN
156
+ RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = ${literal(
157
+ `${fnName} belongs to ${labelOf(model)} and refuses to run as a trigger on `,
158
+ )} || TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME;
159
+ END IF;`;
160
+ }
161
+
162
+ /** One declared `dependent` of a lifecycle parent, resolved: the child and its foreign-key column. */
163
+ interface ModelDependent {
164
+ parent: ModelDescriptor;
165
+ child: ModelDescriptor;
166
+ /** The child's foreign-key column, SQL name. */
167
+ column: string;
168
+ dependent: 'cascade' | 'restrict';
169
+ }
170
+
171
+ /**
172
+ * 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:
174
+ * a soft delete leaves the parent row, so the child's reference stays valid. A 'cascade' or
175
+ * 'restrict' onto a plain child is refused by relationDeleteActions; it is skipped here, never
176
+ * half-generated.
177
+ */
178
+ function lifecycleDependents(models: readonly ModelDescriptor[]): ModelDependent[] {
179
+ const out: ModelDependent[] = [];
180
+ for (const parent of new Set(models)) {
181
+ if (parent.lifecycle !== true) continue;
182
+ for (const rel of Object.values(parent.relations) as Relation[]) {
183
+ if (rel.kind !== 'hasMany' || (rel.dependent !== 'cascade' && rel.dependent !== 'restrict')) continue;
184
+ const child = rel.target();
185
+ if (child.lifecycle !== true) continue;
186
+ out.push({ parent, child, column: toSnakeCase(rel.column), dependent: rel.dependent });
187
+ }
188
+ }
189
+ const key = (d: ModelDependent): string => `${labelOf(d.parent)}\u0000${labelOf(d.child)}\u0000${d.column}\u0000${d.dependent}`;
190
+ return out.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
191
+ }
192
+
193
+ function lifecycleObjects(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration, deleteWhen?: string): LifecycleObject[] {
194
+ const table = qualified(model);
195
+ const label = labelOf(model);
196
+ const pk = pkOf(model);
197
+ const [at, by, batch, xid] = [AT, BY, BATCH, XID];
198
+ const fn = (suffix: string): string => `${model.schema}.${model.table}_lifecycle_${suffix}`;
199
+ const skipLifecycleRole = SKIP_LIFECYCLE_ROLE;
200
+
201
+ const check = restrictCheck(model, dependents, decl);
202
+ const transition = defineFunction(fn('transition'), {
203
+ returns: 'trigger',
204
+ language: 'plpgsql',
205
+ security: 'definer',
206
+ owner: LIFECYCLE_OWNER_ROLE,
207
+ searchPath: SEARCH_PATH,
208
+ body: sql`
209
+ #variable_conflict use_column
210
+ <<lc>>
211
+ DECLARE
212
+ claims text := pg_catalog.current_setting('request.jwt.claims', true);
213
+ actor text;
214
+ batch uuid := pg_catalog.gen_random_uuid();
215
+ ${check ? `${check.declare}\n` : ''}BEGIN
216
+ IF TG_TABLE_SCHEMA <> ${literal(model.schema)} OR TG_TABLE_NAME <> ${literal(model.table)} THEN
217
+ RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = ${literal(
218
+ `${fn('transition')} belongs to ${label} and refuses to run as a trigger on `,
219
+ )} || TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME;
220
+ END IF;
221
+ ${check ? ` ${readCommittedOnly(model)}\n` : ''} IF lc.claims IS NOT NULL AND lc.claims <> '' THEN
222
+ lc.actor := lc.claims::pg_catalog.jsonb ->> 'sub';
223
+ END IF;
224
+ UPDATE ${table}
225
+ SET ${at} = pg_catalog.now(),
226
+ ${by} = lc.actor,
227
+ ${batch} = lc.batch,
228
+ ${xid} = pg_catalog.txid_current()
229
+ WHERE ${pk} = OLD.${pk} AND ${at} IS NULL;
230
+ -- The batch, for the caller that cannot read it: one line per root of a top-level DELETE,
231
+ -- appended to a transaction-local setting. Informational; the stamp columns are the truth.
232
+ IF FOUND AND pg_catalog.pg_trigger_depth() = 1 THEN
233
+ PERFORM pg_catalog.set_config('everystack.lifecycle_batch',
234
+ pg_catalog.concat_ws(E'\\n', NULLIF(pg_catalog.current_setting('everystack.lifecycle_batch', true), ''),
235
+ pg_catalog.to_jsonb(OLD.${pk}::text)::text || ' ' || lc.batch::text), true);
236
+ END IF;
237
+ ${check ? `${check.body}\n` : ''} RETURN NULL;
238
+ END`,
239
+ });
240
+
241
+ const changed = (op: 'INSERT' | 'UPDATE'): string =>
242
+ [at, by, batch, xid].map((c) => (op === 'INSERT' ? `NEW.${c} IS NOT NULL` : `NEW.${c} IS DISTINCT FROM OLD.${c}`)).join(' OR ');
243
+ const refuse = `RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = ${literal(
244
+ `${label}: the lifecycle columns (deleted_at, deleted_by, deleted_batch, deleted_xid) are written only by delete and restore`,
245
+ )};`;
246
+ const guard = defineFunction(fn('guard'), {
247
+ returns: 'trigger',
248
+ language: 'plpgsql',
249
+ owner: LIFECYCLE_OWNER_ROLE,
250
+ searchPath: SEARCH_PATH,
251
+ body: sql`
252
+ BEGIN
253
+ IF TG_OP = 'INSERT' THEN
254
+ IF ${changed('INSERT')} THEN
255
+ ${refuse}
256
+ END IF;
257
+ ELSIF ${changed('UPDATE')} THEN
258
+ ${refuse}
259
+ END IF;
260
+ RETURN NEW;
261
+ END`,
262
+ });
263
+
264
+ return [
265
+ transition,
266
+ guard,
267
+ restoreFunction(model, dependents, decl),
268
+ lifecycleTrigger(`${model.table}_lifecycle_transition`, {
269
+ on: model, timing: 'before', events: ['delete'], forEach: 'row', condition: TRANSITION_WHEN, execute: transition,
270
+ }),
271
+ lifecycleTrigger(`${model.table}_lifecycle_guard`, {
272
+ on: model, timing: 'before', events: ['insert', 'update'], forEach: 'row', condition: skipLifecycleRole, execute: guard,
273
+ }),
274
+ ...cascadeObjects(model, dependents.filter((d) => d.parent === model && d.dependent === 'cascade')),
275
+ ...parentLiveObjects(model, dependents.filter((d) => d.child === model)),
276
+ ...deleteWhenObjects(model, deleteWhen),
277
+ ];
278
+ }
279
+
280
+ /** Refuse outside READ COMMITTED (55000): an older snapshot cannot see a related row committed since. */
281
+ function readCommittedOnly(model: LifecycleTable, op: 'delete' | 'restore' = 'delete'): string {
282
+ const what = op === 'delete'
283
+ ? 'it checks or soft-deletes its dependent rows, which needs READ COMMITTED (a snapshot taken earlier cannot see a dependent row committed since)'
284
+ : 'it checks its parent rows and restores its dependent rows, which needs READ COMMITTED (a snapshot taken earlier cannot see a related row committed since)';
285
+ return `IF pg_catalog.current_setting('transaction_isolation') <> 'read committed' THEN
286
+ RAISE EXCEPTION USING ERRCODE = '55000', MESSAGE = ${literal(
287
+ `${op} of ${labelOf(model)} refused: ${what}; this transaction is `,
288
+ )} || pg_catalog.upper(pg_catalog.current_setting('transaction_isolation'));
289
+ END IF;`;
290
+ }
291
+
292
+ /** The declared lifecycle parents of `model` ('cascade' or 'restrict'), one per parent and column. */
293
+ function lifecycleParents(model: LifecycleTable, dependents: readonly LifecycleDependent[]): LifecycleDependent[] {
294
+ const seen = new Set<string>();
295
+ return dependents.filter((d) => {
296
+ if (d.child !== model) return false;
297
+ const k = `${labelOf(d.parent)}\u0000${d.column}`;
298
+ if (seen.has(k)) return false;
299
+ seen.add(k);
300
+ return true;
301
+ });
302
+ }
303
+
304
+ /**
305
+ * `<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
307
+ * delete did, rows the caller could not delete included.
308
+ *
309
+ * Invariant kept: no live row has a deleted lifecycle parent. The parent-live trigger skips the
310
+ * lifecycle role (these UPDATEs), so this function checks it itself, reading each parent FOR SHARE
311
+ * so a concurrent parent delete either waits for this transaction or is seen by it. A deleted
312
+ * parent of p_id refuses the whole restore. A member comes back only once ALL its parents are live,
313
+ * so parents always precede children; a member whose other parent stays deleted (another batch, or
314
+ * itself left deleted) stays deleted, and so does everything under it. The pass repeats until it
315
+ * restores nothing: a diamond child reached before its second parent is restored on the next pass.
316
+ *
317
+ * A unique collision (a live row now holds the value) refuses on p_id and skips a member. The member
318
+ * UPDATE covers all eligible members of one table per pass. A table with a declared unique index
319
+ * uses one exception block, falling back to per-row blocks only on unique_violation. The index
320
+ * decides, including partial and expression indexes, without a check-before-write race. Tables
321
+ * with only their primary key need no exception block: restore never changes that key. The
322
+ * handler therefore covers only DECLARED unique indexes: a 23505 from a consumer trigger or an
323
+ * undeclared index on such a table aborts the restore (the root surfaces the raw 23505, not the
324
+ * uniform 42501). A self-referencing table restores one depth level per pass.
325
+ *
326
+ * Reading a parent FOR SHARE can deadlock with a concurrent cascade delete of its grandparent (that
327
+ * delete locks the grandparent, then waits on the parent; this restore holds the parent, then
328
+ * needs a row the delete already locked, such as the grandparent as another parent of a member). PostgreSQL aborts one side with 40P01; the invariant holds
329
+ * either way, since each side's writes commit or roll back whole.
330
+ *
331
+ * The subtree is computed at compile time as the set of tables reachable through 'cascade' edges and
332
+ * at run time as id arrays per table, grown from the FRONTIER (the ids the previous round added),
333
+ * so each child is read once rather than once per round. A cycle ends because each new id must be a
334
+ * deleted row of the batch not yet in its array (the EXCEPT). A NULL batch (a legacy row) restores alone.
335
+ * Counts only, never another row's id: restored + skipped = the batch subtree.
336
+ */
337
+ function restoreFunction(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration) {
338
+ const table = qualified(model);
339
+ const label = labelOf(model);
340
+ const pk = pkOf(model);
341
+ const inputTable = (m: TableName): LifecycleTable => {
342
+ const input = decl.tables[labelOf(m)];
343
+ if (!input) throw new Error(`lifecycle declaration misses ${labelOf(m)}`);
344
+ return input;
345
+ };
346
+ const rules = model.auth;
347
+ const roles = Object.keys(rules);
348
+ const branches = roles.map((role) => `(pg_catalog.pg_has_role(lc.actor, ${literal(role)}, 'USAGE') AND (${rules[role]}))`);
349
+ const rule = branches.length ? branches.join('\n OR ') : 'false';
350
+ // Every variable is qualified (lc.<name>; the parameter by the label PostgreSQL gives the function's
351
+ // outer block, its own name), so `#variable_conflict use_column` only decides the unqualified
352
+ // names, and those are the delete rule's columns.
353
+ const id = `${ident(`${model.table}_restore`)}.p_id`;
354
+ const refuse = `RAISE EXCEPTION ${literal(`restore refused: ${label} id=% is not a deleted row you may restore`)}, ${id}
355
+ USING ERRCODE = '42501';`;
356
+ const clear = `${AT} = NULL, ${BY} = NULL, ${BATCH} = NULL, ${XID} = NULL`;
357
+
358
+
359
+ // The tables of the cascade subtree, root first, in discovery order; the edges among them.
360
+ const tables: LifecycleTable[] = [model];
361
+ const cascades = dependents.filter((d) => d.dependent === 'cascade');
362
+ for (let i = 0; i < tables.length; i++) {
363
+ for (const d of cascades) if (d.parent === tables[i] && !tables.some(m => labelOf(m) === labelOf(d.child))) tables.push(inputTable(d.child));
364
+ }
365
+ const edges = cascades.filter((d) => tables.includes(d.parent));
366
+ const ix = (m: TableName): number => tables.findIndex(t => labelOf(t) === labelOf(m));
367
+ const rootParents = lifecycleParents(model, dependents);
368
+ const related = edges.length > 0 || rootParents.length > 0;
369
+
370
+ /** The parent-live test of one row whose foreign keys are the record's p<j> fields. */
371
+ const parentsLive = (m: LifecycleTable, rec: string, onDeleted: string): string =>
372
+ lifecycleParents(m, dependents).map((d, j) => ` IF ${rec}.p${j} IS NOT NULL THEN
373
+ SELECT p.${AT} INTO lc.parent_deleted FROM ${qualified(d.parent)} p WHERE p.${pkOf(d.parent)} = ${rec}.p${j} FOR SHARE;
374
+ IF NOT FOUND OR lc.parent_deleted IS NOT NULL THEN
375
+ ${onDeleted}
376
+ END IF;
377
+ END IF;`).join('\n');
378
+ const fkColumns = (m: LifecycleTable, alias: string): string =>
379
+ lifecycleParents(m, dependents).map((d, j) => `, ${alias}${ident(d.column)} AS p${j}`).join('');
380
+
381
+ const declare = [
382
+ 'parent_deleted timestamptz;',
383
+ 'root record;',
384
+ ...(edges.length === 0 ? [] : [
385
+ 'batch_id uuid;',
386
+ 'live boolean;',
387
+ 'grew boolean;',
388
+ 'progress boolean;',
389
+ 'n bigint;',
390
+ 'restored bigint := 1;',
391
+ 'skipped bigint := 0;',
392
+ ...tables.flatMap((m, i) => [
393
+ `s${i} ${pkType(m)}[] := '{}';`,
394
+ `add${i} ${pkType(m)}[];`,
395
+ `f${i} ${pkType(m)}[] := '{}';`,
396
+ `nf${i} ${pkType(m)}[];`,
397
+ `stuck${i} ${pkType(m)}[] := '{}';`,
398
+ `ready${i} ${pkType(m)}[] := '{}';`,
399
+ `r${i} record;`,
400
+ ]),
401
+ ]),
402
+ ].map((l) => ` ${l}`).join('\n');
403
+
404
+ const grow = edges.map((d) => {
405
+ const [p, c] = [ix(d.parent), ix(d.child)];
406
+ return ` lc.add${c} := ARRAY(SELECT t.${pkOf(inputTable(d.child))} FROM ${qualified(d.child)} t
407
+ JOIN pg_catalog.unnest(lc.f${p}) AS f(k) ON t.${ident(d.column)} = f.k
408
+ WHERE t.${AT} IS NOT NULL AND t.${BATCH} = lc.batch_id
409
+ EXCEPT SELECT pg_catalog.unnest(lc.s${c}));
410
+ IF pg_catalog.cardinality(lc.add${c}) > 0 THEN
411
+ lc.s${c} := lc.s${c} || lc.add${c};
412
+ lc.nf${c} := lc.nf${c} || lc.add${c};
413
+ lc.grew := true;
414
+ END IF;`;
415
+ }).join('\n');
416
+ const frontierReset = tables.map((_, i) => ` lc.nf${i} := '{}';`).join('\n');
417
+ const frontierAdvance = tables.map((_, i) => ` lc.f${i} := lc.nf${i};`).join('\n');
418
+
419
+ const pass = tables.map((m, i) => {
420
+ const mpk = pkOf(m);
421
+ const count = `GET DIAGNOSTICS lc.n = ROW_COUNT;
422
+ lc.restored := lc.restored + lc.n;
423
+ lc.progress := lc.progress OR lc.n > 0;`;
424
+ const bulk = `UPDATE ${qualified(m)} SET ${clear}
425
+ WHERE ${mpk} = ANY(lc.ready${i}) AND ${AT} IS NOT NULL AND ${BATCH} = lc.batch_id;
426
+ ${count}`;
427
+ const update = hasUnique(m) ? `BEGIN
428
+ ${bulk}
429
+ EXCEPTION WHEN unique_violation THEN
430
+ FOR lc.r${i} IN SELECT k FROM pg_catalog.unnest(lc.ready${i}) AS u(k) ORDER BY k LOOP
431
+ BEGIN
432
+ UPDATE ${qualified(m)} SET ${clear}
433
+ WHERE ${mpk} = lc.r${i}.k AND ${AT} IS NOT NULL AND ${BATCH} = lc.batch_id;
434
+ ${count}
435
+ EXCEPTION WHEN unique_violation THEN
436
+ lc.stuck${i} := lc.stuck${i} || lc.r${i}.k;
437
+ END;
438
+ END LOOP;
439
+ END;` : bulk;
440
+ // Keep parent locks outside the exception block so a failed bulk update cannot release them.
441
+ return ` lc.ready${i} := '{}';
442
+ FOR lc.r${i} IN SELECT t.${mpk} AS k${fkColumns(m, 't.')} FROM ${qualified(m)} t
443
+ WHERE t.${mpk} = ANY(lc.s${i}) AND t.${AT} IS NOT NULL AND t.${BATCH} = lc.batch_id
444
+ AND t.${mpk} <> ALL(lc.stuck${i})
445
+ ORDER BY t.${mpk} LOOP
446
+ lc.live := true;
447
+ ${parentsLive(m, `lc.r${i}`, 'lc.live := false;').replace(/^/gm, ' ')}
448
+ IF lc.live THEN
449
+ lc.ready${i} := lc.ready${i} || lc.r${i}.k;
450
+ END IF;
451
+ END LOOP;
452
+ IF pg_catalog.cardinality(lc.ready${i}) > 0 THEN
453
+ ${update}
454
+ END IF;`;
455
+ }).join('\n');
456
+
457
+ const left = tables.map((m, i) => ` SELECT pg_catalog.count(*) INTO lc.n FROM ${qualified(m)} t
458
+ WHERE t.${pkOf(m)} = ANY(lc.s${i}) AND t.${AT} IS NOT NULL AND t.${BATCH} = lc.batch_id;
459
+ lc.skipped := lc.skipped + lc.n;`).join('\n');
460
+
461
+ const walk = edges.length === 0
462
+ ? ` RETURN pg_catalog.jsonb_build_object('restored', 1, 'skipped', 0, 'batch', lc.root.b);`
463
+ : ` lc.batch_id := lc.root.b;
464
+ IF lc.batch_id IS NOT NULL THEN
465
+ lc.s0 := ARRAY[${id}];
466
+ lc.f0 := lc.s0;
467
+ LOOP
468
+ lc.grew := false;
469
+ ${frontierReset}
470
+ ${grow}
471
+ ${frontierAdvance}
472
+ EXIT WHEN NOT lc.grew;
473
+ END LOOP;
474
+ LOOP
475
+ lc.progress := false;
476
+ ${pass}
477
+ EXIT WHEN NOT lc.progress;
478
+ END LOOP;
479
+ ${left}
480
+ END IF;
481
+ RETURN pg_catalog.jsonb_build_object('restored', lc.restored, 'skipped', lc.skipped, 'batch', lc.batch_id);`;
482
+
483
+ return defineFunction(`${model.schema}.${model.table}_restore`, {
484
+ args: [{ name: 'p_id', type: pkType(model) }],
485
+ returns: 'jsonb',
486
+ language: 'plpgsql',
487
+ security: 'definer',
488
+ owner: LIFECYCLE_OWNER_ROLE,
489
+ searchPath: SEARCH_PATH,
490
+ abilities: roles.map((role) => can('execute', { role })),
491
+ body: sql`
492
+ #variable_conflict use_column
493
+ <<lc>>
494
+ DECLARE
495
+ actor text := CASE WHEN pg_catalog.current_setting('role') = 'none' THEN session_user
496
+ ELSE pg_catalog.current_setting('role') END;
497
+ ${declare}
498
+ BEGIN
499
+ ${related ? ` ${readCommittedOnly(model, 'restore')}\n` : ''} SELECT ${BATCH} AS b${fkColumns(model, '')} INTO lc.root FROM ${table}
500
+ WHERE ${pk} = ${id} AND ${AT} IS NOT NULL
501
+ AND (${rule})
502
+ FOR UPDATE;
503
+ IF NOT FOUND THEN
504
+ ${refuse}
505
+ END IF;
506
+ ${parentsLive(model, 'lc.root', refuse)}
507
+ ${hasUnique(model) ? ` BEGIN
508
+ UPDATE ${table} SET ${clear} WHERE ${pk} = ${id};
509
+ EXCEPTION WHEN unique_violation THEN
510
+ ${refuse}
511
+ END;` : ` UPDATE ${table} SET ${clear} WHERE ${pk} = ${id};`}
512
+ ${walk}
513
+ END`,
514
+ });
515
+ }
516
+
517
+ /** The definer function shape every new trigger function shares. */
518
+ function lifecycleTriggerFunction(name: string, body: string) {
519
+ return defineFunction(name, {
520
+ returns: 'trigger',
521
+ language: 'plpgsql',
522
+ security: 'definer',
523
+ owner: LIFECYCLE_OWNER_ROLE,
524
+ searchPath: SEARCH_PATH,
525
+ body: sql`${body}`,
526
+ });
527
+ }
528
+
529
+ /** `<t>_lifecycle_cascade`: stamp each live 'cascade' child with the parent's four values. */
530
+ function cascadeObjects(model: LifecycleTable, children: readonly LifecycleDependent[]): LifecycleObject[] {
531
+ if (children.length === 0) return [];
532
+ const name = `${model.schema}.${model.table}_lifecycle_cascade`;
533
+ const pk = pkOf(model);
534
+ const updates = children.map((d) => ` UPDATE ${qualified(d.child)}
535
+ SET ${AT} = NEW.${AT}, ${BY} = NEW.${BY}, ${BATCH} = NEW.${BATCH}, ${XID} = NEW.${XID}
536
+ WHERE ${ident(d.column)} = NEW.${pk} AND ${AT} IS NULL;`);
537
+ const cascade = lifecycleTriggerFunction(name, `
538
+ BEGIN
539
+ ${ownTableOnly(model, name)}
540
+ ${readCommittedOnly(model)}
541
+ ${updates.join('\n')}
542
+ RETURN NULL;
543
+ END`);
544
+ return [
545
+ cascade,
546
+ lifecycleTrigger(`${model.table}_lifecycle_cascade`, {
547
+ on: model,
548
+ timing: 'after',
549
+ events: ['update'],
550
+ of: [toSnakeCase(LIFECYCLE_COLUMNS[0])],
551
+ forEach: 'row',
552
+ condition: sql`OLD.${AT} IS NULL AND NEW.${AT} IS NOT NULL`,
553
+ execute: cascade,
554
+ }),
555
+ ];
556
+ }
557
+
558
+ /**
559
+ * 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
562
+ * 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.
564
+ *
565
+ * The stamped P rows are found the way restore finds a batch: from OLD's key, down the 'cascade'
566
+ * edges, keeping rows of THIS batch, one set per table, grown from the frontier. Only tables on a
567
+ * cascade path to a 'restrict' parent are walked. OLD's key is in its table's set even when the
568
+ * row was already deleted (an older batch), so a live child of it still refuses, as before.
569
+ * Counts run as the lifecycle role, so rows the deleter cannot see still count; the refusal names
570
+ * the parent table, the child table and a count, never a row.
571
+ */
572
+ function restrictCheck(model: LifecycleTable, dependents: readonly LifecycleDependent[], decl: LifecycleDeclaration): { declare: string; body: string } | undefined {
573
+ const inputTable = (m: TableName): LifecycleTable => {
574
+ const input = decl.tables[labelOf(m)];
575
+ if (!input) throw new Error(`lifecycle declaration misses ${labelOf(m)}`);
576
+ return input;
577
+ };
578
+ const cascades = dependents.filter((d) => d.dependent === 'cascade');
579
+ const subtree: LifecycleTable[] = [model];
580
+ for (let i = 0; i < subtree.length; i++) {
581
+ for (const d of cascades) if (labelOf(d.parent) === labelOf(subtree[i]) && !subtree.some((m) => labelOf(m) === labelOf(d.child))) subtree.push(inputTable(d.child));
582
+ }
583
+ const inSubtree = (m: TableName): boolean => subtree.some((t) => labelOf(t) === labelOf(m));
584
+ const restricts = dependents.filter((d) => d.dependent === 'restrict' && inSubtree(d.parent));
585
+ if (restricts.length === 0) return undefined;
586
+ // Walk only the tables that lead, through cascade edges, to a restrict parent.
587
+ const needed = new Set(restricts.map((d) => labelOf(d.parent)));
588
+ for (let grew = true; grew;) {
589
+ grew = false;
590
+ for (const d of cascades) {
591
+ if (inSubtree(d.parent) && needed.has(labelOf(d.child)) && !needed.has(labelOf(d.parent))) { needed.add(labelOf(d.parent)); grew = true; }
592
+ }
593
+ }
594
+ const tables = subtree.filter((m) => needed.has(labelOf(m)));
595
+ const edges = cascades.filter((d) => needed.has(labelOf(d.parent)) && needed.has(labelOf(d.child)));
596
+ const ix = (m: TableName): number => tables.findIndex((t) => labelOf(t) === labelOf(m));
597
+ const walk = edges.length > 0;
598
+ const keys = (d: LifecycleDependent): string => (walk ? `= ANY(lc.s${ix(d.parent)})` : `= OLD.${pkOf(model)}`);
599
+ const checks = restricts.map((d) => ` SELECT pg_catalog.count(*) INTO lc.n FROM ${qualified(d.child)} WHERE ${ident(d.column)} ${keys(d)} AND ${AT} IS NULL;
600
+ IF lc.n > 0 THEN
601
+ RAISE EXCEPTION USING ERRCODE = '23503', MESSAGE = ${literal(`delete of ${labelOf(d.parent)} refused: `)} || lc.n
602
+ || ${literal(` live row(s) of ${labelOf(d.child)} still reference it (dependent 'restrict' on ${d.column})`)};
603
+ END IF;`).join('\n');
604
+ const declare = [
605
+ 'n bigint;',
606
+ ...(walk ? ['grew boolean;', ...tables.flatMap((m, i) => [
607
+ `s${i} ${pkType(m)}[] := '{}';`, `f${i} ${pkType(m)}[] := '{}';`, `nf${i} ${pkType(m)}[];`,
608
+ // Only a table some walk edge grows into needs the per-edge buffer.
609
+ ...(edges.some((d) => ix(d.child) === i) ? [`add${i} ${pkType(m)}[];`] : []),
610
+ ])] : []),
611
+ ].map((l) => ` ${l}`).join('\n');
612
+ if (!walk) return { declare, body: checks };
613
+ const grow = edges.map((d) => {
614
+ const [p, c] = [ix(d.parent), ix(d.child)];
615
+ return ` lc.add${c} := ARRAY(SELECT t.${pkOf(inputTable(d.child))} FROM ${qualified(d.child)} t
616
+ JOIN pg_catalog.unnest(lc.f${p}) AS f(k) ON t.${ident(d.column)} = f.k
617
+ WHERE t.${BATCH} = lc.batch
618
+ EXCEPT SELECT pg_catalog.unnest(lc.s${c}));
619
+ IF pg_catalog.cardinality(lc.add${c}) > 0 THEN
620
+ lc.s${c} := lc.s${c} || lc.add${c};
621
+ lc.nf${c} := lc.nf${c} || lc.add${c};
622
+ lc.grew := true;
623
+ END IF;`;
624
+ }).join('\n');
625
+ const body = ` lc.s0 := ARRAY[OLD.${pkOf(model)}];
626
+ lc.f0 := lc.s0;
627
+ LOOP
628
+ lc.grew := false;
629
+ ${tables.map((_, i) => ` lc.nf${i} := '{}';`).join('\n')}
630
+ ${grow}
631
+ ${tables.map((_, i) => ` lc.f${i} := lc.nf${i};`).join('\n')}
632
+ EXIT WHEN NOT lc.grew;
633
+ END LOOP;
634
+ ${checks}`;
635
+ return { declare, body };
636
+ }
637
+
638
+ /**
639
+ * `<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
642
+ * 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
644
+ * lines of its own, so a trailing `--` comment in it ends only its own line. Anything but TRUE
645
+ * refuses with 23514 and the constraint name, naming the table and never a row value; the whole
646
+ * 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.
648
+ */
649
+ function deleteWhenObjects(model: LifecycleTable, deleteWhen: string | undefined): LifecycleObject[] {
650
+ if (deleteWhen === undefined) return [];
651
+ const name = `${model.schema}.${model.table}_lifecycle_delete_when`;
652
+ const pk = pkOf(model);
653
+ const guard = lifecycleTriggerFunction(name, `
654
+ #variable_conflict use_column
655
+ <<lc>>
656
+ DECLARE
657
+ allowed boolean;
658
+ BEGIN
659
+ ${ownTableOnly(model, name)}
660
+ SELECT (
661
+ ${deleteWhen}
662
+ ) INTO lc.allowed FROM ${qualified(model)} WHERE ${pk} = OLD.${pk};
663
+ IF lc.allowed IS NOT TRUE THEN
664
+ RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = ${literal(`${model.table}_lifecycle_delete_when`)},
665
+ SCHEMA = ${literal(model.schema)}, TABLE = ${literal(model.table)},
666
+ MESSAGE = ${literal(`delete of ${labelOf(model)} refused: the model's deleteWhen does not allow deleting this row`)};
667
+ END IF;
668
+ RETURN NEW;
669
+ END`);
670
+ return [
671
+ guard,
672
+ lifecycleTrigger(`${model.table}_lifecycle_delete_when`, {
673
+ on: model,
674
+ timing: 'before',
675
+ events: ['update'],
676
+ of: [toSnakeCase(LIFECYCLE_COLUMNS[0])],
677
+ forEach: 'row',
678
+ condition: sql`OLD.${AT} IS NULL AND NEW.${AT} IS NOT NULL`,
679
+ execute: guard,
680
+ }),
681
+ ];
682
+ }
683
+
684
+ /**
685
+ * `<t>_lifecycle_parent` on a lifecycle child: a live row may not reference a deleted parent of a
686
+ * 'cascade' or 'restrict' relation. Only a lifecycle parent can be deleted and still be there.
687
+ */
688
+ function parentLiveObjects(model: LifecycleTable, parents: readonly LifecycleDependent[]): LifecycleObject[] {
689
+ const unique = lifecycleParents(model, parents);
690
+ if (unique.length === 0) return [];
691
+ const name = `${model.schema}.${model.table}_lifecycle_parent`;
692
+ const checks = unique.map((d) => {
693
+ const fk = ident(d.column);
694
+ return ` IF NEW.${fk} IS NOT NULL AND (TG_OP = 'INSERT' OR NEW.${fk} IS DISTINCT FROM OLD.${fk}) THEN
695
+ SELECT p.${AT} INTO lc.parent_deleted FROM ${qualified(d.parent)} p WHERE p.${pkOf(d.parent)} = NEW.${fk} FOR SHARE;
696
+ -- A missing parent gets the same refusal as a deleted one. Left to the foreign key, it would
697
+ -- fail with a different message, telling any inserter which ids are hidden deleted rows.
698
+ IF NOT FOUND OR lc.parent_deleted IS NOT NULL THEN
699
+ RAISE EXCEPTION USING ERRCODE = '23503', MESSAGE = ${literal(
700
+ `${labelOf(model)}: a live row may not reference a missing or deleted ${labelOf(d.parent)} row (${d.column})`,
701
+ )};
702
+ END IF;
703
+ lc.parent_deleted := NULL;
704
+ END IF;`;
705
+ });
706
+ const parentLive = lifecycleTriggerFunction(name, `
707
+ #variable_conflict use_column
708
+ <<lc>>
709
+ DECLARE
710
+ parent_deleted timestamptz;
711
+ BEGIN
712
+ ${ownTableOnly(model, name)}
713
+ IF NEW.${AT} IS NULL THEN
714
+ ${checks.join('\n')}
715
+ END IF;
716
+ RETURN NEW;
717
+ END`);
718
+ const columns = [...new Set(unique.map((d) => d.column))].sort();
719
+ return [
720
+ parentLive,
721
+ lifecycleTrigger(`${model.table}_lifecycle_parent`, {
722
+ on: model, timing: 'before', events: ['insert', 'update'], of: columns, forEach: 'row', condition: SKIP_LIFECYCLE_ROLE, execute: parentLive,
723
+ }),
724
+ ];
725
+ }
726
+
727
+ /** Serialize the root, its cascade subtree and every lifecycle parent those builders read. */
728
+ export function declOf(model: ModelDescriptor, dependents: readonly ModelDependent[]): LifecycleDeclaration {
729
+ const subtree = new Set<ModelDescriptor>([model]);
730
+ for (const parent of subtree) {
731
+ for (const edge of dependents) if (edge.parent === parent && edge.dependent === 'cascade') subtree.add(edge.child);
732
+ }
733
+ // A 'restrict' edge below the subtree is a transition input: the delete checks it after the cascade.
734
+ const relevant = dependents.filter(d => d.parent === model || subtree.has(d.child) || (d.dependent === 'restrict' && subtree.has(d.parent)));
735
+ const inputs = new Set([...subtree, ...relevant.map(d => d.parent)]);
736
+ const tables: Record<string, LifecycleTable> = {};
737
+ for (const m of [...inputs].sort((a, b) => labelOf(a) < labelOf(b) ? -1 : labelOf(a) > labelOf(b) ? 1 : 0)) {
738
+ tables[labelOf(m)] = {
739
+ schema: m.schema, table: m.table,
740
+ pk: { column: toSnakeCase(m.primaryKey[0]), type: pgType(m.fields[m.primaryKey[0]].spec) },
741
+ unique: Object.values(m.fields).some(f => f.spec.isUnique && !f.spec.isPrimaryKey)
742
+ || m.constraints.some(c => c.kind === 'unique' || (c.kind === 'index' && c.isUnique)),
743
+ // Role insertion order is a builder input: its branches must stay byte-identical.
744
+ auth: deleteRulePredicates(m),
745
+ };
746
+ }
747
+ return { tables, edges: relevant.map(d => ({ parent: labelOf(d.parent),
748
+ // A restrict-only child has no table descriptor: quote its dotted schema before splitting.
749
+ child: !tables[labelOf(d.child)] && /[."]/.test(d.child.schema) ? `${ident(d.child.schema)}.${ident(d.child.table)}` : labelOf(d.child),
750
+ column: d.column, dependent: d.dependent })) };
751
+ }
752
+
753
+ /** One generator for model emission and verification of recorded JSON inputs. */
754
+ export function regenerateLifecycle(model: string, decl: LifecycleDeclaration, deleteWhen?: string): LifecycleObject[] {
755
+ const root = decl.tables[model];
756
+ if (!root) throw new Error(`lifecycle declaration misses ${model}`);
757
+ const dependents = decl.edges.map(edge => {
758
+ const parent = decl.tables[edge.parent];
759
+ if (!parent) throw new Error(`lifecycle declaration misses ${edge.parent}`);
760
+ // A restrict-only child contributes its qualified name, not fields or restore inputs.
761
+ const { schema, name: table } = parseQualified(edge.child);
762
+ return { parent, child: decl.tables[edge.child] ?? { schema, table }, column: edge.column, dependent: edge.dependent };
763
+ });
764
+ return lifecycleObjects(root, dependents, decl, deleteWhen);
765
+ }
766
+
767
+ /** The generated lifecycle objects, with comments stamped by the ordinary reconciler. */
768
+ export function lifecycleDerived(models: readonly ModelDescriptor[]): DerivedDescriptor[] {
769
+ const dependents = lifecycleDependents(models);
770
+ const byName = new Map(models.map(model => [labelOf(model), model]));
771
+ return [...new Set(models)].filter(m => m.lifecycle === true)
772
+ .sort((a, b) => labelOf(a) < labelOf(b) ? -1 : labelOf(a) > labelOf(b) ? 1 : 0)
773
+ .flatMap(model => {
774
+ const decl = declOf(model, dependents);
775
+ const generated = regenerateLifecycle(labelOf(model), decl, model.deleteWhen);
776
+ for (const object of generated) {
777
+ if (object.kind !== 'function') continue;
778
+ const role = lifecycleRole(object.name, labelOf(model));
779
+ if (!role) throw new Error(`unknown lifecycle function ${object.name}`);
780
+ const manifest: LifecycleManifest = { everystack: 'lifecycle', v: MANIFEST_VERSION, role, model: labelOf(model), decl,
781
+ ...(role === 'delete_when' && model.deleteWhen !== undefined ? { deleteWhen: model.deleteWhen } : {}), defHash: canonHash(canonOfFunction(object)) };
782
+ object.comment = JSON.stringify(manifest);
783
+ }
784
+ // Only the emitter binds table references back to Models; the builders never see them.
785
+ return generated.map(object => object.kind === 'trigger' ? { ...object, on: byName.get(labelOf(object.on))! } : object);
786
+ });
787
+ }