@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-compile.ts +48 -11
  4. package/src/cli/authz-contract.ts +6 -0
  5. package/src/cli/authz-derive.ts +3 -1
  6. package/src/cli/commands/db-apply.ts +34 -10
  7. package/src/cli/commands/db-backup.ts +62 -26
  8. package/src/cli/commands/db-fork.ts +2 -1
  9. package/src/cli/commands/db-plan.ts +5 -0
  10. package/src/cli/commands/db-pull.ts +48 -12
  11. package/src/cli/commands/db-reconcile.ts +34 -5
  12. package/src/cli/commands/db-sync.ts +32 -3
  13. package/src/cli/commands/db.ts +46 -21
  14. package/src/cli/db-build.ts +11 -1
  15. package/src/cli/declared-diff.ts +3 -2
  16. package/src/cli/derived-apply.ts +5 -1
  17. package/src/cli/derived-catchup.ts +4 -0
  18. package/src/cli/derived-compile.ts +2 -1
  19. package/src/cli/derived-introspect.ts +22 -1
  20. package/src/cli/derived-plan.ts +64 -0
  21. package/src/cli/derived-render.ts +1 -0
  22. package/src/cli/edge-plan.ts +35 -0
  23. package/src/cli/index-lock.ts +65 -0
  24. package/src/cli/index.ts +1 -1
  25. package/src/cli/lifecycle-canon.ts +2 -1
  26. package/src/cli/lifecycle-derived.ts +334 -45
  27. package/src/cli/lifecycle-preflight.ts +498 -0
  28. package/src/cli/lifecycle-pull.ts +119 -11
  29. package/src/cli/migration-compile.ts +4 -3
  30. package/src/cli/migration-generate.ts +3 -2
  31. package/src/cli/model-api.ts +9 -0
  32. package/src/cli/model-render.ts +80 -8
  33. package/src/cli/plain-output.ts +14 -1
  34. package/src/cli/schema-compile.ts +34 -67
  35. package/src/cli/schema-fingerprint.ts +3 -2
  36. package/src/cli/schema-introspect.ts +33 -2
  37. package/src/cli/state-function-deps.ts +2 -1
  38. package/src/cli/lifecycle-verify.ts +0 -232
@@ -53,7 +53,7 @@
53
53
  import { createHash } from 'node:crypto';
54
54
  import { canonicalAuthz } from './authz-canonical';
55
55
  import type { ModelDescriptor, SequenceDescriptor } from '@everystack/model';
56
- import { assertExternalTargets, relationDeleteActions } from '@everystack/model';
56
+ import { assertExternalTargets, lifecycleDependentIndexes, relationDeleteActions } from '@everystack/model';
57
57
  import type { EnumType, SchemaSnapshot, TableSchema } from './schema-introspect';
58
58
  import type { AuthzContract, TableContract, SequenceGrantContract } from './authz-contract';
59
59
  import { compileTableSchema, compileEnums, compileSequences } from './schema-compile';
@@ -498,9 +498,10 @@ export function fingerprintModels(
498
498
  assertExternalTargets(models);
499
499
  // Also over the whole set: a parent's hasMany `dependent` sets the ON DELETE of a child's FK.
500
500
  const dependents = relationDeleteActions(models);
501
+ const dependentIndexes = lifecycleDependentIndexes(models);
501
502
  const scoped = opts.schemas ? models.filter((m) => opts.schemas!.includes(m.schema)) : models;
502
503
  const snapshot: SchemaSnapshot = {
503
- tables: scoped.map((m) => compileTableSchema(m, { schema: opts.schema, dependents })),
504
+ tables: scoped.map((m) => compileTableSchema(m, { schema: opts.schema, dependents, dependentIndexes })),
504
505
  enums: compileEnums(scoped),
505
506
  ...(opts.sequences?.length ? { sequences: compileSequences(opts.sequences) } : {}),
506
507
  };
@@ -166,12 +166,35 @@ export interface SchemaSnapshot {
166
166
  sequences?: SequenceSchema[];
167
167
  /** Installed extensions, `plpgsql` excluded. Absent when the read predates this. */
168
168
  extensions?: string[];
169
+ /**
170
+ * The catalog's row estimate per table (`pg_class.reltuples`): > 0 an estimate, 0 empty, -1 a
171
+ * table with data that was never analyzed. For the plan's ⚠ LOCK line only. Beside the tables,
172
+ * never inside one, so no fingerprint or diff reads it. Absent when the read predates it.
173
+ */
174
+ rowEstimates?: Record<string, number>;
169
175
  }
170
176
 
171
177
  // ---------------------------------------------------------------------------
172
178
  // Introspection SQL.
173
179
  // ---------------------------------------------------------------------------
174
180
 
181
+ /**
182
+ * Row estimates for the plan's ⚠ LOCK line — catalog statistics, no table scan. reltuples is -1
183
+ * before the first VACUUM/ANALYZE; a table that has pages then is reported -1 (data, unknown count).
184
+ */
185
+ export const ROW_ESTIMATES_SQL = `
186
+ SELECT n.nspname AS schema, c.relname AS "table",
187
+ CASE WHEN c.reltuples > 0 THEN c.reltuples::bigint
188
+ WHEN pg_catalog.pg_relation_size(c.oid) > 0 THEN -1
189
+ ELSE 0 END AS estimate
190
+ FROM pg_class c
191
+ JOIN pg_namespace n ON n.oid = c.relnamespace
192
+ WHERE c.relkind = 'r'
193
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
194
+ AND n.nspname NOT LIKE 'pg_%'
195
+ ORDER BY 1, 2;
196
+ `.trim();
197
+
175
198
  /**
176
199
  * Every column of every base table, with its precise type (`format_type`),
177
200
  * nullability, and default. Extension-owned tables are excluded via pg_depend,
@@ -711,6 +734,8 @@ export interface SchemaRows {
711
734
  indexes?: IndexRow[];
712
735
  sequences?: SequenceRow[];
713
736
  extensions?: ExtensionRow[];
737
+ /** Absent on a read that predates ROW_ESTIMATES_SQL (an older ops Lambda reply). */
738
+ estimates?: Array<{ schema: string; table: string; estimate: unknown }>;
714
739
  }
