@everystack/cli 0.3.31 → 0.4.0

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.
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * derived-plan — the reconciler's pure decision core.
3
3
  *
4
- * Joins the three inputs on identity — source objects (db/sql), the live
4
+ * Joins the three inputs on identity — source objects (descriptor-compiled), the live
5
5
  * catalog, and the provenance claims — and decides, per object: skip, create,
6
- * replace, refresh, drop, baseline, or prune. Hand-edited live objects are
6
+ * replace, refresh, drop, baseline, rebaseline, or prune. Hand-edited live objects are
7
7
  * DRIFT: reported, never silently overwritten (`overwriteDrift` makes the
8
8
  * overwrite explicit, and the drift stays in the report so the operator sees
9
9
  * what they overwrote). Never-reconciled matches are `needsBaseline`: the
@@ -24,10 +24,12 @@
24
24
  * drift report, not a silent skip.
25
25
  */
26
26
 
27
- import type { ParsedSources, SourceObject, DerivedKind } from './derived-source.js';
27
+ import { type ParsedSources, type SourceObject, type DerivedKind } from './derived-source.js';
28
28
  import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
29
+ import { triggerDropSql } from './derived-apply.js';
30
+ import { parseGrantAttachments, diffObjectGrants } from './derived-grants.js';
29
31
 
30
- export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'prune';
32
+ export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune';
31
33
 
