@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,177 @@
1
+ /**
2
+ * db:pull's lifecycle fold — reads a lifecycle table back as `lifecycle: true`.
3
+ *
4
+ * `lifecycle: true` generates, per table: four columns, the reserved role's grants and policies,
5
+ * a `deleted_at IS NULL` guard on every other role's SELECT policy, and two functions, a restore
6
+ * function and two triggers. A pull that transcribed them would render the reserved role as an
7
+ * authored ability and the columns as authored fields (defineModel refuses both), and the five
8
+ * objects as derived descriptors (they would be generated twice).
9
+ *
10
+ * So the fold removes all of it from the pull's inputs, and the renderer writes `lifecycle: true`.
11
+ * It folds a table only when the catalog holds EXACTLY what the compiler writes: the four columns
12
+ * as generated, the reserved role's grants and policies equal to a compiled lifecycle model's, the
13
+ * role in no other policy, and the guard at the head of every other SELECT policy. Anything else is left as is, so the pull renders what is there and the model's own
14
+ * refusal names it. The generated objects are removed by name; a hand-edited body still shows,
15
+ * because the derived plan compares definitions.
16
+ *
17
+ * Pure: inputs in, folded copies out.
18
+ */
19
+
20
+ import { defineModel, field, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
21
+ import type { SchemaSnapshot, TableSchema } from './schema-introspect';
22
+ import type { AuthzContract, PolicyContract, TableContract } from './authz-contract';
23
+ import type { DerivedCatalog } from './derived-introspect';
24
+ import { compileTableContract } from './authz-compile';
25
+
26
+ /** The generated columns as the catalog reports them (`format_type`), nullable, no default. */
27
+ const GENERATED_COLUMNS: Record<string, string> = {
28
+ deleted_at: 'timestamp with time zone',
29
+ deleted_by: 'text',
30
+ deleted_batch: 'uuid',
31
+ deleted_xid: 'bigint',
32
+ };
33
+
34
+ const GUARD = '(deleted_at IS NULL)';
35
+
36
+ /** The commands the measured effective matrix reports (`effectivePrivileges`). */
37
+ const EFFECTIVE_COMMANDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
38
+
39
+ export interface LifecycleFold {
40
+ snapshot: SchemaSnapshot;
41
+ contract: AuthzContract;
42
+ catalog: DerivedCatalog;
43
+ /** Schema-qualified tables folded to `lifecycle: true`. */
44
+ tables: Set<string>;
45
+ }
46
+
47
+ const split = (qualified: string): { schema: string; name: string } => {
48
+ const dot = qualified.indexOf('.');
49
+ return dot < 0 ? { schema: 'public', name: qualified } : { schema: qualified.slice(0, dot), name: qualified.slice(dot + 1) };
50
+ };
51
+
52
+ const sorted = (xs: readonly string[] | undefined): string => JSON.stringify([...(xs ?? [])].sort());
53
+
54
+ const policyKey = (p: PolicyContract): string =>
55
+ JSON.stringify([p.name, p.command, [...p.roles].sort(), p.permissive, p.using, p.check]);
56
+
57
+ /** The reserved role's part of a compiled lifecycle model — what an exact table must hold live. */
58
+ function reservedRoleContract(qualified: string): TableContract {
59
+ const { schema, name } = split(qualified);
60
+ const model: ModelDescriptor = defineModel(name, { schema, lifecycle: true, fields: { id: field.integer().primaryKey() } });
61
+ return compileTableContract(model);
62
+ }
63
+
64
+ /**
65
+ * The SELECT policy's text with the guard removed (null: nothing else), or undefined when it does
66
+ * not carry the guard as compiled. The compiler puts the guard first in a top-level AND, and
67
+ * PostgreSQL deparses an AND as `((a) AND (b) …)`, so the guard is exactly this prefix.
68
+ */
69
+ function unguarded(using: string | null): string | null | undefined {
70
+ if (using === GUARD) return null;
71
+ if (using?.startsWith(`(${GUARD} AND `) && using.endsWith(')')) return using.slice(GUARD.length + 6, -1);
72
+ return undefined;
73
+ }
74
+
75
+ function columnsAreGenerated(table: TableSchema): boolean {
76
+ return Object.entries(GENERATED_COLUMNS).every(([name, type]) => {
77
+ const c = table.columns.find((col) => col.name === name);
78
+ return c !== undefined && c.type === type && !c.notNull && c.default === null;
79
+ });
80
+ }
81
+
82
+ /** The folded contract for one table, or undefined when the table is not exactly lifecycle. */
83
+ function foldContract(live: TableContract): TableContract | undefined {
84
+ const role = LIFECYCLE_OWNER_ROLE;
85
+ const expected = reservedRoleContract(live.table);
86
+ if (sorted(live.grants[role]) !== sorted(expected.grants[role])) return undefined;
87
+ const liveCols = live.columnGrants?.[role] ?? {};
88
+ const expectedCols = expected.columnGrants?.[role] ?? {};
89
+ if (sorted(Object.keys(liveCols)) !== sorted(Object.keys(expectedCols))) return undefined;
90
+ if (Object.keys(expectedCols).some((priv) => sorted(liveCols[priv]) !== sorted(expectedCols[priv]))) return undefined;
91
+ // The direct ACL matching is not enough: the role can hold more through membership or PUBLIC.
92
+ // When the live read measured it, the role must hold exactly the commands its compiled grants
93
+ // imply (a column grant counts as its command). Absent = not measured: the direct check stands.
94
+ // A grant to PUBLIC on the table widens the measured set too, so such a table stays unfolded:
95
+ // the check errs toward not folding, never toward a false fold.
96
+ if (live.effectivePrivileges) {
97
+ const implied = new Set([...(expected.grants[role] ?? []), ...Object.keys(expectedCols)].filter((p) => EFFECTIVE_COMMANDS.has(p)));
98
+ if (sorted(live.effectivePrivileges[role]) !== sorted([...implied])) return undefined;
99
+ }
100
+
101
+ const naming = (p: PolicyContract) => p.roles.includes(role);
102
+ const livePolicies = live.policies.filter(naming);
103
+ const expectedPolicies = expected.policies.filter(naming);
104
+ if (sorted(livePolicies.map(policyKey)) !== sorted(expectedPolicies.map(policyKey))) return undefined;
105
+
106
+ const policies: PolicyContract[] = [];
107
+ for (const p of live.policies) {
108
+ if (naming(p)) continue;
109
+ if (p.command !== 'SELECT') {
110
+ policies.push(p);
111
+ continue;
112
+ }
113
+ const rest = unguarded(p.using);
114
+ if (rest === undefined) return undefined;
115
+ policies.push({ ...p, using: rest ?? 'true' });
116
+ }
117
+
118
+ const { [role]: _grant, ...grants } = live.grants;
119
+ const folded: TableContract = { ...live, grants, policies };
120
+ if (live.columnGrants) {
121
+ const { [role]: _cols, ...columnGrants } = live.columnGrants;
122
+ folded.columnGrants = columnGrants;
123
+ }
124
+ if (live.effectivePrivileges) {
125
+ const { [role]: _eff, ...effectivePrivileges } = live.effectivePrivileges;
126
+ folded.effectivePrivileges = effectivePrivileges;
127
+ }
128
+ return folded;
129
+ }
130
+
131
+ /**
132
+ * The five generated objects of one lifecycle table, exactly as the derived catalog keys them. The
133
+ * restore takes the primary key, and a function signature carries no type modifier
134
+ * (`character varying(255)` is `character varying` there). An author's own overload stays.
135
+ */
136
+ function generatedIdentities(table: TableSchema): string[] {
137
+ const { schema, name } = split(table.table);
138
+ const pk = table.columns.find((c) => c.name === table.primaryKey[0]);
139
+ const arg = (pk?.type ?? '').replace(/\(\d+(,\s*\d+)?\)/g, '');
140
+ return [
141
+ `${schema}.${name}_lifecycle_transition()`,
142
+ `${schema}.${name}_lifecycle_guard()`,
143
+ `${schema}.${name}_restore(${arg})`,
144
+ `${table.table}.${name}_lifecycle_transition`,
145
+ `${table.table}.${name}_lifecycle_guard`,
146
+ ];
147
+ }
148
+
149
+ export function foldLifecycle(snapshot: SchemaSnapshot, contract: AuthzContract, catalog: DerivedCatalog): LifecycleFold {
150
+ const tables = new Set<string>();
151
+ const foldedContracts = new Map<string, TableContract>();
152
+ for (const table of snapshot.tables) {
153
+ if (table.primaryKey.length !== 1 || !columnsAreGenerated(table)) continue;
154
+ const live = contract.tables.find((t) => t.table === table.table);
155
+ const folded = live && live.rls.enabled ? foldContract(live) : undefined;
156
+ if (!folded) continue;
157
+ tables.add(table.table);
158
+ foldedContracts.set(table.table, folded);
159
+ }
160
+ if (tables.size === 0) return { snapshot, contract, catalog, tables };
161
+
162
+ const generated = new Set(snapshot.tables.filter((t) => tables.has(t.table)).flatMap(generatedIdentities));
163
+ return {
164
+ snapshot: {
165
+ ...snapshot,
166
+ tables: snapshot.tables.map((t) =>
167
+ tables.has(t.table) ? { ...t, columns: t.columns.filter((c) => !Object.hasOwn(GENERATED_COLUMNS, c.name)) } : t),
168
+ },
169
+ contract: { ...contract, tables: contract.tables.map((t) => foldedContracts.get(t.table) ?? t) },
170
+ catalog: {
171
+ objects: catalog.objects.filter((o) => !generated.has(o.identity)),
172
+ edges: catalog.edges.filter((e) => !generated.has(e.dependent) && !generated.has(e.referenced)),
173
+ provenance: catalog.provenance.filter((p) => !generated.has(p.identity)),
174
+ },
175
+ tables,
176
+ };
177
+ }
@@ -0,0 +1,232 @@
1
+ /** Fail-closed verification of generated lifecycle definitions before db:pull writes. */
2
+ import { LIFECYCLE_OWNER_ROLE, parseQualified, type FunctionDescriptor } from '@everystack/model';
3
+ import { canonHash, canonOfFunction, type FunctionCanon } from './lifecycle-canon';
4
+ import { regenerateLifecycle, lifecycleRole, MANIFEST_VERSION, LIFECYCLE_ROLES, type LifecycleDeclaration, type LifecycleManifest, type LifecycleTrigger } from './lifecycle-derived';
5
+ import { normalizeArgType } from './pg-argtypes';
6
+ import { parseTriggerType } from './derived-introspect';
7
+ import { normalizeDeparsedExpr } from './deparse-normal';
8
+ import { INTROSPECTION_SESSION, type SessionRunner } from './session';
9
+
10
+ const FUNCTIONS = `SELECT n.nspname AS schema, p.proname AS name,
11
+ n.nspname || '.' || p.proname || '(' || coalesce((SELECT string_agg(format_type(t, NULL), ', ' ORDER BY o) FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY x(t,o)), '') || ')' AS identity,
12
+ ARRAY(SELECT format_type(t,NULL) FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY x(t,o) ORDER BY o) AS arg_types,
13
+ pg_get_function_arguments(p.oid) AS args, pg_get_function_result(p.oid) AS returns,
14
+ l.lanname AS language,p.prosecdef AS secdef,p.provolatile::text AS volatility,p.proisstrict AS strict,p.proleakproof AS leakproof,
15
+ coalesce(p.proconfig,'{}'::text[]) AS config,pg_get_userbyid(p.proowner) AS owner,p.prosrc AS body,obj_description(p.oid,'pg_proc') AS comment,
16
+ coalesce((SELECT array_agg(DISTINCT coalesce(g.rolname,'PUBLIC') ORDER BY coalesce(g.rolname,'PUBLIC'))
17
+ FROM aclexplode(coalesce(p.proacl,acldefault('f',p.proowner))) a LEFT JOIN pg_roles g ON g.oid=a.grantee
18
+ WHERE a.grantee<>p.proowner AND a.privilege_type='EXECUTE'),'{}'::name[])::text[] AS execute
19
+ FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace JOIN pg_language l ON l.oid=p.prolang
20
+ WHERE p.prokind IN ('f','p') AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg_%'
21
+ AND NOT EXISTS(SELECT 1 FROM pg_depend d WHERE d.objid=p.oid AND d.deptype='e')`;
22
+ const TRIGGERS = `SELECT n.nspname||'.'||c.relname AS "table",t.tgname AS name,t.tgtype::int AS type,t.tgenabled::text AS enabled,
23
+ t.tgconstraint<>0 AS is_constraint,t.tgdeferrable AS deferrable,t.tginitdeferred AS initially_deferred,
24
+ fn.nspname||'.'||p.proname AS fn,substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr,
25
+ coalesce((SELECT array_agg(a.attname::text ORDER BY x.ord) FROM unnest(t.tgattr::int2[]) WITH ORDINALITY x(attnum,ord)
26
+ JOIN pg_attribute a ON a.attrelid=t.tgrelid AND a.attnum=x.attnum),'{}'::text[]) AS update_of,encode(t.tgargs,'hex') AS args
27
+ FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
28
+ JOIN pg_proc p ON p.oid=t.tgfoid JOIN pg_namespace fn ON fn.oid=p.pronamespace
29
+ WHERE NOT t.tgisinternal AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg_%'`;
30
+ const TABLES = `SELECT n.nspname||'.'||c.relname AS identity,
31
+ coalesce((SELECT jsonb_agg(jsonb_build_object('role',coalesce(r.rolname,'PUBLIC'),
32
+ 'predicate',coalesce(pg_get_expr(p.polqual,p.polrelid),'true'),'permissive',p.polpermissive) ORDER BY p.polname,r.rolname)
33
+ FROM pg_policy p CROSS JOIN LATERAL unnest(p.polroles) role(oid) LEFT JOIN pg_roles r ON r.oid=role.oid
34
+ WHERE p.polrelid=c.oid AND p.polcmd IN ('d','*') AND r.rolname IS DISTINCT FROM 'everystack_lifecycle'),'[]'::jsonb) AS delete_policies,
35
+ ARRAY(SELECT a.attname::text FROM unnest(k.conkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=x.num ORDER BY x.ord) AS pk_columns,
36
+ ARRAY(SELECT format_type(a.atttypid,a.atttypmod) FROM unnest(k.conkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=x.num ORDER BY x.ord) AS pk_types,
37
+ EXISTS(SELECT 1 FROM pg_index i WHERE i.indrelid=c.oid AND i.indisunique AND NOT i.indisprimary) AS unique,
38
+ ((SELECT count(*)=4 FROM pg_attribute a WHERE a.attrelid=c.oid AND NOT a.attisdropped AND a.attname IN ('deleted_at','deleted_by','deleted_batch','deleted_xid'))
39
+ AND EXISTS(SELECT 1 FROM aclexplode(coalesce(c.relacl,acldefault('r',c.relowner))) a JOIN pg_roles r ON r.oid=a.grantee
40
+ WHERE r.rolname='everystack_lifecycle' AND a.privilege_type='DELETE')) AS lifecycle
41
+ FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace LEFT JOIN pg_constraint k ON k.conrelid=c.oid AND k.contype='p'
42
+ WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg_%'`;
43
+ const EDGES = `SELECT pn.nspname||'.'||p.relname AS parent,cn.nspname||'.'||c.relname AS child,
44
+ ARRAY(SELECT a.attname::text FROM unnest(f.conkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=x.num ORDER BY x.ord) AS columns,
45
+ ARRAY(SELECT a.attname::text FROM unnest(f.confkey) WITH ORDINALITY x(num,ord) JOIN pg_attribute a ON a.attrelid=p.oid AND a.attnum=x.num ORDER BY x.ord) AS references,
46
+ f.confdeltype::text AS action FROM pg_constraint f JOIN pg_class c ON c.oid=f.conrelid JOIN pg_namespace cn ON cn.oid=c.relnamespace
47
+ JOIN pg_class p ON p.oid=f.confrelid JOIN pg_namespace pn ON pn.oid=p.relnamespace WHERE f.contype='f'`;
48
+
49
+ type Row = Record<string, unknown>;
50
+ const record = (value: unknown): value is Row => value !== null && typeof value === 'object' && !Array.isArray(value);
51
+ const strings = (value: unknown): string[] => Array.isArray(value) ? value.map(String) : [];
52
+ const same = (a: unknown, b: unknown): boolean => canonHash(a) === canonHash(b);
53
+ const hint = 'missing lifecycle manifest; db:apply for a managed install; db:reconcile --baseline for a brownfield object with no provenance (use --apply to persist)';
54
+
55
+ interface TriggerCanon { kind: 'trigger'; identity: string; table: string; timing: string; events: string[]; forEach: string; fn: string; when: string | null; updateOf: string[]; args: string; enabled: string; constraint: boolean; deferrable: boolean; initiallyDeferred: boolean; }
56
+ interface LiveFunction { name: string; argTypes: string[]; canon: FunctionCanon; comment: unknown; }
57
+ export interface LifecycleVerdict { failures: string[]; notes: string[]; }
58
+ export class LifecycleDriftError extends Error {
59
+ constructor(readonly lines: string[]) { super('lifecycle verification failed'); }
60
+ }
61
+
62
+ /** Only the two fixed generated WHEN expressions need catalog spelling normalization. */
63
+ /** A trigger WHEN, normalised so the generated text and PostgreSQL's deparse compare equal. Exported for tests. */
64
+ export function whenExpression(value: string | null): string | null {
65
+ if (value === null) return null;
66
+ const text = value.replace(new RegExp(`'${LIFECYCLE_OWNER_ROLE}'::name`, 'g'), `'${LIFECYCLE_OWNER_ROLE}'`);
67
+ const tokens = text.match(/'(?:[^']|'')*'|"(?:[^"]|"")*"|[^'"]+/g) ?? [];
68
+ return tokens.map(token => {
69
+ if (token.startsWith("'")) return token;
70
+ if (token.startsWith('"')) return token === '"deleted_at"' ? 'deleted_at' : token;
71
+ // PostgreSQL deparses a pg_catalog function unqualified; the generator writes it qualified.
72
+ // Drop only a WHOLE `pg_catalog.` qualifier, on the raw token (before spaces go, or `OR pg_catalog.`
73
+ // would fuse into `orpg_catalog.`): not after a name character, a dot or a quote, so
74
+ // `ppg_catalog.g_trigger_depth` and `x.pg_catalog.f` keep their text and show as drift.
75
+ return token.replace(/(?<![A-Za-z0-9_$."])pg_catalog\./gi, '').replace(/[()\s]/g, '').toLowerCase();
76
+ }).join('');
77
+ }
78
+ function canonOfTrigger(trigger: LifecycleTrigger): TriggerCanon {
79
+ return { kind: 'trigger', identity: `${trigger.on.schema}.${trigger.on.table}.${trigger.name}`, table: `${trigger.on.schema}.${trigger.on.table}`, timing: trigger.timing,
80
+ events: trigger.events, forEach: trigger.forEach, fn: trigger.execute.name, when: whenExpression(trigger.condition ?? null),
81
+ updateOf: trigger.of ?? [], args: '', enabled: 'O', constraint: false, deferrable: false, initiallyDeferred: false };
82
+ }
83
+ function typeIdentity(type: string): string {
84
+ return normalizeArgType(type) + '|' + (type.match(/\([^)]*\)/g) ?? []).join('').replace(/\s+/g, '');
85
+ }
86
+ function declaration(value: unknown): value is LifecycleDeclaration {
87
+ if (!record(value) || !record(value.tables) || !Array.isArray(value.edges)) return false;
88
+ for (const [id, table] of Object.entries(value.tables)) {
89
+ if (!record(table) || typeof table.schema !== 'string' || typeof table.table !== 'string' || id !== `${table.schema}.${table.table}`
90
+ || !record(table.pk) || typeof table.pk.column !== 'string' || typeof table.pk.type !== 'string'
91
+ || typeof table.unique !== 'boolean' || !record(table.auth) || Object.values(table.auth).some(p => typeof p !== 'string')) return false;
92
+ }
93
+ return value.edges.every(edge => record(edge) && typeof edge.parent === 'string' && typeof edge.child === 'string'
94
+ && typeof edge.column === 'string' && (edge.dependent === 'cascade' || edge.dependent === 'restrict'));
95
+ }
96
+
97
+ /** Fold catalog policies by role exactly as the compiler records DELETE/ALL rules. */
98
+ function sameDeleteRules(auth: Record<string, string>, policies: unknown): boolean {
99
+ if (!Array.isArray(policies)) return false;
100
+ const byRole = new Map<string, string[]>();
101
+ for (const policy of policies) {
102
+ if (!record(policy) || typeof policy.role !== 'string' || typeof policy.predicate !== 'string' || policy.permissive !== true) return false;
103
+ const predicates = byRole.get(policy.role) ?? [];
104
+ predicates.push(normalizeDeparsedExpr(policy.predicate));
105
+ byRole.set(policy.role, predicates);
106
+ }
107
+ const live = Object.fromEntries([...byRole].map(([role, predicates]) => [role,
108
+ predicates.length === 1 ? predicates[0] : predicates.map(p => `(${p})`).join(' OR ')]));
109
+ const declared = Object.fromEntries(Object.entries(auth).map(([role, predicate]) => [role, normalizeDeparsedExpr(predicate)]));
110
+ return same(declared, live);
111
+ }
112
+
113
+ /**
114
+ * An older CLI generated a per-row `<t>_lifecycle_restrict` function (manifest role 'restrict') and
115
+ * BEFORE DELETE trigger; the restrict check now lives in the transition. That pair is stale output,
116
+ * not a malformed manifest or an author trigger: say so, and name the remedy.
117
+ */
118
+ const OLD_RESTRICT = 'stale, generated by an older CLI: run db:apply to remove the old restrict function and trigger';
119
+ const oldRestrictName = (table: string): string => `${table}_lifecycle_restrict`;
120
+
121
+ /** Four catalog reads share one read-only snapshot. No function bodies or generated SQL execute. */
122
+ export async function verifyLifecycle(session: SessionRunner, governedRoles: readonly string[] = ['anon', 'authenticated', 'admin', LIFECYCLE_OWNER_ROLE]): Promise<LifecycleVerdict> {
123
+ const results = await session([FUNCTIONS, TRIGGERS, TABLES, EDGES], INTROSPECTION_SESSION);
124
+ if (results.length !== 4 || results.some(rows => !Array.isArray(rows))) throw new Error('lifecycle catalog could not be read completely');
125
+ const [functions, triggers, tables, edges] = results as Row[][];
126
+ const problems = new Map<string, string>();
127
+ const add = (id: string, reason: string): void => { if (!problems.has(id)) problems.set(id, `drift: ${id}: ${reason}`); };
128
+ const notes = new Set<string>();
129
+ const tableById = new Map(tables.map(t => [String(t.identity), t]));
130
+ const lifecycleTables = new Set(tables.filter(t => t.lifecycle === true).map(t => String(t.identity)));
131
+ const liveFunctions = new Map<string, LiveFunction>();
132
+ for (const row of functions) {
133
+ const canon: FunctionCanon = { kind: 'function', identity: String(row.identity), args: String(row.args), returns: String(row.returns),
134
+ language: String(row.language), secdef: row.secdef === true, volatility: String(row.volatility), strict: row.strict === true, leakproof: row.leakproof === true,
135
+ config: strings(row.config), owner: String(row.owner), execute: strings(row.execute).sort(), body: String(row.body) };
136
+ liveFunctions.set(canon.identity, { name: `${row.schema}.${row.name}`, canon, argTypes: strings(row.arg_types), comment: row.comment });
137
+ }
138
+ const liveTriggers = new Map<string, TriggerCanon>();
139
+ const triggerTypes = new Map<string, number>();
140
+ for (const row of triggers) {
141
+ const shape = parseTriggerType(Number(row.type));
142
+ const canon: TriggerCanon = { kind: 'trigger', identity: `${row.table}.${row.name}`, table: String(row.table), timing: shape.timing, events: shape.events, forEach: shape.forEach,
143
+ fn: String(row.fn), when: whenExpression(row.when_expr == null ? null : String(row.when_expr)), updateOf: strings(row.update_of), args: String(row.args), enabled: String(row.enabled),
144
+ constraint: row.is_constraint === true, deferrable: row.deferrable === true, initiallyDeferred: row.initially_deferred === true };
145
+ liveTriggers.set(canon.identity, canon);
146
+ triggerTypes.set(canon.identity, Number(row.type));
147
+ }
148
+ const familyByName = (live: LiveFunction): boolean => [...lifecycleTables].some(model => {
149
+ if (live.name.startsWith(`${model}_lifecycle_`)) return true;
150
+ const pkTypes = strings(tableById.get(model)?.pk_types);
151
+ return live.name === `${model}_restore` && live.argTypes.length === 1 && pkTypes.length === 1
152
+ && normalizeArgType(live.argTypes[0]) === normalizeArgType(pkTypes[0]);
153
+ });
154
+ const manifests = new Map<string, LifecycleManifest>();
155
+ const groups = new Map<string, { decl: LifecycleDeclaration; from: string }>();
156
+ for (const [id, live] of liveFunctions) {
157
+ let raw: unknown;
158
+ try { raw = typeof live.comment === 'string' ? JSON.parse(live.comment) : undefined; } catch {
159
+ if (live.canon.owner === LIFECYCLE_OWNER_ROLE || familyByName(live)) add(id, 'malformed lifecycle manifest');
160
+ continue;
161
+ }
162
+ const family = live.canon.owner === LIFECYCLE_OWNER_ROLE || (record(raw) && raw.everystack === 'lifecycle')
163
+ || familyByName(live);
164
+ if (!family) continue;
165
+ if (!record(raw) || raw.everystack !== 'lifecycle') { add(id, hint); continue; }
166
+ if (raw.v !== MANIFEST_VERSION) { add(id, `unsupported lifecycle manifest v${String(raw.v)}; this CLI emits v${MANIFEST_VERSION}; re-apply`); continue; }
167
+ if (raw.role === 'restrict' && typeof raw.model === 'string' && lifecycleTables.has(raw.model) && live.name === oldRestrictName(raw.model)) { add(id, OLD_RESTRICT); continue; }
168
+ if (typeof raw.model !== 'string' || !declaration(raw.decl) || typeof raw.role !== 'string' || !(LIFECYCLE_ROLES as readonly string[]).includes(raw.role)
169
+ || typeof raw.defHash !== 'string' || !/^[a-f0-9]{64}$/.test(raw.defHash) || !raw.decl.tables[raw.model]) { add(id, 'malformed lifecycle manifest'); continue; }
170
+ if (lifecycleRole(live.name, raw.model) !== raw.role) { add(id, 'manifest role does not match function name'); continue; }
171
+ const manifest = raw as unknown as LifecycleManifest;
172
+ manifests.set(id, manifest);
173
+ const prev = groups.get(manifest.model);
174
+ if (prev && JSON.stringify(prev.decl) !== JSON.stringify(manifest.decl)) add(id, `conflicting declarations for ${manifest.model}`);
175
+ else if (!prev) groups.set(manifest.model, { decl: manifest.decl, from: id });
176
+ }
177
+ for (const table of lifecycleTables) if (!groups.has(table)) add(table, hint);
178
+
179
+ const expectedFunctions = new Map<string, FunctionDescriptor>();
180
+ const expectedTriggers = new Map<string, TriggerCanon>();
181
+ for (const [model, { decl, from }] of groups) {
182
+ // The catalog is an independent input: a self-consistent comment and body cannot erase a unique index.
183
+ for (const [id, input] of Object.entries(decl.tables)) {
184
+ const actual = tableById.get(id);
185
+ if (!actual) { add(id, 'declared table missing from catalog'); continue; }
186
+ if (!same(strings(actual.pk_columns), [input.pk.column]) || !same(strings(actual.pk_types).map(typeIdentity), [typeIdentity(input.pk.type)])) add(id, 'primary key differs from catalog');
187
+ if (actual.unique !== input.unique) add(id, 'unique flag differs from catalog');
188
+ if (!sameDeleteRules(input.auth, actual.delete_policies)) add(id, 'delete rule in the manifest does not match the live policies');
189
+ for (const role of Object.keys(input.auth)) if (!governedRoles.includes(role)) notes.add(`ungoverned role ${role} in lifecycle manifest ${model}`);
190
+ }
191
+ for (const edge of decl.edges) {
192
+ const parent = tableById.get(edge.parent);
193
+ const child = decl.tables[edge.child] ?? (() => { const { schema, name: table } = parseQualified(edge.child); return { schema, table }; })();
194
+ if (!edges.some(actual => actual.parent === edge.parent && actual.child === `${child.schema}.${child.table}` && same(strings(actual.columns), [edge.column])
195
+ && same(strings(actual.references), strings(parent?.pk_columns)) && actual.action === 'r')) add(`${edge.child}.${edge.column}`, 'foreign key differs from catalog');
196
+ }
197
+ let objects;
198
+ try { objects = regenerateLifecycle(model, decl); } catch { add(from, 'malformed declaration cannot regenerate'); continue; }
199
+ for (const object of objects) {
200
+ if (object.kind === 'function') expectedFunctions.set(canonOfFunction(object).identity, object);
201
+ else { const canon = canonOfTrigger(object); expectedTriggers.set(canon.identity, canon); }
202
+ }
203
+ }
204
+ for (const [id, expected] of expectedFunctions) {
205
+ const actual = liveFunctions.get(id);
206
+ if (!actual) { add(id, 'missing generated function'); continue; }
207
+ const manifest = manifests.get(id);
208
+ if (!manifest) { add(id, hint); continue; }
209
+ const liveHash = canonHash(actual.canon);
210
+ if (manifest.defHash !== liveHash) { add(id, 'drifted, live definition differs from its recorded hash'); continue; }
211
+ if (canonHash(canonOfFunction(expected)) !== liveHash) add(id, 'stale, re-apply');
212
+ }
213
+ for (const [id] of manifests) if (!expectedFunctions.has(id)) add(id, 'unlisted function in lifecycle family');
214
+ for (const [id, expected] of expectedTriggers) {
215
+ const actual = liveTriggers.get(id);
216
+ if (!actual) add(id, 'missing generated trigger');
217
+ else if (!same(expected, actual)) add(id, 'generated trigger differs from regeneration (a hand edit, or an older CLI): run db:apply to re-create it');
218
+ }
219
+ const functionNames = new Set([...expectedFunctions.values()].map(fn => fn.name));
220
+ for (const [id, actual] of liveTriggers) {
221
+ if (expectedTriggers.has(id)) continue;
222
+ const bare = actual.table.slice(actual.table.lastIndexOf('.') + 1);
223
+ if (lifecycleTables.has(actual.table) && id === `${actual.table}.${bare}_lifecycle_restrict` && actual.fn === oldRestrictName(actual.table)) { add(id, OLD_RESTRICT); continue; }
224
+ if (functionNames.has(actual.fn)) add(id, 'unmanaged lifecycle trigger binding');
225
+ else if (lifecycleTables.has(actual.table)) {
226
+ const type = triggerTypes.get(id)!;
227
+ if ((type & 32) !== 0) add(id, 'author TRUNCATE trigger refused: bypasses lifecycle row removal');
228
+ else if ((type & (2 | 8)) === (2 | 8)) add(id, 'author BEFORE DELETE trigger refused: can suppress purge DELETE');
229
+ }
230
+ }
231
+ return { failures: [...problems.values()].sort(), notes: [...notes].sort() };
232
+ }
@@ -19,13 +19,14 @@
19
19
  * lands with whole-DB introspection. The emission is identical either way.
20
20
  */
21
21
 
22
- import type { ModelDescriptor, Module, SequenceDescriptor } from '@everystack/model';
22
+ import { relationDeleteActions, type ModelDescriptor, type Module, type SequenceDescriptor } from '@everystack/model';
23
23
  import type { AuthzContract, TableContract } from './authz-contract';
24
24
  import { compileCreateTable, compileEnums, compileSequences, sequenceCreateSql, foreignKeySql, modelConstraintSpecs, qualifiedTable, toSnakeCase, type CompileTableOptions } from './schema-compile';
25
25
  import { compileTableContract } from './authz-compile';
26
26
  import { emitReconcileSql } from './authz-reconcile';
27
27
  import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff';
28
28
  import { compileDerived } from './derived-compile';
29
+ import { lifecycleDerived } from './lifecycle-derived';
29
30
  import { renderEnsureObjectSchemas } from './derived-apply';
30
31
 
31
32
  /** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
@@ -44,6 +45,9 @@ export function compileMigration(
44
45
  ): string[] {
45
46
  const sql: string[] = [];
46
47
  const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
48
+ // A parent's hasMany `dependent` sets a child FK's ON DELETE; resolved over the whole set
49
+ // from THIS set, before any FK is compiled.
50
+ const dependents = relationDeleteActions(models);
47
51
 
48
52
  // 0. Extensions — the BOOTSTRAP, before any table that might use one of their types.
49
53
  // compileModuleMigration has always emitted these; compileMigration had no way to,
@@ -103,7 +107,7 @@ export function compileMigration(
103
107
  }
104
108
 
105
109
  // 2. Foreign keys — after every table exists.
106
- for (const model of models) sql.push(...foreignKeySql(model));
110
+ for (const model of models) sql.push(...foreignKeySql(model, dependents));
107
111
 
108
112
  // 2b. Standalone indexes — after the columns they cover exist. The ON clause is the
109
113
  // schema-qualified table, so a non-public index targets the right table.
@@ -169,7 +173,8 @@ export function compileModuleMigration(modules: Module[], opts: CompileTableOpti
169
173
  // objects — compiled in topological order, after every table. Greenfield = one
170
174
  // complete script: state + compute; from then on the layer deploys via db:reconcile.
171
175
  const models = modules.flatMap((m) => m.models);
172
- const derivedObjects = compileDerived(models, modules.flatMap((m) => m.derived));
176
+ // The lifecycle triggers are generated from the models, exactly as composeDeclaredDerived does.
177
+ const derivedObjects = compileDerived(models, [...modules.flatMap((m) => m.derived), ...lifecycleDerived(models)]);
173
178
 
174
179
  // 4a. A schema that ONLY derived objects live in has no model to create it, so phase 2
175
180
  // never does — and the first CREATE VIEW in it fails with `schema … does not exist`.
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
19
+ import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
19
20
  import type { SchemaSnapshot, ColumnSchema } from './schema-introspect';
20
21
  import type { AuthzContract, SequenceGrantContract } from './authz-contract';
21
22
  import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
@@ -80,7 +81,8 @@ function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): bool
80
81
  // DROP IDENTITY destroys the backing sequence and its counter — a removal, not a change.
81
82
  // ADD/SET GENERATED are not: they create or retune the auto-value and lose nothing.
82
83
  if (change.kind === 'setIdentity' && change.identity == null) return true;
83
- if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
84
+ // A replaced primary key's drop is paired with the declared key's add in the same edge.
85
+ if (change.kind === 'dropConstraint') return !change.replacesPrimaryKey && !replacedConstraints.has(`${change.table}::${change.name}`);
84
86
  return false;
85
87
  }
86
88
 
@@ -124,12 +126,30 @@ export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapsh
124
126
  * the database (auth, jobs, observability) is left to its own migrations.
125
127
  */
126
128
  export function generateMigrationSql(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
129
+ return generateMigration(models, current, opts).statements;
130
+ }
131
+
132
+ /**
133
+ * {@link generateMigrationSql}, with the structural change set the data phase was emitted from.
134
+ * For a caller that must decide on what a statement IS (db:plan refusing a change it can only
135
+ * hold as a notice) rather than read it back out of the SQL text.
136
+ */
137
+ export function generateMigration(
138
+ models: ModelDescriptor[],
139
+ current: SchemaSnapshot,
140
+ opts: GenerateOptions = {},
141
+ ): { statements: string[]; changes: SchemaChange[] } {
142
+ // An external FK target that this set models is an authoring error (use the model). linkModels
143
+ // checks it at load; checked again here because a caller can hand a set in without linking.
144
+ assertExternalTargets(models);
145
+ // hasMany `dependent` → the child FK's ON DELETE, derived from THIS set (and checked, for the same reason).
146
+ const dependents = relationDeleteActions(models);
127
147
  const schema = opts.schema ?? 'public';
128
148
  // A table's schema comes from the model first (package tables live in dist/ops/auth),
129
149
  // then the call default — so one run spans schemas.
130
150
  const schemaOf = (m: ModelDescriptor): string => m.schema ?? schema;
131
151
  const desiredEnums = compileEnums(models);
132
- const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
152
+ const desired: SchemaSnapshot = { tables: models.map((m) => compileTableSchema(m, { schema, dependents })), enums: desiredEnums, ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}) };
133
153
  const declaredTables = new Set(desired.tables.map((t) => t.table));
134
154
  const declaredEnums = new Set(desiredEnums.map((e) => e.name));
135
155
  // Scope both tables and enums to what the Models declare — an undeclared table OR enum
@@ -305,7 +325,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
305
325
  })
306
326
  : [];
307
327
 
308
- return [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
328
+ return { statements: [...extensionPhase, ...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase], changes };
309
329
  }
310
330
 
311
331
  /**