@everystack/cli 0.3.13 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/commands/db-reconcile.ts +300 -0
- package/src/cli/derived-apply.ts +149 -0
- package/src/cli/derived-introspect.ts +314 -0
- package/src/cli/derived-plan.ts +279 -0
- package/src/cli/derived-source.ts +373 -0
- package/src/cli/index.ts +5 -0
|
@@ -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
|
+
}
|