@everystack/cli 0.4.99 → 0.4.102

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