32
34
  export interface ReconcileAction {
33
35
  action: ReconcileVerb;
@@ -37,6 +39,9 @@ export interface ReconcileAction {
37
39
  /** Rebuild-cost signal from the live catalog (matviews). */
38
40
  bytes?: number;
39
41
  rows?: number;
42
+ /** Drops for the structural kinds — a trigger's `DROP TRIGGER … ON table`, a 'sql'
43
+ * object's recorded/declared drop. Legacy kinds keep the keyword rendering. */
44
+ dropSql?: string;
40
45
  }
41
46
 
42
47
  export interface DriftFinding {
@@ -44,6 +49,21 @@ export interface DriftFinding {
44
49
  detail: string;
45
50
  }
46
51
 
52
+ /** Grant drift on an up-to-date object — the idempotent REVOKE/GRANT delta that converges it. */
53
+ export interface RegrantFinding {
54
+ identity: string;
55
+ statements: string[];
56
+ }
57
+
58
+ /** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
59
+ export interface DependencyDriftFinding {
60
+ identity: string;
61
+ /** Live edges the source never declared — the reachability gate walked past these. */
62
+ missing: string[];
63
+ /** Declared refs with no live edge — stale declarations. */
64
+ stale: string[];
65
+ }
66
+
47
67
  export interface BlockedFinding {
48
68
  identity: string;
49
69
  reason: string;
@@ -66,6 +86,15 @@ export interface ReconcilePlan {
66
86
  warnings: string[];
67
87
  /** Sum of live bytes over rebuilt + refreshed matviews — the headline cost estimate. */
68
88
  totalRebuildBytes: number;
89
+ /** Provenance identity migrations — a table rename carried its triggers (same object,
90
+ * new identity); the bookkeeping follows instead of drop+create-ing a live trigger. */
91
+ migrations: Array<{ from: string; to: string }>;
92
+ /** Grant drift on up-to-date objects: a hand-run GRANT/REVOKE touches no content hash,
93
+ * so this is the only place it surfaces. Applied without ceremony — declared authz is
94
+ * authoritative, and REVOKE/GRANT loses nothing but the drift itself. */
95
+ regrants: RegrantFinding[];
96
+ /** Declared-vs-actual dependency drift — check-fails (fix the declaration), never blocks apply. */
97
+ dependencyDrift: DependencyDriftFinding[];
69
98
  }
70
99
 
71
100
  export interface ReconcileOptions {
@@ -75,9 +104,28 @@ export interface ReconcileOptions {
75
104
  overwriteDrift?: boolean;
76
105
  /**
77
106
  * Rebuild never-reconciled source↔live matches from source (drop+create) instead of trusting
78
- * them via baseline — the guarantee that live == db/sql when first contact can't verify it.
107
+ * them via baseline — the guarantee that live == source when first contact can't verify it.
79
108
  */
80
109
  rebuild?: boolean;
110
+ /**
111
+ * Re-record provenance for objects whose SOURCE hash changed while the LIVE object is
112
+ * verifiably untouched (live defHash == provenance defHash) — bookkeeping instead of a
113
+ * rebuild. The migration verb for a source re-render that produces the same object:
114
+ * the db/sql-era → descriptor move re-renders every object in the compiler's dialect
115
+ * (grant attachments on functions, pinned dollar-tags, plain CREATE), so the source
116
+ * hash changes even when the SQL is semantically identical. The live side is VERIFIED
117
+ * (defHash proves the object is exactly what the reconciler last applied); the source
118
+ * side is TRUSTED (a descriptor that actually differs will never deploy — its change
119
+ * is recorded as applied). Catalog-invisible 'sql'-kind objects are excluded: with no
120
+ * defHash to verify, they keep their recorded-drop rebuild.
121
+ */
122
+ rebaseline?: boolean;
123
+ /**
124
+ * Pending table renames (new qualified table → old qualified table, from the models'
125
+ * `renamedFrom` markers). PostgreSQL carries triggers through ALTER TABLE RENAME, so
126
+ * their provenance identity migrates with them.
127
+ */
128
+ renamedTables?: Record<string, string>;
81
129
  }
82
130
 
83
131
  const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
@@ -91,6 +139,23 @@ export function planReconcile(
91
139
  const liveById = new Map(live.objects.map((o) => [o.identity, o]));
92
140
  const provById = new Map(live.provenance.map((p) => [p.identity, p]));
93
141
 
142
+ // Rename pre-pass: a pending table rename (renamedFrom) carried its triggers along in
143
+ // PostgreSQL, so a provenance row recorded under the OLD table is the SAME object as
144
+ // the source trigger under the NEW one — the bookkeeping migrates; nothing drops.
145
+ const migrations: Array<{ from: string; to: string }> = [];
146
+ for (const [newTable, oldTable] of Object.entries(options.renamedTables ?? {})) {
147
+ for (const prov of [...provById.values()]) {
148
+ if (prov.kind !== 'trigger' || !prov.identity.startsWith(`${oldTable}.`)) continue;
149
+ const to = `${newTable}.${prov.identity.slice(oldTable.length + 1)}`;
150
+ if (!srcById.has(to) || provById.has(to)) continue;
151
+ provById.delete(prov.identity);
152
+ provById.set(to, { ...prov, identity: to });
153
+ migrations.push({ from: prov.identity, to });
154
+ }
155
+ }
156
+ // Every provenance consumer below reads the POST-MIGRATION view.
157
+ const provRows = [...provById.values()];
158
+
94
159
  const skipped: string[] = [];
95
160
  const drift: DriftFinding[] = [];
96
161
  const needsBaseline: string[] = [];
@@ -106,16 +171,37 @@ export function planReconcile(
106
171
  /** Managed objects the source removed. */
107
172
  const drop = new Map<string, string>();
108
173
  const baseline: string[] = [];
174
+ const rebaseline: string[] = [];
109
175
  const prune: string[] = [];
110
176
 
111
177
  // -------------------------------------------------------------------------
112
178
  // The decision table.
113
179
  // -------------------------------------------------------------------------
114
180
 
181
+ /** identity → { statement, reason } for the provenance-tracked ('sql') kinds — these
182
+ * never pass the live-existence filter, so they assemble their own drop actions. */
183
+ const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
184
+ const extraWarnings: string[] = [];
185
+
115
186
  for (const src of source.objects) {
116
187
  const liveObj = liveById.get(src.identity);
117
188
  const prov = provById.get(src.identity);
118
189
 
190
+ // 'sql' objects (aggregates, domains, …) never join the live catalog — PROVENANCE is
191
+ // the live proxy. The documented contract of the escape hatch: provenance-tracked,
192
+ // no live drift detection; a change rebuilds through the recorded drop.
193
+ if (src.kind === 'sql') {
194
+ if (!prov) {
195
+ create.set(src.identity, 'new in source (provenance-tracked kind — invisible to the catalog)');
196
+ } else if (src.hash !== prov.srcHash) {
197
+ sqlDrops.set(src.identity, { dropSql: prov.dropSql ?? src.drop ?? '', reason: 'source changed' });
198
+ create.set(src.identity, 'source changed (rebuilt through the recorded drop)');
199
+ } else {
200
+ skipped.push(src.identity);
201
+ }
202
+ continue;
203
+ }
204
+
119
205
  if (!liveObj) {
120
206
  create.set(src.identity, prov
121
207
  ? 'missing from the database (recorded as applied — dropped by hand?)'
@@ -155,7 +241,11 @@ export function planReconcile(
155
241
  continue;
156
242
  }
157
243
  if (srcChanged) {
158
- if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
244
+ // The live object is verifiably untouched here (liveChanged was handled above), so
245
+ // under rebaseline a source re-render is bookkeeping: re-record the source hash,
246
+ // rebuild nothing, cascade nothing.
247
+ if (options.rebaseline) rebaseline.push(src.identity);
248
+ else if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
159
249
  else fnReplace.set(src.identity, 'source changed');
160
250
  continue;
161
251
  }
@@ -168,8 +258,68 @@ export function planReconcile(
168
258
  else unmanaged.push(liveObj.identity);
169
259
  }
170
260
 
171
- for (const prov of live.provenance) {
172
- if (!srcById.has(prov.identity) && !liveById.has(prov.identity)) prune.push(prov.identity);
261
+ for (const prov of provRows) {
262
+ if (srcById.has(prov.identity) || liveById.has(prov.identity)) continue;
263
+ // A removed 'sql' object is invisible to the catalog — pruning would silently orphan
264
+ // it in the database. The recorded drop_sql removes it (the C1 closure); only a
265
+ // legacy row with no recorded drop falls back to prune, and says so.
266
+ if (prov.kind === 'sql') {
267
+ if (prov.dropSql) {
268
+ sqlDrops.set(prov.identity, { dropSql: prov.dropSql, reason: 'removed from source (dropped via the recorded drop_sql)' });
269
+ } else {
270
+ prune.push(prov.identity);
271
+ extraWarnings.push(`${prov.identity}: a 'sql'-kind object recorded without drop_sql (legacy row) — bookkeeping pruned; drop the object by hand if it still exists.`);
272
+ }
273
+ continue;
274
+ }
275
+ prune.push(prov.identity);
276
+ }
277
+
278
+ // -------------------------------------------------------------------------
279
+ // B4 — live drift the content hashes can't see, over the UP-TO-DATE objects
280
+ // only: created/replaced objects re-run their attachments, drifted objects
281
+ // are owned by the drift refusal.
282
+ // -------------------------------------------------------------------------
283
+
284
+ const regrants: RegrantFinding[] = [];
285
+ const dependencyDrift: DependencyDriftFinding[] = [];
286
+ // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
287
+ // and running the checks NOW means a migration converges (grants ride the same apply)
288
+ // instead of surfacing on the next plan.
289
+ const lensSet = new Set([...skipped, ...rebaseline]);
290
+ for (const src of source.objects) {
291
+ if (!lensSet.has(src.identity)) continue;
292
+ if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
293
+ const liveObj = liveById.get(src.identity);
294
+
295
+ // Grant drift — only when the catalog actually reported ACLs (absent ≠ empty).
296
+ // Every object is descriptor-compiled (db/sql retired, B7), and the compiler
297
+ // ALWAYS declares authz — a private relation is declared-empty, not undeclared —
298
+ // so every up-to-date object is grant-checked.
299
+ if (liveObj?.grants !== undefined) {
300
+ const parsed = parseGrantAttachments(src.attachments);
301
+ if (parsed.hasColumnGrants) {
302
+ extraWarnings.push(
303
+ `${src.identity}: column-scoped grants are applied at create but not drift-checked (attacl introspection is future work) — hand edits to them are invisible here.`,
304
+ );
305
+ } else {
306
+ const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
307
+ const statements = diffObjectGrants(target, parsed.grants, liveObj.grants);
308
+ if (statements.length > 0) regrants.push({ identity: src.identity, statements });
309
+ }
310
+ }
311
+
312
+ // Dependency drift — declared, then verified. Only descriptor-home relations carry a
313
+ // declaration, and only relations have rewrite-rule edges for the catalog to answer with.
314
+ if (src.declaredDeps !== undefined && (src.kind === 'view' || src.kind === 'materialized view')) {
315
+ const liveDeps = new Set(live.edges.filter((e) => e.dependent === src.identity).map((e) => e.referenced));
316
+ const declaredSet = new Set(src.declaredDeps);
317
+ const missing = [...liveDeps].filter((d) => !declaredSet.has(d)).sort();
318
+ const stale = [...declaredSet].filter((d) => !liveDeps.has(d)).sort();
319
+ if (missing.length > 0 || stale.length > 0) {
320
+ dependencyDrift.push({ identity: src.identity, missing, stale });
321
+ }
322
+ }
173
323
  }
174
324
 
175
325
  // -------------------------------------------------------------------------
@@ -260,11 +410,23 @@ export function planReconcile(
260
410
  const cost = liveObj?.kind === 'materialized view'
261
411
  ? { ...(liveObj.bytes !== undefined ? { bytes: liveObj.bytes } : {}), ...(liveObj.rows !== undefined ? { rows: liveObj.rows } : {}) }
262
412
  : {};
263
- return { action, identity: id, kind, reason, ...cost };
413
+ // A trigger's drop needs its table — composed from structure (source or live), never
414
+ // from the identity string (the red-team's C4: quoting a compound identity mangles it).
415
+ const structural = action === 'drop' && kind === 'trigger'
416
+ ? (() => {
417
+ const o = src ?? liveObj;
418
+ return o?.table ? { dropSql: triggerDropSql(o.name, o.table) } : {};
419
+ })()
420
+ : {};
421
+ return { action, identity: id, kind, reason, ...cost, ...structural };
264
422
  };
265
423
 
266
424
  const actions: ReconcileAction[] = [
267
425
  ...orderDrops(dropSet).map((id) => actionFor(id, 'drop', rebuild.get(id) ?? drop.get(id) ?? '')),
426
+ // 'sql'-kind drops (rebuilds + removals) — invisible to the live catalog, so they
427
+ // carry their own statement; ordered with the other drops, before every create.
428
+ ...[...sqlDrops.entries()].sort((a, b) => a[0].localeCompare(b[0]))
429
+ .map(([id, d]) => ({ action: 'drop' as const, identity: id, kind: 'sql' as DerivedKind, reason: d.reason, dropSql: d.dropSql })),
268
430
  ...[...fnReplace.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
269
431
  .map(([id, reason]) => actionFor(id, 'replace', reason)),
270
432
  ...createSet.sort((a, b) => seqOf(a) - seqOf(b))
@@ -272,6 +434,7 @@ export function planReconcile(
272
434
  ...[...refresh.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
273
435
  .map(([id, reason]) => actionFor(id, 'refresh', reason)),
274
436
  ...baseline.sort().map((id) => actionFor(id, 'baseline', 'recording provenance for an existing match (trusted once, explicitly)')),
437
+ ...rebaseline.sort().map((id) => actionFor(id, 'rebaseline', 'source re-rendered — live verified untouched (defHash match); provenance re-recorded, no rebuild')),
275
438
  ...prune.sort().map((id) => ({
276
439
  action: 'prune' as const, identity: id, kind: 'view' as DerivedKind,
277
440
  reason: 'stale provenance — object gone from both source and database',
@@ -289,7 +452,10 @@ export function planReconcile(
289
452
  needsBaseline: needsBaseline.sort(),
290
453
  unmanaged: unmanaged.sort(),
291
454
  blocked,
292
- warnings: [...source.warnings],
455
+ warnings: [...source.warnings, ...extraWarnings],
293
456
  totalRebuildBytes,
457
+ migrations,
458
+ regrants: regrants.sort((a, b) => a.identity.localeCompare(b.identity)),
459
+ dependencyDrift: dependencyDrift.sort((a, b) => a.identity.localeCompare(b.identity)),
294
460
  };
295
461
  }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * derived-render — db:pull's derived-layer renderer (B5, read-model-everywhere).
3
+ *
4
+ * Live catalog → descriptor source. Bodies are the catalog's canonical deparse — that is
5
+ * EXPECTED (a pulled body never matches an authored spelling byte-for-byte); the
6
+ * adoption path is the existing `--baseline`: pull, commit, baseline once, and the next
7
+ * reconcile is a no-op. Grants render to `abilities` where the standard shapes match;
8
+ * `dependsOn` renders from the live edge graph, so pulled declarations are born accurate
9
+ * (B4's dependency drift and B2's reachability gate get real inputs from day one);
10
+ * matview indexes ride Brick E's parser; standalone sequences become `defineSequence`;
11
+ * a function the v1 signature vocabulary can't say falls back to `defineSql` carrying
12
+ * the whole `pg_get_functiondef`. Anything inexpressible is a LOUD FIXME — the adoption
13
+ * checklist effect, on purpose — never a silent drop.
14
+ */
15
+
16
+ import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
17
+ import type { SequenceSchema } from './schema-introspect.js';
18
+ import { parseIndexDefinition } from './schema-introspect.js';
19
+
20
+ export interface DerivedRenderResult {
21
+ /** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
22
+ block: string;
23
+ /** Rendered derived variable names, emission order — feed `defineModule({ derived })`. */
24
+ names: string[];
25
+ /** Rendered sequence variable names — feed `defineModule({ sequences })`. */
26
+ sequenceNames: string[];
27
+ /** Model-package symbols the block uses (defineView, can, sql, arg, index, …). */
28
+ imports: string[];
29
+ /** Everything skipped or flagged — mirrored as FIXME comments in the block. */
30
+ warnings: string[];
31
+ }
32
+
33
+ export interface DerivedRenderOptions {
34
+ /** Qualified table (`public.posts`) → the model variable name rendered in the barrel. */
35
+ knownTables: Map<string, string>;
36
+ /** Standalone sequences from the snapshot (serial-owned stay implicit). */
37
+ sequences?: SequenceSchema[];
38
+ }
39
+
40
+ const PLAIN_IDENT = /^[a-z_][a-z0-9_$]*$/;
41
+
42
+ function toCamelCase(name: string): string {
43
+ return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
44
+ }
45
+
46
+ /** Escape a body for a template literal in generated source — it must always parse. */
47
+ function tsTemplate(body: string): string {
48
+ return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
49
+ }
50
+
51
+ function tsString(s: string): string {
52
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
53
+ }
54
+
55
+ /** The relation grant shapes `abilities` can say. Null = inexpressible (caller FIXMEs). */
56
+ function relationAbilities(grants: Record<string, string[]>): string[] | null {
57
+ const roles = Object.keys(grants);
58
+ for (const role of roles) {
59
+ if (role.toUpperCase() === 'PUBLIC') return null;
60
+ if (grants[role].some((p) => p !== 'SELECT')) return null;
61
+ }
62
+ const set = new Set(roles);
63
+ const out: string[] = [];
64
+ if (set.has('anon') && set.has('authenticated')) {
65
+ out.push(`can('read')`);
66
+ set.delete('anon');
67
+ set.delete('authenticated');
68
+ }
69
+ for (const role of [...set].sort()) out.push(`can('read', { role: ${tsString(role)} })`);
70
+ return out;
71
+ }
72
+
73
+ /** VOLATILITY letters → the descriptor words ('v' is the default, never spelled). */
74
+ const VOLATILITY: Record<string, string> = { i: 'immutable', s: 'stable' };
75
+
76
+ interface ParsedArg {
77
+ name: string;
78
+ type: string;
79
+ default?: string;
80
+ }
81
+
82
+ /** Parse `pg_get_function_arguments` output. Null = beyond the v1 vocabulary. */
83
+ function parseArgs(args: string): ParsedArg[] | null {
84
+ const trimmed = args.trim();
85
+ if (!trimmed) return [];
86
+ const out: ParsedArg[] = [];
87
+ let depth = 0;
88
+ let start = 0;
89
+ const entries: string[] = [];
90
+ for (let i = 0; i < trimmed.length; i++) {
91
+ if (trimmed[i] === '(') depth++;
92
+ else if (trimmed[i] === ')') depth--;
93
+ else if (trimmed[i] === ',' && depth === 0) {
94
+ entries.push(trimmed.slice(start, i));
95
+ start = i + 1;
96
+ }
97
+ }
98
+ entries.push(trimmed.slice(start));
99
+ for (const entry of entries.map((e) => e.trim())) {
100
+ if (/^(OUT|INOUT|VARIADIC)\b/i.test(entry)) return null;
101
+ const m = entry.match(/^([a-z_][a-z0-9_$]*)\s+(.+?)(?:\s+DEFAULT\s+(.+))?$/i);
102
+ if (!m || !m[2]) return null;
103
+ // A single token is an UNNAMED arg ('text') — the match would misread type as name.
104
+ if (!/\s/.test(entry.replace(/\s+DEFAULT\s+.+$/i, '')) ) return null;
105
+ out.push({ name: m[1], type: m[2], ...(m[3] ? { default: m[3] } : {}) });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** One matview index attachment → the Brick E builder chain, or null (FIXME). */
111
+ function renderIndexBuilder(indexdef: string): string | null {
112
+ const parsed = parseIndexDefinition(indexdef);
113
+ if (!parsed) return null;
114
+ const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(c) : `sql\`${tsTemplate(c)}\``);
115
+ let s = `index(${parsed.columns.map(entry).join(', ')})`;
116
+ if (parsed.using) s += `.using(${tsString(parsed.using)})`;
117
+ if (parsed.unique) s += '.unique()';
118
+ if (parsed.include?.length) s += `.include(${parsed.include.map(tsString).join(', ')})`;
119
+ if (parsed.where) s += `.where(sql\`${tsTemplate(parsed.where)}\`)`;
120
+ return s;
121
+ }
122
+
123
+ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRenderOptions): DerivedRenderResult {
124
+ const warnings: string[] = [];
125
+ const lines: string[] = [];
126
+ const names: string[] = [];
127
+ const used = new Set<string>();
128
+ const need = (sym: string): void => { used.add(sym); };
129
+
130
+ // -- sequences (state layer — they precede the compute in the module) -------
131
+
132
+ const sequenceNames: string[] = [];
133
+ for (const seq of opts.sequences ?? []) {
134
+ const varName = toCamelCase(seq.name.replace(/\./g, '_'));
135
+ const props: string[] = [];
136
+ if (seq.as !== 'bigint') props.push(`as: ${tsString(seq.as)}`);
137
+ if (seq.start != null) props.push(`start: ${seq.start}`);
138
+ if (seq.increment != null) props.push(`increment: ${seq.increment}`);
139
+ lines.push(`export const ${varName} = defineSequence(${tsString(seq.name)}${props.length ? `, { ${props.join(', ')} }` : ''});`);
140
+ need('defineSequence');
141
+ sequenceNames.push(varName);
142
+ }
143
+ if (sequenceNames.length) lines.push('');
144
+
145
+ // -- emission order: dependencies first (Kahn over derived→derived edges) ---
146
+
147
+ const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
148
+ for (const t of catalog.objects.filter((o) => o.kind === 'trigger')) {
149
+ warnings.push(`${t.identity}: live trigger not rendered — declare it on its model with trigger() (v1 renders relations, functions, and sequences).`);
150
+ }
151
+ const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
152
+ // Var names carry the schema for non-public objects — two schemas can share a bare name.
153
+ const varOf = new Map(renderable.map((o) =>
154
+ [o.identity, toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`)]));
155
+ // The name a descriptor DECLARES: bare for public, qualified otherwise. parseQualified
156
+ // round-trips it, so the compiled identity matches the live object it was pulled from —
157
+ // a bare name would compile to public.<name>, baseline would never join, and reconcile
158
+ // would plan duplicate creates in public (the red-team's pull-qualification finding).
159
+ const declaredName = (o: LiveObject): string => (o.schema === 'public' ? o.name : o.identity);
160
+
161
+ const indegree = new Map<string, number>();
162
+ const dependents = new Map<string, string[]>();
163
+ for (const o of renderable) indegree.set(o.identity, 0);
164
+ for (const e of catalog.edges) {
165
+ if (!byIdentity.has(e.dependent) || !byIdentity.has(e.referenced)) continue;
166
+ indegree.set(e.dependent, (indegree.get(e.dependent) ?? 0) + 1);
167
+ dependents.set(e.referenced, [...(dependents.get(e.referenced) ?? []), e.dependent]);
168
+ }
169
+ const ready = renderable.filter((o) => indegree.get(o.identity) === 0).map((o) => o.identity).sort();
170
+ const ordered: LiveObject[] = [];
171
+ while (ready.length) {
172
+ const id = ready.shift()!;
173
+ ordered.push(byIdentity.get(id)!);
174
+ for (const dep of dependents.get(id) ?? []) {
175
+ const left = (indegree.get(dep) ?? 0) - 1;
176
+ indegree.set(dep, left);
177
+ if (left === 0) {
178
+ ready.push(dep);
179
+ ready.sort();
180
+ }
181
+ }
182
+ }
183
+ // A cycle is impossible in a live catalog (PostgreSQL refuses it); anything left over
184
+ // would mean edge noise — emit it anyway, identity-sorted, rather than dropping it.
185
+ for (const o of renderable) if (!ordered.includes(o)) ordered.push(o);
186
+
187
+ // -- per-object rendering ----------------------------------------------------
188
+
189
+ const depsFor = (identity: string): { refs: string[]; fixmes: string[] } => {
190
+ const refs: string[] = [];
191
+ const fixmes: string[] = [];
192
+ const seen = new Set<string>();
193
+ for (const e of catalog.edges) {
194
+ if (e.dependent !== identity || seen.has(e.referenced)) continue;
195
+ seen.add(e.referenced);
196
+ const model = opts.knownTables.get(e.referenced);
197
+ const derived = varOf.get(e.referenced);
198
+ if (model) refs.push(model);
199
+ else if (derived) refs.push(derived);
200
+ else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
201
+ }
202
+ return { refs: refs.sort(), fixmes };
203
+ };
204
+
205
+ for (const o of ordered) {
206
+ const varName = varOf.get(o.identity)!;
207
+ const { refs, fixmes } = depsFor(o.identity);
208
+
209
+ if (o.kind === 'view' || o.kind === 'materialized view') {
210
+ const abilities = relationAbilities(o.grants ?? {});
211
+ if (abilities === null) {
212
+ const grantText = Object.entries(o.grants ?? {}).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
213
+ warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.`);
214
+ lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
215
+ continue;
216
+ }
217
+ const props: string[] = [];
218
+ if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
219
+ if (abilities.length === 0) props.push('private: true,');
220
+ else {
221
+ props.push(`abilities: [${abilities.join(', ')}],`);
222
+ need('can');
223
+ }
224
+ if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
225
+ if (o.kind === 'materialized view') {
226
+ const builders = o.indexes.map((def) => {
227
+ const b = renderIndexBuilder(def);
228
+ if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
229
+ return b;
230
+ }).filter((b): b is string => b !== null);
231
+ if (builders.length) {
232
+ props.push(`indexes: [${builders.join(', ')}],`);
233
+ need('index');
234
+ }
235
+ if (o.populated === false) props.push(`populate: 'deferred',`);
236
+ }
237
+ if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
238
+ props.push(`as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`);
239
+ need(o.kind === 'view' ? 'defineView' : 'defineMaterializedView');
240
+ need('sql');
241
+ lines.push(
242
+ ...fixmes,
243
+ `export const ${varName} = ${o.kind === 'view' ? 'defineView' : 'defineMaterializedView'}(${tsString(declaredName(o))}, {`,
244
+ ...props.map((p) => ` ${p}`),
245
+ '});',
246
+ '',
247
+ );
248
+ names.push(varName);
249
+ continue;
250
+ }
251
+
252
+ // Functions.
253
+ const f = o.fn;
254
+ const grants = o.grants ?? {};
255
+ const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
256
+ const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
257
+ const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
258
+
259
+ if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
260
+ warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
261
+ lines.push(
262
+ `// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
263
+ `export const ${varName} = defineSql(${tsString(declaredName(o))}, {`,
264
+ ` kind: 'function',`,
265
+ ` drop: sql\`DROP FUNCTION IF EXISTS ${o.identity.startsWith('public.') ? o.name : o.identity}\`,`,
266
+ ` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
267
+ '});',
268
+ '',
269
+ );
270
+ need('defineSql');
271
+ need('sql');
272
+ names.push(varName);
273
+ continue;
274
+ }
275
+
276
+ const props: string[] = [];
277
+ if (args.length) {
278
+ const rendered = args.map((a) =>
279
+ `arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${tsTemplate(a.default)}\` }` : ''})`);
280
+ props.push(`args: [${rendered.join(', ')}],`);
281
+ need('arg');
282
+ }
283
+ props.push(`returns: ${tsString(f.returns)},`);
284
+ props.push(`language: ${tsString(f.language)},`);
285
+ if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
286
+ if (f.secdef) {
287
+ props.push(`security: 'definer',`);
288
+ const path = (f.searchPath ?? 'pg_catalog').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
289
+ props.push(`searchPath: [${path.map(tsString).join(', ')}],`);
290
+ }
291
+ if (roleGrants.length) {
292
+ props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
293
+ need('can');
294
+ }
295
+ if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
296
+ if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
297
+ props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
298
+ need('defineFunction');
299
+ need('sql');
300
+ lines.push(
301
+ ...fixmes,
302
+ ...(publicExec
303
+ ? [`// FIXME: ${o.identity} — live grants EXECUTE to PUBLIC; descriptors ALWAYS revoke PUBLIC. Declare the intended callers (adopting this descriptor applies the revoke).`]
304
+ : []),
305
+ `export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
306
+ ...props.map((p) => ` ${p}`),
307
+ '});',
308
+ '',
309
+ );
310
+ names.push(varName);
311
+ }
312
+
313
+ return {
314
+ block: lines.join('\n').trimEnd(),
315
+ names,
316
+ sequenceNames,
317
+ imports: [...used].sort(),
318
+ warnings,
319
+ };
320
+ }