715
740
 
716
741
  /**
@@ -804,6 +829,9 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
804
829
  enums,
805
830
  ...(sequences.length ? { sequences } : {}),
806
831
  ...(extensions !== undefined ? { extensions } : {}),
832
+ ...(rows.estimates !== undefined
833
+ ? { rowEstimates: Object.fromEntries(rows.estimates.map((r) => [`${r.schema}.${r.table}`, Number(r.estimate)])) }
834
+ : {}),
807
835
  };
808
836
  }
809
837
 
@@ -820,8 +848,10 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
820
848
  // search_path. Column defaults, CHECK constraints and index expressions all deparse
821
849
  // relative to that path — spread across separate connections these five rows can render
822
850
  // the same schema two ways, and a fingerprint minted from the mix describes nothing.
823
- const [columns, constraints, enums, indexes, sequences, extensions] = await session(
824
- [COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL],
851
+ // The row estimates ride the same session and snapshot; a reply without them (fewer result
852
+ // sets) assembles without `rowEstimates`, and the plan then warns without a count.
853
+ const [columns, constraints, enums, indexes, sequences, extensions, estimates] = await session(
854
+ [COLUMNS_SQL, CONSTRAINTS_SQL, ENUMS_SQL, INDEXES_SQL, SEQUENCES_SQL, EXTENSIONS_SQL, ROW_ESTIMATES_SQL],
825
855
  INTROSPECTION_SESSION,
826
856
  );
827
857
  return assembleSchema({
@@ -831,5 +861,6 @@ export async function introspectSchema(session: SessionRunner): Promise<SchemaSn
831
861
  indexes: indexes as IndexRow[],
832
862
  sequences: sequences as SequenceRow[],
833
863
  extensions: extensions as ExtensionRow[],
864
+ ...(Array.isArray(estimates) ? { estimates: estimates as SchemaRows['estimates'] } : {}),
834
865
  });
835
866
  }
@@ -158,7 +158,7 @@ export async function createStateReferencedFunctions(
158
158
  session: SessionRunner,
159
159
  statements: readonly string[],
160
160
  declared: readonly SourceObject[],
161
- options: { actor?: string | null; gitRef?: string | null; now?: () => number } = {},
161
+ options: { actor?: string | null; gitRef?: string | null; now?: () => number; lifecyclePreflighted?: boolean } = {},
162
162
  ): Promise<string[]> {
163
163
  const referenced = stateReferencedFunctions(statements, declared);
164
164
  if (referenced.length === 0) return [];
@@ -184,6 +184,7 @@ export async function createStateReferencedFunctions(
184
184
  actor: options.actor ?? null,
185
185
  gitRef: options.gitRef ?? null,
186
186
  now: options.now,
187
+ lifecyclePreflighted: options.lifecyclePreflighted,
187
188
  });
188
189
  if (run.refusal) {
189
190
  throw new Error(`could not create the functions the state batch calls (${missing.map((o) => o.identity).join(', ')}) before it: ${run.refusal}`);
@@ -1,232 +0,0 @@
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
- }