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