@everystack/cli 0.3.13 → 0.3.15

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.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * derived-introspect — the live-catalog side of the derived-object reconciler.
3
+ *
4
+ * Reads what the database actually has for the compute layer: functions,
5
+ * views, and materialized views, each with its canonical definition
6
+ * (`pg_get_functiondef` / `pg_get_viewdef`), its indexes (matviews), its
7
+ * comment, and its size — plus the dependency edges between derived objects
8
+ * (from pg_rewrite/pg_depend) and the reconciler's provenance claims.
9
+ *
10
+ * The live definition hash (`defHash`) is computed over the CATALOG's
11
+ * canonical deparse, which never matches the authored source text — Postgres
12
+ * reformats. It is only ever compared against the def hash the reconciler
13
+ * recorded at apply time; source hashes and def hashes live in different
14
+ * universes and never meet. See derived-plan.ts for the decision table.
15
+ *
16
+ * Same contract as schema-introspect: SQL runs through an injected
17
+ * QueryRunner; every mapper is pure and unit-tested without a database.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto';
21
+ import { IGNORED_SCHEMAS, type QueryRunner } from './authz-contract.js';
22
+ import { normalizeSql, type DerivedKind } from './derived-source.js';
23
+
24
+ export interface LiveObject {
25
+ kind: DerivedKind;
26
+ schema: string;
27
+ name: string;
28
+ /** `schema.name` — join key against source objects and provenance. */
29
+ identity: string;
30
+ /** Canonical definition as the catalog deparses it. */
31
+ definition: string;
32
+ /** `pg_get_indexdef` for each index on a matview, sorted for stability. */
33
+ indexes: string[];
34
+ /** Object comment, when present (part of the def hash — a dropped comment is drift). */
35
+ comment?: string;
36
+ /** sha256 over normalized definition + indexes + comment. */
37
+ defHash: string;
38
+ /** Matview only: whether it holds data. */
39
+ populated?: boolean;
40
+ /** Matview only: pg_total_relation_size — the rebuild-cost signal. */
41
+ bytes?: number;
42
+ /** Matview only: reltuples estimate. */
43
+ rows?: number;
44
+ }
45
+
46
+ /** dependent identity → referenced identity (both derived objects). */
47
+ export interface DependencyEdge {
48
+ dependent: string;
49
+ referenced: string;
50
+ }
51
+
52
+ /** What the reconciler recorded the last time it touched this object. */
53
+ export interface ProvenanceRow {
54
+ identity: string;
55
+ srcHash: string;
56
+ defHash: string;
57
+ }
58
+
59
+ export interface DerivedCatalog {
60
+ objects: LiveObject[];
61
+ edges: DependencyEdge[];
62
+ provenance: ProvenanceRow[];
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Introspection SQL.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /** Views and matviews with canonical definitions, size, and comment. */
70
+ export const DERIVED_RELATIONS_SQL = `
71
+ SELECT
72
+ n.nspname AS schema,
73
+ c.relname AS name,
74
+ c.relkind AS kind,
75
+ pg_get_viewdef(c.oid) AS definition,
76
+ obj_description(c.oid, 'pg_class') AS comment,
77
+ c.relispopulated AS populated,
78
+ pg_total_relation_size(c.oid) AS bytes,
79
+ c.reltuples::bigint AS rows
80
+ FROM pg_class c
81
+ JOIN pg_namespace n ON n.oid = c.relnamespace
82
+ WHERE c.relkind IN ('v', 'm')
83
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
84
+ AND n.nspname NOT LIKE 'pg_%'
85
+ AND NOT EXISTS (
86
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
87
+ )
88
+ ORDER BY n.nspname, c.relname;
89
+ `.trim();
90
+
91
+ /** Plain functions and procedures with canonical definitions and comment. */
92
+ export const DERIVED_FUNCTIONS_SQL = `
93
+ SELECT
94
+ n.nspname AS schema,
95
+ p.proname AS name,
96
+ pg_get_functiondef(p.oid) AS definition,
97
+ obj_description(p.oid, 'pg_proc') AS comment
98
+ FROM pg_proc p
99
+ JOIN pg_namespace n ON n.oid = p.pronamespace
100
+ WHERE p.prokind IN ('f', 'p')
101
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
102
+ AND n.nspname NOT LIKE 'pg_%'
103
+ AND NOT EXISTS (
104
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
105
+ )
106
+ ORDER BY n.nspname, p.proname;
107
+ `.trim();
108
+
109
+ /** Every index on a materialized view, as its canonical CREATE INDEX text. */
110
+ export const MATVIEW_INDEXES_SQL = `
111
+ SELECT
112
+ n.nspname AS schema,
113
+ t.relname AS name,
114
+ pg_get_indexdef(idx.indexrelid) AS indexdef
115
+ FROM pg_index idx
116
+ JOIN pg_class t ON t.oid = idx.indrelid
117
+ JOIN pg_namespace n ON n.oid = t.relnamespace
118
+ WHERE t.relkind = 'm'
119
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
120
+ AND n.nspname NOT LIKE 'pg_%'
121
+ ORDER BY n.nspname, t.relname, idx.indexrelid;
122
+ `.trim();
123
+
124
+ /**
125
+ * Dependency edges between derived objects, through each view/matview's
126
+ * rewrite rule: view → view/matview it selects from, and view → function it
127
+ * calls. (Function → function edges do not exist in pg_depend — function
128
+ * bodies are opaque to it; source order is the fallback there.)
129
+ */
130
+ export const DERIVED_DEPENDS_SQL = `
131
+ SELECT DISTINCT
132
+ dn.nspname AS dependent_schema,
133
+ dc.relname AS dependent_name,
134
+ rn.nspname AS referenced_schema,
135
+ rc.relname AS referenced_name
136
+ FROM pg_depend d
137
+ JOIN pg_rewrite rw ON rw.oid = d.objid
138
+ JOIN pg_class dc ON dc.oid = rw.ev_class
139
+ JOIN pg_namespace dn ON dn.oid = dc.relnamespace
140
+ JOIN pg_class rc ON rc.oid = d.refobjid
141
+ JOIN pg_namespace rn ON rn.oid = rc.relnamespace
142
+ WHERE d.classid = 'pg_rewrite'::regclass
143
+ AND d.refclassid = 'pg_class'::regclass
144
+ AND dc.oid <> rc.oid
145
+ AND dc.relkind IN ('v', 'm')
146
+ AND rc.relkind IN ('v', 'm')
147
+ UNION
148
+ SELECT DISTINCT
149
+ dn.nspname, dc.relname, rn.nspname, rp.proname
150
+ FROM pg_depend d
151
+ JOIN pg_rewrite rw ON rw.oid = d.objid
152
+ JOIN pg_class dc ON dc.oid = rw.ev_class
153
+ JOIN pg_namespace dn ON dn.oid = dc.relnamespace
154
+ JOIN pg_proc rp ON rp.oid = d.refobjid
155
+ JOIN pg_namespace rn ON rn.oid = rp.pronamespace
156
+ WHERE d.classid = 'pg_rewrite'::regclass
157
+ AND d.refclassid = 'pg_proc'::regclass
158
+ AND dc.relkind IN ('v', 'm');
159
+ `.trim();
160
+
161
+ /** The reconciler's provenance claims. The table may not exist yet — callers tolerate that. */
162
+ export const PROVENANCE_SQL = `
163
+ SELECT identity, src_hash, def_hash FROM everystack.derived_provenance;
164
+ `.trim();
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Pure mappers.
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export interface RelationRow {
171
+ schema: string;
172
+ name: string;
173
+ kind: unknown;
174
+ definition: unknown;
175
+ comment: unknown;
176
+ populated: unknown;
177
+ bytes: unknown;
178
+ rows: unknown;
179
+ }
180
+
181
+ export interface FunctionRow {
182
+ schema: string;
183
+ name: string;
184
+ definition: unknown;
185
+ comment: unknown;
186
+ }
187
+
188
+ export interface IndexDefRow {
189
+ schema: string;
190
+ name: string;
191
+ indexdef: unknown;
192
+ }
193
+
194
+ export interface DependsRow {
195
+ dependent_schema: string;
196
+ dependent_name: string;
197
+ referenced_schema: string;
198
+ referenced_name: string;
199
+ }
200
+
201
+ export interface ProvenanceRawRow {
202
+ identity: string;
203
+ src_hash: unknown;
204
+ def_hash: unknown;
205
+ }
206
+
207
+ /** sha256 over an object's normalized definition + sorted indexes + comment. */
208
+ export function computeDefHash(definition: string, indexes: string[], comment?: string): string {
209
+ const content = [normalizeSql(definition), ...[...indexes].sort().map(normalizeSql), comment ?? '']
210
+ .join('\n');
211
+ return createHash('sha256').update(content).digest('hex');
212
+ }
213
+
214
+ export interface DerivedRows {
215
+ relations: RelationRow[];
216
+ functions: FunctionRow[];
217
+ indexes: IndexDefRow[];
218
+ depends: DependsRow[];
219
+ provenance: ProvenanceRawRow[];
220
+ }
221
+
222
+ /**
223
+ * Fold introspected rows into the catalog. Deterministic: objects sorted by
224
+ * identity, indexes sorted, edges sorted — a re-introspection of an unchanged
225
+ * database is byte-stable.
226
+ */
227
+ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
228
+ const indexesByIdentity = new Map<string, string[]>();
229
+ for (const row of rows.indexes) {
230
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
231
+ const identity = `${row.schema}.${row.name}`;
232
+ const list = indexesByIdentity.get(identity) ?? [];
233
+ list.push(String(row.indexdef));
234
+ indexesByIdentity.set(identity, list);
235
+ }
236
+
237
+ const objects: LiveObject[] = [];
238
+
239
+ for (const row of rows.relations) {
240
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
241
+ const kind: DerivedKind = String(row.kind) === 'm' ? 'materialized view' : 'view';
242
+ const identity = `${row.schema}.${row.name}`;
243
+ const definition = String(row.definition ?? '');
244
+ const indexes = [...(indexesByIdentity.get(identity) ?? [])].sort();
245
+ const comment = row.comment == null ? undefined : String(row.comment);
246
+ const obj: LiveObject = {
247
+ kind, schema: row.schema, name: row.name, identity,
248
+ definition, indexes,
249
+ ...(comment !== undefined ? { comment } : {}),
250
+ defHash: computeDefHash(definition, indexes, comment),
251
+ };
252
+ if (kind === 'materialized view') {
253
+ obj.populated = row.populated == null ? undefined : String(row.populated) !== 'false';
254
+ obj.bytes = row.bytes == null ? undefined : Number(row.bytes);
255
+ obj.rows = row.rows == null ? undefined : Math.max(0, Number(row.rows));
256
+ }
257
+ objects.push(obj);
258
+ }
259
+
260
+ for (const row of rows.functions) {
261
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
262
+ const identity = `${row.schema}.${row.name}`;
263
+ const definition = String(row.definition ?? '');
264
+ const comment = row.comment == null ? undefined : String(row.comment);
265
+ objects.push({
266
+ kind: 'function', schema: row.schema, name: row.name, identity,
267
+ definition, indexes: [],
268
+ ...(comment !== undefined ? { comment } : {}),
269
+ defHash: computeDefHash(definition, [], comment),
270
+ });
271
+ }
272
+
273
+ const edges: DependencyEdge[] = rows.depends
274
+ .filter((r) => !IGNORED_SCHEMAS.has(r.dependent_schema) && !IGNORED_SCHEMAS.has(r.referenced_schema))
275
+ .map((r) => ({
276
+ dependent: `${r.dependent_schema}.${r.dependent_name}`,
277
+ referenced: `${r.referenced_schema}.${r.referenced_name}`,
278
+ }))
279
+ .sort((a, b) => (a.dependent + a.referenced).localeCompare(b.dependent + b.referenced));
280
+
281
+ const provenance: ProvenanceRow[] = rows.provenance
282
+ .map((r) => ({ identity: r.identity, srcHash: String(r.src_hash ?? ''), defHash: String(r.def_hash ?? '') }))
283
+ .sort((a, b) => a.identity.localeCompare(b.identity));
284
+
285
+ return {
286
+ objects: objects.sort((a, b) => a.identity.localeCompare(b.identity)),
287
+ edges,
288
+ provenance,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Introspect the live derived layer. Runs the read-only SQL through the
294
+ * injected runner; a missing provenance table (first run against a database
295
+ * the reconciler has never touched) folds to an empty claims list.
296
+ */
297
+ export async function introspectDerived(run: QueryRunner): Promise<DerivedCatalog> {
298
+ const [relations, functions, indexes, depends] = await Promise.all([
299
+ run(DERIVED_RELATIONS_SQL), run(DERIVED_FUNCTIONS_SQL), run(MATVIEW_INDEXES_SQL), run(DERIVED_DEPENDS_SQL),
300
+ ]);
301
+ let provenance: ProvenanceRawRow[] = [];
302
+ try {
303
+ provenance = (await run(PROVENANCE_SQL)) as ProvenanceRawRow[];
304
+ } catch {
305
+ // everystack.derived_provenance does not exist yet — nothing has been reconciled.
306
+ }
307
+ return assembleDerivedCatalog({
308
+ relations: relations as RelationRow[],
309
+ functions: functions as FunctionRow[],
310
+ indexes: indexes as IndexDefRow[],
311
+ depends: depends as DependsRow[],
312
+ provenance,
313
+ });
314
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * derived-plan — the reconciler's pure decision core.
3
+ *
4
+ * Joins the three inputs on identity — source objects (db/sql), the live
5
+ * catalog, and the provenance claims — and decides, per object: skip, create,
6
+ * replace, refresh, drop, baseline, or prune. Hand-edited live objects are
7
+ * DRIFT: reported, never silently overwritten (`overwriteDrift` makes the
8
+ * overwrite explicit, and the drift stays in the report so the operator sees
9
+ * what they overwrote). Never-reconciled matches are `needsBaseline`: the
10
+ * reconciler refuses to guess whether live matches source — `baseline: true`
11
+ * records the trust explicitly, once.
12
+ *
13
+ * Graph work: replacing (or dropping) a relation rebuilds its live transitive
14
+ * dependents — dropped in reverse-topological order, recreated in source
15
+ * order (the declared dependency order). Replacing a function refreshes the
16
+ * matviews that call it (functions replace in place via CREATE OR REPLACE; a
17
+ * matview's stored rows are stale the moment its function changes). A live
18
+ * dependent with no source to rebuild from BLOCKS its chain: nothing in that
19
+ * chain applies, and the report names the blocker.
20
+ *
21
+ * Decisions here never read timestamps and never trust provenance where the
22
+ * catalog can contradict it — a poisoned claim converges to a rebuild or a
23
+ * drift report, not a silent skip.
24
+ */
25
+
26
+ import type { ParsedSources, SourceObject, DerivedKind } from './derived-source.js';
27
+ import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
28
+
29
+ export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'prune';
30
+
31
+ export interface ReconcileAction {
32
+ action: ReconcileVerb;
33
+ identity: string;
34
+ kind: DerivedKind;
35
+ reason: string;
36
+ /** Rebuild-cost signal from the live catalog (matviews). */
37
+ bytes?: number;
38
+ rows?: number;
39
+ }
40
+
41
+ export interface DriftFinding {
42
+ identity: string;
43
+ detail: string;
44
+ }
45
+
46
+ export interface BlockedFinding {
47
+ identity: string;
48
+ reason: string;
49
+ }
50
+
51
+ export interface ReconcilePlan {
52
+ /** In execution order: drops (reverse-topo), function replaces, creates (source order), refreshes, baselines, prunes. */
53
+ actions: ReconcileAction[];
54
+ /** Managed objects that are up to date. */
55
+ skipped: string[];
56
+ /** Hand-edited managed objects — reported always, rebuilt only under overwriteDrift. */
57
+ drift: DriftFinding[];
58
+ /** Source↔live matches the reconciler has never touched — baseline explicitly or rebuild. */
59
+ needsBaseline: string[];
60
+ /** Live derived objects with no source and no provenance — not ours, never touched. */
61
+ unmanaged: string[];
62
+ /** Chains held because a live dependent has no source to rebuild from. */
63
+ blocked: BlockedFinding[];
64
+ /** Source-parse warnings, passed through so one report carries everything. */
65
+ warnings: string[];
66
+ /** Sum of live bytes over rebuilt + refreshed matviews — the headline cost estimate. */
67
+ totalRebuildBytes: number;
68
+ }
69
+
70
+ export interface ReconcileOptions {
71
+ /** Record provenance for never-reconciled source↔live matches instead of flagging them. */
72
+ baseline?: boolean;
73
+ /** Rebuild drifted objects from source (the drift finding stays in the report). */
74
+ overwriteDrift?: boolean;
75
+ }
76
+
77
+ const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
78
+
79
+ export function planReconcile(
80
+ source: ParsedSources,
81
+ live: DerivedCatalog,
82
+ options: ReconcileOptions = {},
83
+ ): ReconcilePlan {
84
+ const srcById = new Map(source.objects.map((o) => [o.identity, o]));
85
+ const liveById = new Map(live.objects.map((o) => [o.identity, o]));
86
+ const provById = new Map(live.provenance.map((p) => [p.identity, p]));
87
+
88
+ const skipped: string[] = [];
89
+ const drift: DriftFinding[] = [];
90
+ const needsBaseline: string[] = [];
91
+ const unmanaged: string[] = [];
92
+ const blocked: BlockedFinding[] = [];
93
+
94
+ /** identity → rebuild reason, for relations that must drop + recreate. */
95
+ const rebuild = new Map<string, string>();
96
+ /** Function replacements (in place). */
97
+ const fnReplace = new Map<string, string>();
98
+ /** Creations with no live counterpart. */
99
+ const create = new Map<string, string>();
100
+ /** Managed objects the source removed. */
101
+ const drop = new Map<string, string>();
102
+ const baseline: string[] = [];
103
+ const prune: string[] = [];
104
+
105
+ // -------------------------------------------------------------------------
106
+ // The decision table.
107
+ // -------------------------------------------------------------------------
108
+
109
+ for (const src of source.objects) {
110
+ const liveObj = liveById.get(src.identity);
111
+ const prov = provById.get(src.identity);
112
+
113
+ if (!liveObj) {
114
+ create.set(src.identity, prov
115
+ ? 'missing from the database (recorded as applied — dropped by hand?)'
116
+ : 'new in source');
117
+ continue;
118
+ }
119
+ if (!prov) {
120
+ if (options.baseline) baseline.push(src.identity);
121
+ else needsBaseline.push(src.identity);
122
+ continue;
123
+ }
124
+
125
+ const srcChanged = src.hash !== prov.srcHash;
126
+ const liveChanged = liveObj.defHash !== prov.defHash;
127
+
128
+ if (liveChanged) {
129
+ drift.push({
130
+ identity: src.identity,
131
+ detail: srcChanged
132
+ ? 'live definition differs from what the reconciler applied AND the source changed — resolve by hand or rebuild with overwriteDrift'
133
+ : 'live definition differs from what the reconciler applied (hand-edited?)',
134
+ });
135
+ if (options.overwriteDrift) {
136
+ if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (drift overwritten)');
137
+ else fnReplace.set(src.identity, 'replaced from source (drift overwritten)');
138
+ }
139
+ continue;
140
+ }
141
+ if (srcChanged) {
142
+ if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
143
+ else fnReplace.set(src.identity, 'source changed');
144
+ continue;
145
+ }
146
+ skipped.push(src.identity);
147
+ }
148
+
149
+ for (const liveObj of live.objects) {
150
+ if (srcById.has(liveObj.identity)) continue;
151
+ if (provById.has(liveObj.identity)) drop.set(liveObj.identity, 'removed from source');
152
+ else unmanaged.push(liveObj.identity);
153
+ }
154
+
155
+ for (const prov of live.provenance) {
156
+ if (!srcById.has(prov.identity) && !liveById.has(prov.identity)) prune.push(prov.identity);
157
+ }
158
+
159
+ // -------------------------------------------------------------------------
160
+ // The graph: cascade rebuilds, refreshes, and blocked chains.
161
+ // -------------------------------------------------------------------------
162
+
163
+ const dependents = new Map<string, string[]>();
164
+ for (const e of live.edges) {
165
+ const list = dependents.get(e.referenced) ?? [];
166
+ list.push(e.dependent);
167
+ dependents.set(e.referenced, list);
168
+ }
169
+
170
+ const closureOf = (root: string): string[] => {
171
+ const seen = new Set<string>();
172
+ const queue = [...(dependents.get(root) ?? [])];
173
+ while (queue.length) {
174
+ const next = queue.shift()!;
175
+ if (seen.has(next)) continue;
176
+ seen.add(next);
177
+ queue.push(...(dependents.get(next) ?? []));
178
+ }
179
+ return [...seen];
180
+ };
181
+
182
+ // Relation roots whose live dependents must be handled: rebuilds and drops.
183
+ const relationRoots = [...rebuild.keys(), ...drop.keys()];
184
+ for (const root of relationRoots) {
185
+ const closure = closureOf(root).filter((id) => liveById.get(id) && isRelation(liveById.get(id)!.kind));
186
+ const blockers = closure.filter((id) => !srcById.has(id) && !drop.has(id));
187
+ if (blockers.length > 0) {
188
+ blocked.push({
189
+ identity: root,
190
+ reason: `live dependents with no source to rebuild from: ${blockers.join(', ')} — held`,
191
+ });
192
+ rebuild.delete(root);
193
+ drop.delete(root);
194
+ continue;
195
+ }
196
+ for (const dep of closure) {
197
+ if (!rebuild.has(dep) && !drop.has(dep) && srcById.has(dep)) {
198
+ rebuild.set(dep, `dependency rebuild (depends on ${root})`);
199
+ }
200
+ }
201
+ }
202
+
203
+ // Matviews that call a replaced function hold stale rows — refresh them,
204
+ // unless the rebuild already recreates them fresh.
205
+ const refresh = new Map<string, string>();
206
+ for (const fnId of fnReplace.keys()) {
207
+ for (const dep of closureOf(fnId)) {
208
+ const liveDep = liveById.get(dep);
209
+ if (!liveDep || liveDep.kind !== 'materialized view') continue;
210
+ if (rebuild.has(dep) || drop.has(dep)) continue;
211
+ refresh.set(dep, `depends on replaced function ${fnId}`);
212
+ }
213
+ }
214
+
215
+ // -------------------------------------------------------------------------
216
+ // Ordering.
217
+ // -------------------------------------------------------------------------
218
+
219
+ /** Reverse-topological among the drop set: dependents drop before their dependencies. */
220
+ const orderDrops = (ids: string[]): string[] => {
221
+ const inSet = new Set(ids);
222
+ const depth = new Map<string, number>();
223
+ const depthOf = (id: string, seen: Set<string>): number => {
224
+ if (depth.has(id)) return depth.get(id)!;
225
+ if (seen.has(id)) return 0; // cycle guard — Postgres would not have allowed it anyway
226
+ seen.add(id);
227
+ const deps = (dependents.get(id) ?? []).filter((d) => inSet.has(d));
228
+ const d = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((x) => depthOf(x, seen)));
229
+ depth.set(id, d);
230
+ return d;
231
+ };
232
+ return [...ids].sort((a, b) => depthOf(a, new Set()) - depthOf(b, new Set()) || a.localeCompare(b));
233
+ };
234
+
235
+ const seqOf = (id: string): number => srcById.get(id)?.seq ?? Number.MAX_SAFE_INTEGER;
236
+
237
+ const dropSet = [...new Set([...rebuild.keys(), ...drop.keys()])].filter((id) => liveById.has(id));
238
+ const createSet = [...new Set([...rebuild.keys(), ...create.keys()])].filter((id) => srcById.has(id));
239
+
240
+ const actionFor = (id: string, action: ReconcileVerb, reason: string): ReconcileAction => {
241
+ const src = srcById.get(id);
242
+ const liveObj = liveById.get(id);
243
+ const kind = (src?.kind ?? liveObj?.kind) as DerivedKind;
244
+ const cost = liveObj?.kind === 'materialized view'
245
+ ? { ...(liveObj.bytes !== undefined ? { bytes: liveObj.bytes } : {}), ...(liveObj.rows !== undefined ? { rows: liveObj.rows } : {}) }
246
+ : {};
247
+ return { action, identity: id, kind, reason, ...cost };
248
+ };
249
+
250
+ const actions: ReconcileAction[] = [
251
+ ...orderDrops(dropSet).map((id) => actionFor(id, 'drop', rebuild.get(id) ?? drop.get(id) ?? '')),
252
+ ...[...fnReplace.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
253
+ .map(([id, reason]) => actionFor(id, 'replace', reason)),
254
+ ...createSet.sort((a, b) => seqOf(a) - seqOf(b))
255
+ .map((id) => actionFor(id, 'create', rebuild.get(id) ?? create.get(id) ?? '')),
256
+ ...[...refresh.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
257
+ .map(([id, reason]) => actionFor(id, 'refresh', reason)),
258
+ ...baseline.sort().map((id) => actionFor(id, 'baseline', 'recording provenance for an existing match (trusted once, explicitly)')),
259
+ ...prune.sort().map((id) => ({
260
+ action: 'prune' as const, identity: id, kind: 'view' as DerivedKind,
261
+ reason: 'stale provenance — object gone from both source and database',
262
+ })),
263
+ ];
264
+
265
+ const totalRebuildBytes = actions
266
+ .filter((a) => (a.action === 'create' || a.action === 'refresh') && a.bytes !== undefined)
267
+ .reduce((sum, a) => sum + (a.bytes ?? 0), 0);
268
+
269
+ return {
270
+ actions,
271
+ skipped: skipped.sort(),
272
+ drift,
273
+ needsBaseline: needsBaseline.sort(),
274
+ unmanaged: unmanaged.sort(),
275
+ blocked,
276
+ warnings: [...source.warnings],
277
+ totalRebuildBytes,
278
+ };
279
+ }