@everystack/cli 0.3.31 → 0.4.1
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 -1
- package/package.json +2 -2
- package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
- package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/aws.ts +12 -3
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-authz.ts +3 -17
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +102 -34
- package/src/cli/commands/db-pull.ts +82 -6
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/commands/db.ts +7 -6
- package/src/cli/commands/update.ts +2 -1
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +173 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +377 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +176 -10
- package/src/cli/derived-render.ts +366 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/edge-plan.ts +4 -1
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +125 -16
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-advice.ts +66 -0
- package/src/cli/pipeline-path.ts +1 -1
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- package/src/cli/state-apply.ts +52 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* derived-lint — the B2 gates over the declared derived layer
|
|
3
|
+
* (docs/plans/read-model-everywhere.md, "Rejection rules and gates").
|
|
4
|
+
*
|
|
5
|
+
* The table gate (authz-lint's findReadAuthzGaps) has a derived mirror: a declared
|
|
6
|
+
* relation nobody can read exists superuser-only from the moment it is created. Two more
|
|
7
|
+
* gates close the function surface (SECURITY DEFINER with undeclared callers) and the
|
|
8
|
+
* matview trap (a snapshot taken with the refresher's eyes over row-scoped sources), and
|
|
9
|
+
* one closes the invoker-view gap: `securityInvoker: true` means the CALLER's rights are
|
|
10
|
+
* checked against every relation the body touches — a view can be granted and still
|
|
11
|
+
* unreadable, one property over.
|
|
12
|
+
*
|
|
13
|
+
* Reachability walks DECLARED deps only — the declared graph paying for itself
|
|
14
|
+
* (`dependsOn`: declared, then verified; reconcile checks the declaration against the
|
|
15
|
+
* live catalog). An empty `dependsOn` is an empty closure, not a pass on a technicality:
|
|
16
|
+
* the drift report names the missing declaration.
|
|
17
|
+
*
|
|
18
|
+
* Audience semantics mirror the table grant compiler (authz-compile), and the mirroring
|
|
19
|
+
* is PINNED by a differential test against compileTableContract (derived-lint.test.ts) —
|
|
20
|
+
* asserted-in-parallel implementations drift exactly where the examples don't look. The
|
|
21
|
+
* shapes: bare read → anon + authenticated; owner/via-scoped read → authenticated only;
|
|
22
|
+
* column-scoped self read (`owner` + `columns`) → its role (or authenticated); `{ role }`
|
|
23
|
+
* narrows to that role; manage → its role (or authenticated); model-private does NOT
|
|
24
|
+
* zero the audience (private models still compile grants — the auth.users pattern). Row
|
|
25
|
+
* scoping (RLS) filters rows, not reach — a grant that returns only your rows is still a
|
|
26
|
+
* grant.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
|
|
31
|
+
FunctionDescriptor, DependsOnRef, Ability,
|
|
32
|
+
} from '@everystack/model';
|
|
33
|
+
import { parseQualified } from './derived-source.js';
|
|
34
|
+
|
|
35
|
+
export interface DerivedAuthzGap {
|
|
36
|
+
/** `schema.name` of the descriptor that fails the gate. */
|
|
37
|
+
identity: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function identityOf(d: DerivedDescriptor): string {
|
|
42
|
+
const { schema, name } = parseQualified(d.name);
|
|
43
|
+
return `${schema}.${name}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
|
|
47
|
+
return 'table' in ref;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The column-scoped self read — same predicate as the table grant compiler. */
|
|
51
|
+
function isColumnRead(a: Ability): boolean {
|
|
52
|
+
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Does the table's grant compile give `role` SELECT (full or column-scoped)?
|
|
57
|
+
*
|
|
58
|
+
* Exported for the differential pin against compileTableContract — this math must agree
|
|
59
|
+
* with the ACTUAL grant compiler for every ability shape, or the gate refuses views the
|
|
60
|
+
* database would serve (or worse, passes ones it won't). Note `private` on a MODEL is
|
|
61
|
+
* deliberately not consulted: private means "not exposed via the generic API", and its
|
|
62
|
+
* authz still compiles into real grants (the auth.users pattern — private + admin
|
|
63
|
+
* manage + column-scoped self read). The red team caught the gate treating model-private
|
|
64
|
+
* as zero-reach, which bricked every invoker view over a private table.
|
|
65
|
+
*/
|
|
66
|
+
export function tableReaches(m: ModelDescriptor, role: string): boolean {
|
|
67
|
+
for (const a of m.abilities) {
|
|
68
|
+
if (isColumnRead(a)) {
|
|
69
|
+
if ((a.condition.role ?? 'authenticated') === role) return true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (a.action !== 'read' && a.action !== 'manage') continue;
|
|
73
|
+
if (a.condition.role) {
|
|
74
|
+
if (a.condition.role === role) return true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (role === 'authenticated') return true;
|
|
78
|
+
if (role === 'anon' && a.action === 'read' && !a.condition.owner && !a.condition.via) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
|
|
84
|
+
function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
|
|
85
|
+
const roles = new Set<string>();
|
|
86
|
+
for (const a of d.abilities) {
|
|
87
|
+
if (a.condition.role) roles.add(a.condition.role);
|
|
88
|
+
else {
|
|
89
|
+
roles.add('anon');
|
|
90
|
+
roles.add('authenticated');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return roles;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function functionRoles(fn: FunctionDescriptor): Set<string> {
|
|
97
|
+
// define-time already rejected bare can('execute') — every ability carries a role.
|
|
98
|
+
return new Set(fn.abilities.map((a) => a.condition.role!));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Gate: derived read-gap (db:check fail)
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every declared relation makes its read decision or fails: an exposed view/matview with
|
|
107
|
+
* no read ability and no `private: true` is superuser-only from birth — the derived
|
|
108
|
+
* mirror of the table landmine findReadAuthzGaps catches.
|
|
109
|
+
*/
|
|
110
|
+
export function findDerivedReadGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
111
|
+
const gaps: DerivedAuthzGap[] = [];
|
|
112
|
+
for (const d of derived) {
|
|
113
|
+
if (d.kind !== 'view' && d.kind !== 'materialized view') continue;
|
|
114
|
+
if (d.private || d.abilities.length > 0) continue;
|
|
115
|
+
const identity = identityOf(d);
|
|
116
|
+
gaps.push({
|
|
117
|
+
identity,
|
|
118
|
+
message:
|
|
119
|
+
`${identity} is a declared ${d.kind} with no read ability and no private: true — no app role can ` +
|
|
120
|
+
`read it, so it exists superuser-only. Declare can('read') for public data, can('read', { columns }) ` +
|
|
121
|
+
`to scope fields, or private: true if it is not part of the data API.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return gaps;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Gate: SECURITY DEFINER with no declared caller (db:check fail)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A definer function runs with its owner's rights; compiling one with no `can('execute')`
|
|
133
|
+
* is privileged code with an undeclared audience — callable by nobody today (PUBLIC is
|
|
134
|
+
* revoked unconditionally) and by whoever gets a grant tomorrow, with no declaration to
|
|
135
|
+
* review. Invoker functions may stay dark: unprivileged, owner-callable, honest.
|
|
136
|
+
*/
|
|
137
|
+
export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
138
|
+
const gaps: DerivedAuthzGap[] = [];
|
|
139
|
+
for (const d of derived) {
|
|
140
|
+
if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
|
|
141
|
+
const identity = identityOf(d);
|
|
142
|
+
gaps.push({
|
|
143
|
+
identity,
|
|
144
|
+
message:
|
|
145
|
+
`${identity} is SECURITY DEFINER with no declared caller — privileged code whose audience nobody ` +
|
|
146
|
+
`reviewed. Declare who calls it with can('execute', { role: '…' }), or make it security: 'invoker'.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return gaps;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Gate: matview snapshot over row-scoped sources (db:check warn)
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** Walk the declared closure through views (a matview's own snapshot is its own decision). */
|
|
157
|
+
function closureModels(refs: readonly DependsOnRef[], seen = new Set<DependsOnRef>()): ModelDescriptor[] {
|
|
158
|
+
const models: ModelDescriptor[] = [];
|
|
159
|
+
for (const ref of refs) {
|
|
160
|
+
if (seen.has(ref)) continue;
|
|
161
|
+
seen.add(ref);
|
|
162
|
+
if (isModelRef(ref)) models.push(ref);
|
|
163
|
+
else if (ref.kind === 'view') models.push(...closureModels(ref.dependsOn, seen));
|
|
164
|
+
}
|
|
165
|
+
return models;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rowScoped(m: ModelDescriptor): boolean {
|
|
169
|
+
return m.abilities.some((a) => a.condition.owner || a.condition.via);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A matview's rows are whatever its refresher could see, served flat to every granted
|
|
174
|
+
* role: over a row-scoped source, a superuser refresher snapshots EVERYTHING (broad), and
|
|
175
|
+
* a FORCE-RLS-subject owner silently snapshots a SUBSET (filtered) — both wrong in a
|
|
176
|
+
* different direction, neither visible in the schema. Warn, don't fail: an aggregating
|
|
177
|
+
* body (counts, sums) is the legitimate case, and only the author knows.
|
|
178
|
+
*/
|
|
179
|
+
export function findMatviewSnapshotWarnings(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
180
|
+
const warnings: DerivedAuthzGap[] = [];
|
|
181
|
+
for (const d of derived) {
|
|
182
|
+
if (d.kind !== 'materialized view' || d.private || d.abilities.length === 0) continue;
|
|
183
|
+
const scoped = closureModels(d.dependsOn).filter(rowScoped);
|
|
184
|
+
if (scoped.length === 0) continue;
|
|
185
|
+
const identity = identityOf(d);
|
|
186
|
+
const sources = scoped.map((m) => `${m.schema}.${m.table}`).join(', ');
|
|
187
|
+
warnings.push({
|
|
188
|
+
identity,
|
|
189
|
+
message:
|
|
190
|
+
`${identity} snapshots row-scoped source(s) ${sources} with the refresher's eyes — a superuser ` +
|
|
191
|
+
`refresh serves EVERY row flat to every granted role; a FORCE-RLS-subject owner silently snapshots ` +
|
|
192
|
+
`a subset. Confirm the body aggregates/filters away the per-row data, or declare private: true.`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return warnings;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Gate: invoker-view grant reachability (compile fail)
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/** The first ref in the closure `role` cannot reach, or null. */
|
|
203
|
+
function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<DependsOnRef>): string | null {
|
|
204
|
+
for (const ref of refs) {
|
|
205
|
+
if (seen.has(ref)) continue;
|
|
206
|
+
seen.add(ref);
|
|
207
|
+
if (isModelRef(ref)) {
|
|
208
|
+
if (!tableReaches(ref, role)) return `${ref.schema}.${ref.table}`;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
switch (ref.kind) {
|
|
212
|
+
case 'view': {
|
|
213
|
+
if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
|
|
214
|
+
// An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
|
|
215
|
+
if (ref.securityInvoker) {
|
|
216
|
+
const deeper = findUnreachable(role, ref.dependsOn, seen);
|
|
217
|
+
if (deeper) return deeper;
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'materialized view':
|
|
222
|
+
// A matview is its own snapshot: SELECT on the matview is the whole requirement.
|
|
223
|
+
if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
|
|
224
|
+
break;
|
|
225
|
+
case 'function':
|
|
226
|
+
// The body calls it with the caller's rights — EXECUTE is part of reach.
|
|
227
|
+
if (!functionRoles(ref).has(role)) return identityOf(ref);
|
|
228
|
+
break;
|
|
229
|
+
case 'sql':
|
|
230
|
+
break; // opaque — never a false positive
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* `securityInvoker: true` + a grant to role R is a PROMISE that R can read the body's
|
|
238
|
+
* relations — PostgreSQL checks R, not the owner, against every one of them. This gate
|
|
239
|
+
* makes the promise static: every granted role must have reach through the declared
|
|
240
|
+
* `dependsOn` closure, or the view is granted-but-unreadable and the compile refuses.
|
|
241
|
+
*/
|
|
242
|
+
export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
|
|
243
|
+
const gaps: DerivedAuthzGap[] = [];
|
|
244
|
+
for (const d of derived) {
|
|
245
|
+
if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
|
|
246
|
+
const identity = identityOf(d);
|
|
247
|
+
for (const role of [...relationRoles(d)].sort()) {
|
|
248
|
+
const unreachable = findUnreachable(role, d.dependsOn, new Set());
|
|
249
|
+
if (unreachable) {
|
|
250
|
+
gaps.push({
|
|
251
|
+
identity,
|
|
252
|
+
message:
|
|
253
|
+
`view '${identity}' grants ${role} but its dependency '${unreachable}' gives ${role} no reach — ` +
|
|
254
|
+
`granted-but-unreadable (an invoker view reads with the caller's rights). Grant '${unreachable}' ` +
|
|
255
|
+
`to ${role}, seal the view with securityInvoker: false, or narrow the view's roles.`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return gaps;
|
|
261
|
+
}
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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 ==
|
|
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
|
-
|
|
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
|
|
172
|
-
if (
|
|
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
|
-
|
|
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
|
}
|