@everystack/cli 0.4.100 → 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.
- package/CHANGELOG.md +91 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +226 -76
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +35 -17
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +64 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- 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
|
+
}
|