@everystack/cli 0.4.106 → 0.4.107
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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/cli/alter-type-dependents.ts +199 -0
- package/src/cli/apply-execute.ts +18 -0
- package/src/cli/commands/db-apply.ts +49 -6
- package/src/cli/commands/db-plan.ts +26 -12
- package/src/cli/commands/db-sync.ts +19 -8
- package/src/cli/derived-catchup.ts +6 -0
- package/src/cli/edge-plan.ts +34 -2
- package/src/cli/lifecycle-preflight.ts +162 -19
- package/src/cli/state-apply.ts +53 -21
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes to `@everystack/cli` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.107] - 2026-10-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- db:plan names db:provision first when a lifecycle plan needs it
|
|
12
|
+
- db:apply prints a wall time per phase
|
|
13
|
+
- a type change under a declared view or matview mints and applies
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- the lifecycle preflight accepts the grants the reviewed plan revokes
|
|
18
|
+
- a re-created view's apply errors name it as a view
|
|
19
|
+
- a grant the plan's REVOKE cannot remove stays a lifecycle finding
|
|
20
|
+
- a destroy_log grant from another role names db:provision's REVOKE
|
|
21
|
+
|
|
7
22
|
## [0.4.106] - 2026-10-31
|
|
8
23
|
|
|
9
24
|
### Added
|
package/package.json
CHANGED
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { QueryRunner } from './authz-contract';
|
|
16
|
+
import type { SessionRunner } from './session';
|
|
17
|
+
import type { SourceObject } from './derived-source';
|
|
18
|
+
import type { StateDependent } from './edge-plan';
|
|
19
|
+
import { introspectDerived } from './derived-introspect';
|
|
20
|
+
import { planReconcile } from './derived-plan';
|
|
21
|
+
import {
|
|
22
|
+
builderOwnerMode, derivedSearchPath, ownerPreflightRefusal, ownerPreflightSql, ownerRequirements,
|
|
23
|
+
renderEnsureObjectSchemas, renderReconcileSql, type OwnerApplyMode,
|
|
24
|
+
} from './derived-apply';
|
|
25
|
+
import { quoteIdent, quoteQualified } from './pg-ident';
|
|
16
26
|
|
|
17
27
|
export interface AlterTypeTarget {
|
|
18
28
|
table: string;
|
|
@@ -94,3 +104,192 @@ export function renderAlterTypeRefusal(blocked: readonly BlockedAlterType[]): st
|
|
|
94
104
|
`Route the type change through db:swap (rebuild + atomic rename), or drop and re-create the dependent derived objects around it (db:reconcile) — deliberately, not as an apply-time surprise.`,
|
|
95
105
|
].join('\n');
|
|
96
106
|
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Declared dependents: dropped and re-created inside the state transaction.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every view, matview or rule the targets' type changes force out: the ones that bind a target
|
|
114
|
+
* column, and, transitively, every view or matview whose rule reads one of those (a view on a
|
|
115
|
+
* view). PostgreSQL cannot drop the first without the second.
|
|
116
|
+
*/
|
|
117
|
+
export function dependentClosureSql(targets: readonly AlterTypeTarget[]): string {
|
|
118
|
+
const direct = targets.map((t) => {
|
|
119
|
+
const rel = quoteLiteral(t.table);
|
|
120
|
+
return `(d.refobjid = ${rel}::regclass AND d.refobjsubid = (SELECT attnum FROM pg_catalog.pg_attribute
|
|
121
|
+
WHERE attrelid = ${rel}::regclass AND attname = ${quoteLiteral(t.column)}))`;
|
|
122
|
+
}).join(' OR ');
|
|
123
|
+
return `WITH RECURSIVE closure(oid) AS (
|
|
124
|
+
SELECT r.ev_class FROM pg_catalog.pg_depend d JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
|
|
125
|
+
WHERE d.classid = 'pg_catalog.pg_rewrite'::regclass AND d.refclassid = 'pg_catalog.pg_class'::regclass
|
|
126
|
+
AND r.ev_class <> d.refobjid AND (${direct})
|
|
127
|
+
UNION
|
|
128
|
+
SELECT r.ev_class FROM closure c JOIN pg_catalog.pg_depend d ON d.refobjid = c.oid
|
|
129
|
+
AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.classid = 'pg_catalog.pg_rewrite'::regclass
|
|
130
|
+
JOIN pg_catalog.pg_rewrite r ON r.oid = d.objid
|
|
131
|
+
WHERE r.ev_class <> c.oid
|
|
132
|
+
)
|
|
133
|
+
SELECT DISTINCT cl.oid::bigint AS oid,
|
|
134
|
+
CASE cl.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'rule' END AS kind,
|
|
135
|
+
n.nspname::text || '.' || cl.relname::text AS identity,
|
|
136
|
+
pg_catalog.pg_total_relation_size(cl.oid)::bigint AS bytes
|
|
137
|
+
FROM closure JOIN pg_catalog.pg_class cl ON cl.oid = closure.oid JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
|
|
138
|
+
ORDER BY 3`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What else depends on the closure, which a plain DROP would refuse over (a function returning
|
|
143
|
+
* the view's row type) or remove silently (a trigger on a view). The closure's own rules and
|
|
144
|
+
* members are not counted: they are dropped and re-created with it.
|
|
145
|
+
*/
|
|
146
|
+
export function closureBlockersSql(oids: readonly number[]): string {
|
|
147
|
+
const list = `ARRAY[${oids.map((oid) => String(Math.trunc(oid))).join(', ')}]::oid[]`;
|
|
148
|
+
return `SELECT DISTINCT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid) AS what,
|
|
149
|
+
pg_catalog.pg_describe_object(d.refclassid, d.refobjid, 0) AS on_what
|
|
150
|
+
FROM pg_catalog.pg_depend d
|
|
151
|
+
WHERE ((d.refclassid = 'pg_catalog.pg_class'::regclass AND d.refobjid = ANY(${list}))
|
|
152
|
+
OR (d.refclassid = 'pg_catalog.pg_type'::regclass AND d.refobjid IN (SELECT reltype FROM pg_catalog.pg_class WHERE oid = ANY(${list}))))
|
|
153
|
+
AND ((d.deptype = 'n'
|
|
154
|
+
AND NOT (d.classid = 'pg_catalog.pg_rewrite'::regclass AND (SELECT ev_class FROM pg_catalog.pg_rewrite WHERE oid = d.objid) = ANY(${list}))
|
|
155
|
+
AND NOT (d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = ANY(${list})))
|
|
156
|
+
OR d.classid = 'pg_catalog.pg_trigger'::regclass)
|
|
157
|
+
ORDER BY 1`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `1.2 MB`, for the plan's matview line. */
|
|
161
|
+
export function formatBytes(bytes: number): string {
|
|
162
|
+
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
|
|
163
|
+
let value = bytes;
|
|
164
|
+
let unit = 0;
|
|
165
|
+
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
|
166
|
+
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The plan's line for one dependent. */
|
|
170
|
+
export function renderStateDependentLine(d: Pick<StateDependent, 'identity' | 'kind' | 'bytes'>): string {
|
|
171
|
+
const cost = d.kind === 'materialized view' ? ` (repopulates ${formatBytes(d.bytes ?? 0)})` : '';
|
|
172
|
+
return `~ ${d.kind} ${d.identity}: dropped before the type change and re-created from the checkout in the same transaction${cost}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type StateDependentsMint = { dependents: StateDependent[] } | { refusal: string };
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The declared views and matviews the targets' type changes force out, rendered for the state
|
|
179
|
+
* transaction, or a refusal naming what the platform cannot re-create:
|
|
180
|
+
*
|
|
181
|
+
* - a dependent the models do not declare (nothing can re-create it from the checkout):
|
|
182
|
+
* declare it in the models, which db:pull renders;
|
|
183
|
+
* - a declared one whose live definition the reconciler cannot vouch for (drift, or never
|
|
184
|
+
* reconciled): db:reconcile has the verb for each;
|
|
185
|
+
* - something else depending on the closure (a function over a view's row type, a trigger on a
|
|
186
|
+
* view), which a plain DROP would refuse over or silently remove.
|
|
187
|
+
*
|
|
188
|
+
* Read-only. Each create is the SQL a derived apply renders for that object.
|
|
189
|
+
*/
|
|
190
|
+
export async function mintStateDependents(
|
|
191
|
+
runner: QueryRunner,
|
|
192
|
+
session: SessionRunner,
|
|
193
|
+
targets: readonly AlterTypeTarget[],
|
|
194
|
+
declared: readonly SourceObject[],
|
|
195
|
+
): Promise<StateDependentsMint> {
|
|
196
|
+
if (targets.length === 0) return { dependents: [] };
|
|
197
|
+
const rows = await runner(dependentClosureSql(targets));
|
|
198
|
+
if (rows.length === 0) return { dependents: [] };
|
|
199
|
+
const byIdentity = new Map(declared.map((o) => [o.identity, o]));
|
|
200
|
+
const where = targets.map((t) => `${t.table}."${t.column}"`).join(', ');
|
|
201
|
+
const undeclared = rows.filter((r: any) => r.kind === 'rule' || byIdentity.get(String(r.identity))?.kind !== r.kind);
|
|
202
|
+
if (undeclared.length > 0) {
|
|
203
|
+
return {
|
|
204
|
+
refusal: `This plan changes the type of ${where}, and PostgreSQL refuses that while a view or rule binds the column. `
|
|
205
|
+
+ 'db:apply drops and re-creates the declared ones in the same transaction, but these are not declared, so nothing can re-create them:\n'
|
|
206
|
+
+ undeclared.map((r: any) => ` ${r.kind} ${r.identity} is not declared`).join('\n')
|
|
207
|
+
+ '\nFix: declare it in the models (db:pull renders it), then re-run db:plan.',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const blockers = await runner(closureBlockersSql(rows.map((r: any) => Number(r.oid))));
|
|
211
|
+
if (blockers.length > 0) {
|
|
212
|
+
return {
|
|
213
|
+
refusal: `This plan changes the type of ${where}. db:apply would drop and re-create ${rows.map((r: any) => `${r.kind} ${r.identity}`).join(', ')} around it, `
|
|
214
|
+
+ 'but other objects depend on them, and a plain DROP would refuse over them or remove them:\n'
|
|
215
|
+
+ blockers.map((b: any) => ` ${b.what} depends on ${b.on_what}`).join('\n')
|
|
216
|
+
+ '\nRoute this type change through db:swap, or change the models so nothing else depends on these views first.',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Creation order is the declared layer's dependency order; the drops run in reverse.
|
|
221
|
+
const order = new Map(declared.map((o, i) => [o.identity, i]));
|
|
222
|
+
const closure = rows
|
|
223
|
+
.map((r: any) => ({ identity: String(r.identity), kind: r.kind as StateDependent['kind'], bytes: Number(r.bytes) }))
|
|
224
|
+
.sort((a, b) => order.get(a.identity)! - order.get(b.identity)!);
|
|
225
|
+
const ids = new Set(closure.map((c) => c.identity));
|
|
226
|
+
|
|
227
|
+
const live = await introspectDerived(session);
|
|
228
|
+
const parsed = { objects: [...declared], warnings: [] as string[] };
|
|
229
|
+
// The reconciler's own verdict first: a hand-edited or never-reconciled view is not ours to
|
|
230
|
+
// re-create silently, and db:reconcile already has the verb for each.
|
|
231
|
+
const verdict = planReconcile(parsed, live, { only: [...ids] });
|
|
232
|
+
const drifted = verdict.drift.filter((d) => ids.has(d.identity)).map((d) => d.identity);
|
|
233
|
+
const unbaselined = verdict.needsBaseline.filter((id) => ids.has(id));
|
|
234
|
+
if (drifted.length + unbaselined.length > 0) {
|
|
235
|
+
return {
|
|
236
|
+
refusal: `This plan changes the type of ${where}, which db:apply handles by re-creating the declared views and matviews that bind it, `
|
|
237
|
+
+ 'but the reconciler cannot vouch for some of them:\n'
|
|
238
|
+
+ [...drifted.map((id) => ` ${id} differs from its record (hand-edited). Fix: db:reconcile --apply --overwrite-drift, or declare the live definition.`),
|
|
239
|
+
...unbaselined.map((id) => ` ${id} was never reconciled. Fix: db:reconcile --baseline once you have verified it matches the models.`)].join('\n')
|
|
240
|
+
+ '\nThen re-run db:plan.',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const liveById = new Map(live.objects.map((o) => [o.identity, o]));
|
|
245
|
+
// Rendered as the creates a derived apply would run against the catalog as it is after the
|
|
246
|
+
// drops: the closure absent.
|
|
247
|
+
const absent = {
|
|
248
|
+
...live,
|
|
249
|
+
objects: live.objects.filter((o) => !ids.has(o.identity)),
|
|
250
|
+
edges: live.edges.filter((e) => !ids.has(e.dependent) && !ids.has(e.referenced)),
|
|
251
|
+
};
|
|
252
|
+
const combined = planReconcile(parsed, absent, { only: [...ids] });
|
|
253
|
+
const requirements = ownerRequirements(combined, parsed.objects);
|
|
254
|
+
let ownerMode: OwnerApplyMode = 'set-role';
|
|
255
|
+
if (requirements.length > 0) {
|
|
256
|
+
const preflight = await runner(ownerPreflightSql(requirements));
|
|
257
|
+
const refusal = ownerPreflightRefusal(preflight as any[]);
|
|
258
|
+
if (refusal) return { refusal: `could not plan the re-create of the views a type change binds: ${refusal}` };
|
|
259
|
+
ownerMode = builderOwnerMode(preflight as any[]);
|
|
260
|
+
}
|
|
261
|
+
const schemas = derivedSearchPath([...declared]);
|
|
262
|
+
const searchPath = schemas.length === 1 && schemas[0] === 'public' ? null : schemas.map(quoteIdent).join(', ');
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
dependents: closure.map((c) => {
|
|
266
|
+
const source = byIdentity.get(c.identity)!;
|
|
267
|
+
const rendered = renderReconcileSql(planReconcile(parsed, absent, { only: [c.identity] }), parsed.objects, ownerMode);
|
|
268
|
+
if (rendered.record.length !== 1 || rendered.record[0] !== c.identity) {
|
|
269
|
+
throw new Error(`could not render the re-create of ${c.identity}: the target changed while minting; re-run db:plan.`);
|
|
270
|
+
}
|
|
271
|
+
const drop = `DROP ${c.kind === 'view' ? 'VIEW' : 'MATERIALIZED VIEW'} ${quoteQualified(c.identity)}`;
|
|
272
|
+
const owner = ownerMode === 'set-role' ? source.owner : undefined;
|
|
273
|
+
return {
|
|
274
|
+
identity: c.identity,
|
|
275
|
+
kind: c.kind,
|
|
276
|
+
expectedDefHash: liveById.get(c.identity)!.defHash,
|
|
277
|
+
dropSql: owner ? [`SET ROLE ${owner}`, drop, 'RESET ROLE'].join('\n;\n') : drop,
|
|
278
|
+
sql: [...renderEnsureObjectSchemas([source]), ...rendered.statements].join('\n;\n'),
|
|
279
|
+
searchPath,
|
|
280
|
+
grantContract: {
|
|
281
|
+
attachments: source.attachments.filter((a) => a.kind === 'grant'),
|
|
282
|
+
...(source.owner ? { owner: source.owner } : {}),
|
|
283
|
+
ownerMode,
|
|
284
|
+
},
|
|
285
|
+
provenance: {
|
|
286
|
+
srcHash: source.hash,
|
|
287
|
+
...(source.bodyHash ? { bodyHash: source.bodyHash } : {}),
|
|
288
|
+
kind: c.kind,
|
|
289
|
+
...(source.drop ? { dropSql: source.drop } : {}),
|
|
290
|
+
},
|
|
291
|
+
...(c.kind === 'materialized view' ? { bytes: c.bytes } : {}),
|
|
292
|
+
};
|
|
293
|
+
}),
|
|
294
|
+
};
|
|
295
|
+
}
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -189,6 +189,12 @@ export interface ApplyPlanResult {
|
|
|
189
189
|
liveFingerprint: string;
|
|
190
190
|
reason?: string;
|
|
191
191
|
logId?: number;
|
|
192
|
+
/**
|
|
193
|
+
* Wall time, in ms, of the two phases this function runs, on the statuses that reached the
|
|
194
|
+
* state transaction: everything before it (the lock, the gates, descent; `verifyMs`), and the
|
|
195
|
+
* transaction plus the landed-fingerprint read (`stateMs`). Optional on the wire.
|
|
196
|
+
*/
|
|
197
|
+
phases?: { verifyMs: number; stateMs: number };
|
|
192
198
|
/**
|
|
193
199
|
* The keys the row check found orphans on, if any — data, for the caller's advisory (see
|
|
194
200
|
* fk-orphan-advisory). Present on the paths that got as far as running the check, empty
|
|
@@ -465,6 +471,7 @@ export async function executeApplyPlan(
|
|
|
465
471
|
plan: EdgePlan,
|
|
466
472
|
options: ApplyPlanOptions = {},
|
|
467
473
|
): Promise<ApplyPlanResult> {
|
|
474
|
+
const started = Date.now();
|
|
468
475
|
const { hash: live, snapshot: before, contract: beforeAuthz, ledger, ledgerRead } = await liveBaseAndLedger(session);
|
|
469
476
|
|
|
470
477
|
// The endpoint is FULL (it carries the ungoverned live authz through), so it is compared against
|
|
@@ -596,16 +603,23 @@ export async function executeApplyPlan(
|
|
|
596
603
|
// rewritten — the plan is an artifact under a content hash, and `planHash(plan)` below has to
|
|
597
604
|
// keep naming the file the operator reviewed.
|
|
598
605
|
const { statements: toRun, withheld } = withheldValidations(plan.statements, orphans);
|
|
606
|
+
if (options.stateOnly && plan.stateDependents?.length) {
|
|
607
|
+
const reason = `this plan re-creates ${plan.stateDependents.map((d) => `${d.kind} ${d.identity}`).join(', ')} around a column type change, but --state-only skips derived writes. Apply the reviewed plan without --state-only.`;
|
|
608
|
+
await recordRefusal(runner, plan, live, 'state dependents', reason, options);
|
|
609
|
+
return { status: 'refused', liveFingerprint: live, reason };
|
|
610
|
+
}
|
|
599
611
|
if (options.stateOnly && plan.statePrerequisites?.length) {
|
|
600
612
|
const reason = 'this plan needs state prerequisites, but --state-only skips function writes. Apply the reviewed plan without --state-only, or satisfy the prerequisites and re-mint.';
|
|
601
613
|
await recordRefusal(runner, plan, live, 'state prerequisites', reason, options);
|
|
602
614
|
return { status: 'refused', liveFingerprint: live, reason };
|
|
603
615
|
}
|
|
604
616
|
|
|
617
|
+
const stateStarted = Date.now();
|
|
605
618
|
let result: StateApplyResult;
|
|
606
619
|
try {
|
|
607
620
|
result = await applyGeneratedStatements(runner, toRun, {
|
|
608
621
|
statePrerequisites: plan.statePrerequisites,
|
|
622
|
+
stateDependents: plan.stateDependents,
|
|
609
623
|
transaction: options.transaction,
|
|
610
624
|
...(withheld.length > 0 ? { skippedValidations: withheld } : {}),
|
|
611
625
|
actor: options.actor,
|
|
@@ -630,6 +644,7 @@ export async function executeApplyPlan(
|
|
|
630
644
|
}
|
|
631
645
|
|
|
632
646
|
const { hash: landed } = await liveBaseFingerprint(session);
|
|
647
|
+
const phases = { verifyMs: stateStarted - started, stateMs: Date.now() - stateStarted };
|
|
633
648
|
if (result.logId !== undefined) {
|
|
634
649
|
// The log stamps the RAW landed hash — the same flavor as `from` AND as the full `to`, so the
|
|
635
650
|
// schema_log chain reads as one consistent series of live states.
|
|
@@ -645,6 +660,7 @@ export async function executeApplyPlan(
|
|
|
645
660
|
return {
|
|
646
661
|
status: 'validation-pending',
|
|
647
662
|
liveFingerprint: landed,
|
|
663
|
+
phases,
|
|
648
664
|
// The next move follows the CAUSE: bad rows are a data fix, a check that was cut off by a
|
|
649
665
|
// clock is a lane change. See validationPendingReason.
|
|
650
666
|
reason: validationPendingReason(result.pendingValidations),
|
|
@@ -660,6 +676,7 @@ export async function executeApplyPlan(
|
|
|
660
676
|
return {
|
|
661
677
|
status: 'verify-failed',
|
|
662
678
|
liveFingerprint: landed,
|
|
679
|
+
phases,
|
|
663
680
|
reason: `applied, but the target landed on ${landed.slice(0, 12)} — the plan predicted ${plan.toFingerprint.slice(0, 12)}. Investigate before touching this database again (db:fingerprint, db:generate).`,
|
|
664
681
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
665
682
|
fkOrphans: orphans,
|
|
@@ -668,6 +685,7 @@ export async function executeApplyPlan(
|
|
|
668
685
|
return {
|
|
669
686
|
status: 'applied',
|
|
670
687
|
liveFingerprint: landed,
|
|
688
|
+
phases,
|
|
671
689
|
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
672
690
|
fkOrphans: orphans,
|
|
673
691
|
};
|
|
@@ -238,6 +238,30 @@ export function catchUpRerunCommand(flags: Record<string, string>): string {
|
|
|
238
238
|
return parts.join(' ');
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Wall time, in ms, of each db:apply phase that ran. */
|
|
242
|
+
export interface ApplyPhaseTimings {
|
|
243
|
+
verify?: number;
|
|
244
|
+
state?: number;
|
|
245
|
+
derived?: number;
|
|
246
|
+
postChecks?: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* One line: the wall time of each phase that ran, in order, or null when none did. Printed beside
|
|
251
|
+
* the receipt, never inside it, so the receipt's contract is unchanged.
|
|
252
|
+
*/
|
|
253
|
+
export function renderPhaseTimings(phases: ApplyPhaseTimings): string | null {
|
|
254
|
+
const named: Array<[string, number | undefined]> = [
|
|
255
|
+
['descent and fingerprint verify', phases.verify],
|
|
256
|
+
['state transaction', phases.state],
|
|
257
|
+
['derived catch-up', phases.derived],
|
|
258
|
+
['post-checks', phases.postChecks],
|
|
259
|
+
];
|
|
260
|
+
const ran = named.filter((entry): entry is [string, number] => entry[1] !== undefined);
|
|
261
|
+
if (ran.length === 0) return null;
|
|
262
|
+
return `phases: ${ran.map(([name, ms]) => `${name} ${(ms / 1000).toFixed(2)}s`).join(', ')}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
241
265
|
/** What the catch-up did, or what to say about the fact that it did not. */
|
|
242
266
|
type CatchUpReport = { ok: true; statements: string[] } | { ok: false; lines: string[] };
|
|
243
267
|
|
|
@@ -261,10 +285,12 @@ async function runCatchUp(
|
|
|
261
285
|
applied: number,
|
|
262
286
|
onRefusal: 'exit' | 'throw',
|
|
263
287
|
declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
|
|
288
|
+
phases?: ApplyPhaseTimings,
|
|
264
289
|
): Promise<CatchUpReport> {
|
|
265
290
|
const verbose = flags.verbose === 'true';
|
|
266
291
|
try {
|
|
267
292
|
const outcome = await catchUpDerived(venue, {
|
|
293
|
+
...(phases ? { onPhase: (phase: 'derived' | 'post-checks', ms: number) => { phases[phase === 'derived' ? 'derived' : 'postChecks'] = ms; } } : {}),
|
|
268
294
|
...(declared !== undefined ? { declared } : {}),
|
|
269
295
|
...(flags.models ? { models: flags.models } : {}),
|
|
270
296
|
onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
|
|
@@ -336,6 +362,7 @@ async function catchUpAfterApply(
|
|
|
336
362
|
venue: CatchUpVenue,
|
|
337
363
|
flags: Record<string, string>,
|
|
338
364
|
declared?: Awaited<ReturnType<typeof loadDeclaredDerived>>,
|
|
365
|
+
phases?: ApplyPhaseTimings,
|
|
339
366
|
): Promise<CatchUpReport> {
|
|
340
367
|
if (!LANDED.has(result.status)) return { ok: true, statements: [] };
|
|
341
368
|
if (flags['state-only'] === 'true') return { ok: true, statements: [] };
|
|
@@ -345,7 +372,7 @@ async function catchUpAfterApply(
|
|
|
345
372
|
const applied = result.status === 'already-applied'
|
|
346
373
|
? 0
|
|
347
374
|
: classifyGeneratedStatements((plan.statements ?? []).filter((s) => !unapplied.has(s))).executable.length;
|
|
348
|
-
return runCatchUp(venue, flags, applied, 'throw', declared);
|
|
375
|
+
return runCatchUp(venue, flags, applied, 'throw', declared, phases);
|
|
349
376
|
}
|
|
350
377
|
|
|
351
378
|
/**
|
|
@@ -521,7 +548,9 @@ async function applyPlanViaStage(
|
|
|
521
548
|
const declared = await loadDeclaredDerived(flags.models);
|
|
522
549
|
const unclassified = unclassifiedGranteesWarning(declared?.models ?? [], lifecycleRolesOf(declared));
|
|
523
550
|
if (unclassified) warn(unclassified);
|
|
524
|
-
|
|
551
|
+
// The plan's own REVOKEs resolve the grant findings they remove: the state transaction runs them
|
|
552
|
+
// before the catch-up creates any lifecycle function, and the post-check reads the catalog after.
|
|
553
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [], lifecycleRolesOf(declared), plan.statements)
|
|
525
554
|
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
|
|
526
555
|
if (lifecycleRefusal) {
|
|
527
556
|
fail(lifecycleRefusal);
|
|
@@ -537,6 +566,10 @@ async function applyPlanViaStage(
|
|
|
537
566
|
// answered by two containers at two moments. Read the stage twice and refuse a
|
|
538
567
|
// disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
|
|
539
568
|
// refusal must exit as itself, not as "Apply failed".
|
|
569
|
+
// Wall times as this CLI sees them: the reads, descent and authority before the write; the write
|
|
570
|
+
// invoke (the ops Lambda's own lock, gates and transaction); then the catch-up's two halves.
|
|
571
|
+
const phases: ApplyPhaseTimings = {};
|
|
572
|
+
const verifyStarted = Date.now();
|
|
540
573
|
step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
|
|
541
574
|
const readSession = lambdaSessionRunner(region, fn);
|
|
542
575
|
const readPair = await readStageStateTwice(readSession).catch((err: any) => {
|
|
@@ -628,6 +661,8 @@ async function applyPlanViaStage(
|
|
|
628
661
|
}
|
|
629
662
|
|
|
630
663
|
step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
|
|
664
|
+
const stateStarted = Date.now();
|
|
665
|
+
phases.verify = stateStarted - verifyStarted;
|
|
631
666
|
const result: any = await invokeAction(region, fn, 'db:apply', {
|
|
632
667
|
plan,
|
|
633
668
|
// q1-contract-version direction A: the server refuses on exact mismatch, so a payload-key or
|
|
@@ -643,6 +678,7 @@ async function applyPlanViaStage(
|
|
|
643
678
|
...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
|
|
644
679
|
stage: flags.stage,
|
|
645
680
|
});
|
|
681
|
+
phases.state = Date.now() - stateStarted;
|
|
646
682
|
if (result?.error) {
|
|
647
683
|
fail(`Apply failed: ${result.error}`);
|
|
648
684
|
process.exit(1);
|
|
@@ -670,8 +706,10 @@ async function applyPlanViaStage(
|
|
|
670
706
|
}
|
|
671
707
|
|
|
672
708
|
// Then the functions, views and triggers — on the same stage, credential-free, always.
|
|
673
|
-
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared);
|
|
709
|
+
const catchUp = await catchUpAfterApply(landedResult!, plan, { kind: 'stage', stage: flags.stage }, flags, declared, phases);
|
|
674
710
|
exitOnCatchUpFailure(catchUp);
|
|
711
|
+
const timings = LANDED.has(landedResult!.status) ? renderPhaseTimings(phases) : null;
|
|
712
|
+
if (timings) info(timings);
|
|
675
713
|
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
676
714
|
const advisoryModels = await modelsForAdvisory(landedResult!, flags);
|
|
677
715
|
reportApplyResult(landedResult!, plan, {
|
|
@@ -960,7 +998,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
960
998
|
// Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
|
|
961
999
|
const unclassified = unclassifiedGranteesWarning((await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
|
|
962
1000
|
if (unclassified) warn(unclassified);
|
|
963
|
-
|
|
1001
|
+
// As on the stage lane: a finding this plan's own REVOKE removes is not a refusal.
|
|
1002
|
+
const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements)
|
|
964
1003
|
?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
|
|
965
1004
|
if (lifecycleRefusal) {
|
|
966
1005
|
fail(lifecycleRefusal);
|
|
@@ -1087,6 +1126,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1087
1126
|
|
|
1088
1127
|
let landed: ApplyPlanResult | undefined;
|
|
1089
1128
|
let catchUp: CatchUpReport = { ok: true, statements: [] };
|
|
1129
|
+
const phases: ApplyPhaseTimings = {};
|
|
1090
1130
|
try {
|
|
1091
1131
|
// The edge as hashes — internal bookkeeping, not what a reader came for. Verbose only.
|
|
1092
1132
|
if (flags.verbose === 'true') info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
@@ -1117,7 +1157,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1117
1157
|
{ verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
|
|
1118
1158
|
async () => {
|
|
1119
1159
|
// Re-checked under the lease, where the writes happen.
|
|
1120
|
-
const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
|
|
1160
|
+
const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()), plan.statements);
|
|
1121
1161
|
if (leasedRefusal) {
|
|
1122
1162
|
fail(leasedRefusal);
|
|
1123
1163
|
process.exit(1);
|
|
@@ -1179,7 +1219,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1179
1219
|
// they read, and no second operator can slip in between the two halves. Its failure
|
|
1180
1220
|
// is carried out as a report, never thrown: the plan landed, and "rolled back" would
|
|
1181
1221
|
// be a lie.
|
|
1182
|
-
|
|
1222
|
+
if (result.phases) Object.assign(phases, { verify: result.phases.verifyMs, state: result.phases.stateMs });
|
|
1223
|
+
const report = await catchUpAfterApply(result, plan, { kind: 'direct', runner, session }, flags, undefined, phases);
|
|
1183
1224
|
return { result, catchUp: report };
|
|
1184
1225
|
},
|
|
1185
1226
|
);
|
|
@@ -1199,6 +1240,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
1199
1240
|
}
|
|
1200
1241
|
|
|
1201
1242
|
exitOnCatchUpFailure(catchUp);
|
|
1243
|
+
const timings = LANDED.has(landed!.status) ? renderPhaseTimings(phases) : null;
|
|
1244
|
+
if (timings) info(timings);
|
|
1202
1245
|
const declaredGrantees = await declaredGranteesForReceipt(plan, flags);
|
|
1203
1246
|
const advisoryModels = await modelsForAdvisory(landed!, flags);
|
|
1204
1247
|
reportApplyResult(landed!, plan, {
|
|
@@ -63,7 +63,7 @@ import { resolveModelsPath } from '../models-path';
|
|
|
63
63
|
import { resolveConfig, opsFunction } from '../config';
|
|
64
64
|
import { resolveOperatorUrlViaStage } from '../direct-venue';
|
|
65
65
|
import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
|
|
66
|
-
import {
|
|
66
|
+
import { lifecycleApplyReport, lifecycleProvisionFirst, lifecycleRolesOf, unclassifiedGranteesWarning } from '../lifecycle-preflight';
|
|
67
67
|
import { loadModels } from './db-generate';
|
|
68
68
|
import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
|
|
69
69
|
import { introspectDerived } from '../derived-introspect';
|
|
@@ -71,7 +71,7 @@ import { planReconcile, type ReconcilePlan } from '../derived-plan';
|
|
|
71
71
|
import { checkFails, buildReconcileReport } from './db-reconcile';
|
|
72
72
|
import { governedRoleSet, ungovernedGrants } from '../authz-reconcile';
|
|
73
73
|
import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline';
|
|
74
|
-
import { alterTypeStatementTargets,
|
|
74
|
+
import { alterTypeStatementTargets, mintStateDependents, renderStateDependentLine } from '../alter-type-dependents';
|
|
75
75
|
import { compileTableContract } from '../authz-compile';
|
|
76
76
|
import { reportPipelineLastRun } from './pipeline-run';
|
|
77
77
|
import { renderPlanReport } from '../plain-output';
|
|
@@ -241,11 +241,13 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
241
241
|
// The modules' role declaration, as db:apply reads it. A barrel that does not compose leaves it
|
|
242
242
|
// undeclared here (the check as before); the paths that need the derived layer report the error.
|
|
243
243
|
const lifecycleRoles = lifecycleRolesOf(await loadDeclaredDerived(flags.models).catch(() => null));
|
|
244
|
-
|
|
244
|
+
// First, before anything else is said: what db:provision must create or fix. Nothing below
|
|
245
|
+
// can apply until it has run, and the fix is a verb, not SQL.
|
|
246
|
+
const provisionFirst = await lifecycleProvisionFirst(runner, models,
|
|
247
|
+
flags.stage ? `everystack db:provision --stage ${flags.stage}` : 'everystack db:provision --database-url <this database>');
|
|
248
|
+
if (provisionFirst) warn(provisionFirst);
|
|
245
249
|
const unclassified = unclassifiedGranteesWarning(models, lifecycleRoles);
|
|
246
250
|
if (unclassified) warn(unclassified);
|
|
247
|
-
// db:plan only reads, so it mints the plan anyway; db:apply runs the same check and refuses.
|
|
248
|
-
if (lifecycleNote) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycleNote}`);
|
|
249
251
|
let snapshot: SchemaSnapshot;
|
|
250
252
|
let contract: AuthzContract;
|
|
251
253
|
if (dbSource.kind === 'url') {
|
|
@@ -318,22 +320,34 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
318
320
|
process.exit(1);
|
|
319
321
|
}
|
|
320
322
|
|
|
323
|
+
// The lifecycle catalog check db:apply runs, over THIS plan: a finding the plan's own REVOKE
|
|
324
|
+
// removes is listed as resolved; the rest will block the apply. db:plan only reads, so it
|
|
325
|
+
// mints the plan anyway. Read after the mint because the plan is half of the answer.
|
|
326
|
+
const lifecycle = await lifecycleApplyReport(runner, models, lifecycleRoles, plan.statements);
|
|
327
|
+
if (lifecycle.resolved) info(lifecycle.resolved);
|
|
328
|
+
if (lifecycle.refusal) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycle.refusal}`);
|
|
329
|
+
|
|
321
330
|
// The edge is pure, but this decision belongs to mint: only this command holds both the
|
|
322
331
|
// checkout's declared functions and the live catalog. Embedding the exact missing CREATEs
|
|
323
332
|
// lets every apply lane enact the reviewed answer without reading source again.
|
|
324
333
|
plan.statePrerequisites = await mintStatePrerequisites(runner, session, plan.statements, declaredDerived?.objects ?? []);
|
|
325
334
|
|
|
326
|
-
// The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
335
|
+
// The plan lane's contract: what it mints, it can apply. A SET DATA TYPE on a column a view
|
|
336
|
+
// or matview binds fails at apply ("cannot alter type of a column used by a view or rule").
|
|
337
|
+
// The declared ones ride on the plan: the state transaction drops them, changes the type and
|
|
338
|
+
// re-creates them from this checkout before COMMIT. Anything the platform cannot re-create
|
|
339
|
+
// refuses here, by name, while there is still a live catalog to ask.
|
|
330
340
|
const alterTargets = alterTypeStatementTargets(plan.statements);
|
|
331
341
|
if (alterTargets.length > 0) {
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
fail(`Mint refused: ${
|
|
342
|
+
const dependents = await mintStateDependents(runner, session, alterTargets, declaredDerived?.objects ?? []);
|
|
343
|
+
if ('refusal' in dependents) {
|
|
344
|
+
fail(`Mint refused: ${dependents.refusal}`);
|
|
335
345
|
process.exit(1);
|
|
336
346
|
}
|
|
347
|
+
if (dependents.dependents.length > 0) {
|
|
348
|
+
plan.stateDependents = dependents.dependents;
|
|
349
|
+
for (const dependent of dependents.dependents) info(renderStateDependentLine(dependent));
|
|
350
|
+
}
|
|
337
351
|
}
|
|
338
352
|
|
|
339
353
|
// The class rides on the ARTIFACT, not only the terminal — a reviewer reading
|
|
@@ -31,7 +31,7 @@ import fs from 'node:fs/promises';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import type { ModelDescriptor } from '@everystack/model';
|
|
33
33
|
import { introspectContract, type QueryRunner } from '../authz-contract';
|
|
34
|
-
import { catalogInputsFromModels,
|
|
34
|
+
import { catalogInputsFromModels, lifecycleApplyReport, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecycleRolesOf, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
|
|
35
35
|
import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
|
|
36
36
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
|
|
37
37
|
import { generateMigrationSql, unmodeledTables } from '../migration-generate';
|
|
@@ -98,6 +98,8 @@ export interface SyncHooks {
|
|
|
98
98
|
onStatePlan?: (statements: string[], classified: ClassifiedStatements, pass: number) => void;
|
|
99
99
|
/** Fires after the state layer applied and verified, before the compute reconcile. */
|
|
100
100
|
onStateDone?: (state: StateSyncOutcome) => void;
|
|
101
|
+
/** Fires before anything executes when the state diff's own REVOKEs resolve lifecycle findings (one line). */
|
|
102
|
+
onLifecycleResolved?: (line: string) => void;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
export interface SyncRun {
|
|
@@ -140,6 +142,7 @@ async function syncOnce(
|
|
|
140
142
|
options: SyncOptions,
|
|
141
143
|
hooks: SyncHooks,
|
|
142
144
|
pass: number,
|
|
145
|
+
preflight?: (statements: readonly string[]) => Promise<void>,
|
|
143
146
|
): Promise<SyncRun> {
|
|
144
147
|
const current = await introspectSchema(session);
|
|
145
148
|
const liveAuthz = await introspectContract(session, contractFunctionRow, FUNCTIONS_SQL);
|
|
@@ -147,6 +150,8 @@ async function syncOnce(
|
|
|
147
150
|
allowDrops: options.allowDrops, liveAuthz, sequences: options.sequences,
|
|
148
151
|
governedRoles: options.governedRoles, extensions: options.extensions,
|
|
149
152
|
});
|
|
153
|
+
// Read-only, before anything is written: it needs the statements this pass will run.
|
|
154
|
+
await preflight?.(statements);
|
|
150
155
|
const classified = classifyGeneratedStatements(statements);
|
|
151
156
|
hooks.onStatePlan?.(statements, classified, pass);
|
|
152
157
|
// A held drop of a key into a lifecycle table leaves a key the after-state catalog check refuses,
|
|
@@ -244,13 +249,18 @@ export async function executeSync(
|
|
|
244
249
|
hooks: SyncHooks = {},
|
|
245
250
|
): Promise<SyncRun> {
|
|
246
251
|
// State runs before compute, so without this a lifecycle table would land with no transition trigger.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// It reads the first pass's statements: a finding their own REVOKE removes is resolved, not refused
|
|
253
|
+
// (the state batch runs before the reconcile creates any lifecycle function, which re-checks).
|
|
254
|
+
const preflight = async (statements: readonly string[]): Promise<void> => {
|
|
255
|
+
const lifecycle = await lifecycleApplyReport(runner, models, options.lifecycleRoles, statements);
|
|
256
|
+
if (lifecycle.refusal) throw new Error(lifecycle.refusal);
|
|
257
|
+
if (lifecycle.resolved) hooks.onLifecycleResolved?.(lifecycle.resolved);
|
|
258
|
+
// Before anything is written, so before both places a pass creates derived objects (the
|
|
259
|
+
// functions the state batch calls, then the reconcile).
|
|
260
|
+
const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
|
|
261
|
+
if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
|
|
262
|
+
};
|
|
263
|
+
const first = await syncOnce(runner, session, models, options, hooks, 1, preflight);
|
|
254
264
|
// The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
|
|
255
265
|
// only, so a compute refusal or an unadopted object cannot be settled by looking again —
|
|
256
266
|
// retrying on those would make every brownfield sync pay a second full read (and print its
|
|
@@ -487,6 +497,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
|
|
|
487
497
|
onStateDone: () => {
|
|
488
498
|
if (declaredDb?.objects.length) step('Reconciling the declared derived layer...');
|
|
489
499
|
},
|
|
500
|
+
onLifecycleResolved: (line) => info(line),
|
|
490
501
|
},
|
|
491
502
|
);
|
|
492
503
|
|
|
@@ -83,6 +83,8 @@ export interface CatchUpOptions {
|
|
|
83
83
|
* Required for the same reason as `onRefusal`: only the caller knows what it wrote.
|
|
84
84
|
*/
|
|
85
85
|
stateApplied: boolean;
|
|
86
|
+
/** Wall time, in ms, of each phase that ran: the owner gate and reconcile, then the post-check. */
|
|
87
|
+
onPhase?: (phase: 'derived' | 'post-checks', ms: number) => void;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export interface CatchUpOutcome {
|
|
@@ -124,6 +126,7 @@ export async function catchUpDerived(
|
|
|
124
126
|
gitRef: currentGitRef(),
|
|
125
127
|
};
|
|
126
128
|
|
|
129
|
+
const derivedStarted = Date.now();
|
|
127
130
|
// Before the first derived write, judge the owners the reconcile will give the declared trigger
|
|
128
131
|
// functions on lifecycle tables.
|
|
129
132
|
const config = venue.kind === 'stage' ? await resolveConfig(venue.stage) : undefined;
|
|
@@ -171,11 +174,14 @@ export async function catchUpDerived(
|
|
|
171
174
|
// A refusal is not a soft outcome here. db:reconcile prints it and exits 1; the catch-up did
|
|
172
175
|
// not land, so db:apply must say so and exit the same way.
|
|
173
176
|
if (run.refusal) throw new CatchUpError(run.refusal);
|
|
177
|
+
opts.onPhase?.('derived', Date.now() - derivedStarted);
|
|
174
178
|
|
|
175
179
|
// Verification: the live catalog check again, over what the reconcile just made live. On the
|
|
176
180
|
// operator session, where the commit is: db:query may read a replica that has not caught up.
|
|
181
|
+
const postStarted = Date.now();
|
|
177
182
|
const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? [], lifecycleRolesOf(declared));
|
|
178
183
|
if (post) throw new LifecycleFindingsError(post);
|
|
184
|
+
opts.onPhase?.('post-checks', Date.now() - postStarted);
|
|
179
185
|
|
|
180
186
|
return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
|
|
181
187
|
}
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -53,8 +53,10 @@ import { compileTableRenames, compileTableMoves, compileRenames } from './schema
|
|
|
53
53
|
* A v3 reader does not know how to enact or record those prerequisites, so the file formats are
|
|
54
54
|
* not compatible. The existing db:apply version refusal makes the mismatch a re-mint before any
|
|
55
55
|
* write, rather than letting a peer silently omit part of the reviewed artifact.
|
|
56
|
+
* v7 adds `stateDependents`: declared views and matviews a column type change binds, dropped and
|
|
57
|
+
* re-created inside the state transaction. A v6 reader would skip them and the ALTER would fail.
|
|
56
58
|
*/
|
|
57
|
-
export const PLAN_VERSION =
|
|
59
|
+
export const PLAN_VERSION = 7;
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
62
|
* The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
|
|
@@ -119,8 +121,12 @@ export const PLAN_VERSION = 6;
|
|
|
119
121
|
*
|
|
120
122
|
* v10 applies the reviewed prerequisite search path and restores the transaction's original
|
|
121
123
|
* path before state DDL and grant verification. A v9 stage could bind defaults to other schemas.
|
|
124
|
+
*
|
|
125
|
+
* v11 (milestone upgrade-no-hand-sql): the plan gained `stateDependents`, which the server drops
|
|
126
|
+
* before the state edge and re-creates after it, in the same transaction, with provenance. A v10
|
|
127
|
+
* stage would skip them, and its ALTER COLUMN TYPE would fail on the view that binds the column.
|
|
122
128
|
*/
|
|
123
|
-
export const APPLY_CONTRACT_VERSION =
|
|
129
|
+
export const APPLY_CONTRACT_VERSION = 11;
|
|
124
130
|
|
|
125
131
|
/** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
|
|
126
132
|
export interface PlanClassification {
|
|
@@ -167,6 +173,30 @@ export type StatePrerequisite = {
|
|
|
167
173
|
};
|
|
168
174
|
};
|
|
169
175
|
|
|
176
|
+
/**
|
|
177
|
+
* A declared view or matview that binds a column this plan changes the type of. PostgreSQL refuses
|
|
178
|
+
* `ALTER COLUMN ... TYPE` while it exists, so the state transaction drops it (dependents first),
|
|
179
|
+
* runs the edge, and re-creates it from the reviewed checkout SQL before COMMIT: it is never absent.
|
|
180
|
+
* Listed in creation (dependency) order; the drops run in reverse.
|
|
181
|
+
*/
|
|
182
|
+
export type StateDependent = {
|
|
183
|
+
/** `schema.name`. */
|
|
184
|
+
identity: string;
|
|
185
|
+
kind: 'view' | 'materialized view';
|
|
186
|
+
/** The live definition hash read at mint. db:apply refuses, before any write, unless it still holds. */
|
|
187
|
+
expectedDefHash: string;
|
|
188
|
+
/** The exact DROP, without CASCADE: anything the mint did not see that depends on it fails the transaction. */
|
|
189
|
+
dropSql: string;
|
|
190
|
+
/** The exact schema preparation, CREATE, ownership, grant, comment and index SQL db:apply runs. */
|
|
191
|
+
sql: string;
|
|
192
|
+
/** As StatePrerequisite.searchPath. */
|
|
193
|
+
searchPath: string | null;
|
|
194
|
+
grantContract: StatePrerequisite['grantContract'];
|
|
195
|
+
provenance: { srcHash: string; bodyHash?: string; kind: 'view' | 'materialized view'; dropSql?: string };
|
|
196
|
+
/** Matview only: pg_total_relation_size at mint. The re-create repopulates it, inside the transaction. */
|
|
197
|
+
bytes?: number;
|
|
198
|
+
};
|
|
199
|
+
|
|
170
200
|
export interface EdgePlan {
|
|
171
201
|
v: number;
|
|
172
202
|
/** The target's exact live fingerprint at mint time — the lock. */
|
|
@@ -234,6 +264,8 @@ export interface EdgePlan {
|
|
|
234
264
|
* before this field existed have none; an absent field means no prerequisite was recorded.
|
|
235
265
|
*/
|
|
236
266
|
statePrerequisites?: readonly StatePrerequisite[];
|
|
267
|
+
/** Declared views and matviews the state transaction drops and re-creates around a type change. */
|
|
268
|
+
stateDependents?: readonly StateDependent[];
|
|
237
269
|
executable: number;
|
|
238
270
|
/** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
|
|
239
271
|
destructive: number;
|
|
@@ -6,6 +6,8 @@ import { compileTableContract } from './authz-compile';
|
|
|
6
6
|
import { reconcileOwner, triggerFunctionOf } from './derived-apply';
|
|
7
7
|
import { isSessionError, type SessionRunner } from './session';
|
|
8
8
|
import { parseQualified, type SourceObject } from './derived-source';
|
|
9
|
+
import { quoteQualified } from './pg-ident';
|
|
10
|
+
import { ALWAYS_GOVERNED } from './authz-reconcile';
|
|
9
11
|
import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
|
|
10
12
|
|
|
11
13
|
export const LIFECYCLE_LOG_REFUSAL = 'everystack.destroy_log is missing; run db:provision before db:apply';
|
|
@@ -32,20 +34,96 @@ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unkno
|
|
|
32
34
|
/**
|
|
33
35
|
* The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
|
|
34
36
|
* rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
|
|
37
|
+
*
|
|
38
|
+
* `planStatements`: the reviewed plan this check runs before. A finding the plan resolves is not
|
|
39
|
+
* a refusal (see planRevokedGrants). The plan's state transaction commits before the derived
|
|
40
|
+
* catch-up creates any lifecycle function, and the post-check reads the catalog again after it.
|
|
35
41
|
*/
|
|
36
42
|
export async function lifecycleApplyRefusal(
|
|
37
43
|
runner: QueryRunner,
|
|
38
44
|
models: readonly ModelDescriptor[],
|
|
39
45
|
roles: LifecycleRoleDeclaration = {},
|
|
46
|
+
planStatements: readonly string[] = [],
|
|
40
47
|
): Promise<string | null> {
|
|
41
|
-
|
|
48
|
+
return (await lifecycleApplyReport(runner, models, roles, planStatements)).refusal;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** lifecycleApplyRefusal, plus the findings the plan resolves (one line, or null when none). */
|
|
52
|
+
export async function lifecycleApplyReport(
|
|
53
|
+
runner: QueryRunner,
|
|
54
|
+
models: readonly ModelDescriptor[],
|
|
55
|
+
roles: LifecycleRoleDeclaration = {},
|
|
56
|
+
planStatements: readonly string[] = [],
|
|
57
|
+
): Promise<{ refusal: string | null; resolved: string | null }> {
|
|
58
|
+
if (!models.some(model => model.lifecycle === true)) return { refusal: null, resolved: null };
|
|
42
59
|
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
60
|
+
const shape = lifecycleLogRowRefusal(log);
|
|
61
|
+
if (shape) return { refusal: shape, resolved: null };
|
|
43
62
|
// Before the plan: a modeled table's keys are the plan's to converge (the models declare each
|
|
44
63
|
// one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
|
|
45
64
|
const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
|
|
46
|
-
|
|
65
|
+
const report = await lifecycleCatalogFindings(runner, { ...catalogInputsFromModels(models, roles), modeledTables },
|
|
66
|
+
{ planRevokes: planRevokedGrants(planStatements) });
|
|
67
|
+
return { refusal: catalogRefusalText(report), resolved: resolvedLine(report.resolved) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What `db:provision` must do before a lifecycle plan can apply, as one line naming the command,
|
|
72
|
+
* or null. The same reads the apply refuses on: everystack.destroy_log missing or out of date
|
|
73
|
+
* (lifecycleLogRowRefusal), and the lifecycle role missing or holding LOGIN or BYPASSRLS (the
|
|
74
|
+
* attributes db:provision resets). `command` is the db:provision invocation for this target.
|
|
75
|
+
*/
|
|
76
|
+
export async function lifecycleProvisionFirst(
|
|
77
|
+
runner: QueryRunner,
|
|
78
|
+
models: readonly ModelDescriptor[],
|
|
79
|
+
command: string,
|
|
80
|
+
options: { lifecycleRole?: string } = {},
|
|
81
|
+
): Promise<string | null> {
|
|
82
|
+
if (!models.some(model => model.lifecycle === true)) return null;
|
|
83
|
+
const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
|
|
84
|
+
const reasons: string[] = [];
|
|
85
|
+
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
86
|
+
if (log?.present !== true) reasons.push('everystack.destroy_log is missing');
|
|
87
|
+
else if (log.current !== true) reasons.push('everystack.destroy_log is out of date');
|
|
88
|
+
const [role] = await runner(roleSql(lc));
|
|
89
|
+
if (!truthy(role?.present)) reasons.push(`role ${lc} is missing`);
|
|
90
|
+
else {
|
|
91
|
+
const wrong = (['login', 'bypassrls'] as const).filter(key => truthy(role[key])).map(key => key.toUpperCase());
|
|
92
|
+
if (wrong.length > 0) reasons.push(`role ${lc} has ${wrong.join(', ')}`);
|
|
93
|
+
}
|
|
94
|
+
if (reasons.length === 0) return null;
|
|
95
|
+
return `run db:provision first: ${reasons.join('; ')}. Run: ${command}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The grants a reviewed plan revokes, one key per (table, grantee, privilege, column) — the exact
|
|
100
|
+
* statements the reconciler emits (authz-reconcile.ts reconcileGrants, reconcileColumnGrants):
|
|
101
|
+
*
|
|
102
|
+
* REVOKE REFERENCES, TRIGGER, TRUNCATE ON public.t FROM admin;
|
|
103
|
+
* REVOKE REFERENCES (a, b) ON public.t FROM admin;
|
|
104
|
+
*
|
|
105
|
+
* Anything else (a sequence, a statement this does not recognise) resolves nothing, so an
|
|
106
|
+
* unrecognised form can only leave a finding standing, never hide one.
|
|
107
|
+
*/
|
|
108
|
+
export function planRevokedGrants(statements: readonly string[]): ReadonlySet<string> {
|
|
109
|
+
const keys = new Set<string>();
|
|
110
|
+
for (const statement of statements) {
|
|
111
|
+
const column = /^REVOKE ([A-Z]+) \(([^()]+)\) ON (.+) FROM ([^;]+);$/.exec(statement);
|
|
112
|
+
if (column) {
|
|
113
|
+
for (const name of column[2].split(', ')) keys.add(revokeKey(column[3], column[4], column[1], name));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const table = /^REVOKE ([A-Z]+(?:, [A-Z]+)*) ON (.+) FROM ([^;]+);$/.exec(statement);
|
|
117
|
+
if (!table || table[2].startsWith('SEQUENCE ')) continue;
|
|
118
|
+
for (const privilege of table[1].split(', ')) keys.add(revokeKey(table[2], table[3], privilege));
|
|
119
|
+
}
|
|
120
|
+
return keys;
|
|
47
121
|
}
|
|
48
122
|
|
|
123
|
+
/** `table` as the reconciler emits it (quoteQualified of the contract's `schema.table`). */
|
|
124
|
+
const revokeKey = (table: string, grantee: string, privilege: string, column?: string): string =>
|
|
125
|
+
JSON.stringify([table, grantee, privilege, column ?? null]);
|
|
126
|
+
|
|
49
127
|
/**
|
|
50
128
|
* What the modules declare about the roles, beside the models (DeclaredDerived). The catalog check
|
|
51
129
|
* reads it to tell a definer role the models grant to apart from a role a request can become.
|
|
@@ -129,6 +207,12 @@ export interface LifecycleCatalogInputs {
|
|
|
129
207
|
requestRoles?: readonly string[];
|
|
130
208
|
/** Every declared derived-object owner. Read only when `requestRoles` is present. */
|
|
131
209
|
declaredOwners?: readonly string[];
|
|
210
|
+
/**
|
|
211
|
+
* The roles a fresh plan governs (ALWAYS_GOVERNED and every grantee of the models): the plan
|
|
212
|
+
* revokes any REFERENCES, TRIGGER or TRUNCATE such a role holds on a lifecycle table, since a
|
|
213
|
+
* lifecycle model cannot declare one. Absent (a db:reconcile payload): every rule-3 fix is SQL.
|
|
214
|
+
*/
|
|
215
|
+
governedRoles?: readonly string[];
|
|
132
216
|
}
|
|
133
217
|
|
|
134
218
|
/** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
|
|
@@ -175,15 +259,17 @@ const notApplicationRole = (lc: string) => (role: string): boolean => role !== '
|
|
|
175
259
|
export function catalogInputsFromModels(
|
|
176
260
|
models: readonly ModelDescriptor[],
|
|
177
261
|
declaration: LifecycleRoleDeclaration = {},
|
|
178
|
-
): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[] } {
|
|
262
|
+
): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[]; governedRoles: string[] } {
|
|
179
263
|
const roles = new Set<string>();
|
|
264
|
+
const governed = new Set<string>(ALWAYS_GOVERNED);
|
|
180
265
|
for (const model of models) {
|
|
181
266
|
const contract = compileTableContract(model);
|
|
182
|
-
for (const role of Object.keys(contract.grants)) roles.add(role);
|
|
183
|
-
for (const role of Object.keys(contract.columnGrants ?? {})) roles.add(role);
|
|
267
|
+
for (const role of Object.keys(contract.grants)) { roles.add(role); governed.add(role); }
|
|
268
|
+
for (const role of Object.keys(contract.columnGrants ?? {})) { roles.add(role); governed.add(role); }
|
|
184
269
|
for (const policy of contract.policies) for (const role of policy.roles) roles.add(role);
|
|
185
270
|
}
|
|
186
271
|
return {
|
|
272
|
+
governedRoles: [...governed].sort(),
|
|
187
273
|
lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
|
|
188
274
|
applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
|
|
189
275
|
dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
|
|
@@ -392,23 +478,27 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
|
|
|
392
478
|
* Rule 3. TRIGGER, REFERENCES or TRUNCATE on a lifecycle table, or REFERENCES on one of its
|
|
393
479
|
* columns, held by anyone but its owner. On everystack.destroy_log, any privilege but SELECT
|
|
394
480
|
* (table or column) held by anyone but its owner: a writer could forge or remove a marker.
|
|
481
|
+
* One row per grantor: PostgreSQL revokes only the grants made by the role REVOKE runs as, and
|
|
482
|
+
* runs a member's (or a superuser's) REVOKE as the owner, so who granted it decides what removes it.
|
|
395
483
|
*/
|
|
396
484
|
const aclSql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", ${granteeName('a.grantee')} AS grantee,
|
|
397
|
-
a.privilege_type AS privilege, NULL::text AS "column"
|
|
485
|
+
a.privilege_type AS privilege, NULL::text AS "column", a.grantor = c.relowner AS owner_granted,
|
|
486
|
+
pg_catalog.pg_get_userbyid(a.grantor)::text AS grantor
|
|
398
487
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
399
488
|
CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
|
|
400
489
|
WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
|
|
401
490
|
((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type IN ('TRIGGER', 'REFERENCES', 'TRUNCATE'))
|
|
402
491
|
OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
|
|
403
492
|
UNION ALL
|
|
404
|
-
SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text
|
|
493
|
+
SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text,
|
|
494
|
+
a.grantor = c.relowner, pg_catalog.pg_get_userbyid(a.grantor)::text
|
|
405
495
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
406
496
|
JOIN pg_catalog.pg_attribute t ON t.attrelid = c.oid AND t.attnum > 0 AND NOT t.attisdropped
|
|
407
497
|
CROSS JOIN LATERAL pg_catalog.aclexplode(t.attacl) a
|
|
408
498
|
WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
|
|
409
499
|
((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type = 'REFERENCES')
|
|
410
500
|
OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
|
|
411
|
-
ORDER BY 1, 4 NULLS FIRST, 3, 2`;
|
|
501
|
+
ORDER BY 1, 4 NULLS FIRST, 3, 2, 6`;
|
|
412
502
|
|
|
413
503
|
/**
|
|
414
504
|
* Rule 4. Each marker function: SECURITY DEFINER, owned by destroy_log's owner, EXECUTE for the
|
|
@@ -520,24 +610,52 @@ export async function lifecycleCatalogRefusal(
|
|
|
520
610
|
inputs: LifecycleCatalogInputs,
|
|
521
611
|
options: { lifecycleRole?: string } = {},
|
|
522
612
|
): Promise<string | null> {
|
|
523
|
-
|
|
613
|
+
return catalogRefusalText(await lifecycleCatalogFindings(runner, inputs, options));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const catalogRefusalText = ({ findings, resolved }: CatalogFindings): string | null => {
|
|
524
617
|
if (findings.length === 0) return null;
|
|
525
|
-
|
|
618
|
+
const also = resolved.length === 0 ? '' : ` (the plan resolves ${resolved.length} more)`;
|
|
619
|
+
return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'})${also}; `
|
|
526
620
|
+ `fix each, then re-run:\n${findingLines(findings)}`;
|
|
527
|
-
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The resolved rule-3 findings as one line: per grantee and privilege, the tables the plan
|
|
625
|
+
* revokes it on. Null when the plan resolves none.
|
|
626
|
+
*/
|
|
627
|
+
const resolvedLine = (resolved: readonly ResolvedGrant[]): string | null => {
|
|
628
|
+
if (resolved.length === 0) return null;
|
|
629
|
+
const byGrant = new Map<string, string[]>();
|
|
630
|
+
for (const r of resolved) {
|
|
631
|
+
const grant = `${r.privilege}${r.column ? ` (${r.column})` : ''} from ${r.grantee}`;
|
|
632
|
+
byGrant.set(grant, [...(byGrant.get(grant) ?? []), r.table]);
|
|
633
|
+
}
|
|
634
|
+
const parts = [...byGrant].map(([grant, tables]) => `${grant} on ${[...new Set(tables)].sort().join(', ')}`);
|
|
635
|
+
return `the plan resolves ${countFindings(resolved.length)}: it revokes ${parts.join('; ')}, before any lifecycle function is created.`;
|
|
636
|
+
};
|
|
528
637
|
|
|
529
638
|
const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
|
|
530
639
|
const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
|
|
531
640
|
|
|
532
|
-
/**
|
|
641
|
+
/** A rule-3 finding the reviewed plan's own REVOKE removes. */
|
|
642
|
+
interface ResolvedGrant { table: string; grantee: string; privilege: string; column: string | null }
|
|
643
|
+
interface CatalogFindings { findings: string[]; resolved: ResolvedGrant[] }
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* lifecycleCatalogRefusal's findings, one string each, without the heading. `planRevokes`
|
|
647
|
+
* (planRevokedGrants): a rule-3 grant on a lifecycle table that the plan revokes, exactly that
|
|
648
|
+
* privilege from that grantee on that table (and column), moves to `resolved`. Nothing else does.
|
|
649
|
+
*/
|
|
533
650
|
async function lifecycleCatalogFindings(
|
|
534
651
|
runner: QueryRunner,
|
|
535
652
|
inputs: LifecycleCatalogInputs,
|
|
536
|
-
options: { lifecycleRole?: string } = {},
|
|
537
|
-
): Promise<
|
|
653
|
+
options: { lifecycleRole?: string; planRevokes?: ReadonlySet<string> } = {},
|
|
654
|
+
): Promise<CatalogFindings> {
|
|
538
655
|
const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
|
|
539
656
|
const tables = [...new Set(inputs.lifecycleTables)].sort();
|
|
540
|
-
|
|
657
|
+
const resolved: ResolvedGrant[] = [];
|
|
658
|
+
if (tables.length === 0) return { findings: [], resolved };
|
|
541
659
|
const set = await applicationRoleSet(runner, inputs, lc);
|
|
542
660
|
const apps = set.apps;
|
|
543
661
|
const findings: string[] = [...set.findings];
|
|
@@ -578,11 +696,36 @@ async function lifecycleCatalogFindings(
|
|
|
578
696
|
findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}${cause ? ` ${cause}` : ''}`);
|
|
579
697
|
}
|
|
580
698
|
|
|
699
|
+
const governed = new Set(inputs.governedRoles ?? []);
|
|
581
700
|
for (const row of await runner(aclSql(tables))) {
|
|
701
|
+
const column = row.column == null ? null : String(row.column);
|
|
582
702
|
const on = row.column == null ? '' : ` (${ident(String(row.column))})`;
|
|
703
|
+
// Granted by another role WITH GRANT OPTION: the plan's REVOKE (run as the owner) and a fresh
|
|
704
|
+
// plan both leave it, so it is never resolved. No verb removes it today (item U6).
|
|
705
|
+
if (!truthy(row.owner_granted)) {
|
|
706
|
+
const table = quoteLabel(String(row.table));
|
|
707
|
+
const grantor = ident(String(row.grantor));
|
|
708
|
+
const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
|
|
709
|
+
// On destroy_log the revoke comes from db:provision, not from a plan.
|
|
710
|
+
const revoker = row.table === 'everystack.destroy_log' ? "db:provision's REVOKE" : "the plan's REVOKE";
|
|
711
|
+
findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}, granted by ${row.grantor} with grant option; `
|
|
712
|
+
+ `${revoker} runs as the owner and cannot remove it. Fix: REVOKE ${row.privilege}${on} ON TABLE ${table} FROM ${who} GRANTED BY ${grantor}; `
|
|
713
|
+
+ `(as ${row.grantor}, a member of it, or a superuser), or REVOKE GRANT OPTION FOR ${row.privilege}${on} ON TABLE ${table} FROM ${grantor} CASCADE;`);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (options.planRevokes && tables.includes(String(row.table))
|
|
717
|
+
&& options.planRevokes.has(revokeKey(quoteQualified(String(row.table)), String(row.grantee), String(row.privilege), column ?? undefined))) {
|
|
718
|
+
resolved.push({ table: String(row.table), grantee: String(row.grantee), privilege: String(row.privilege), column });
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
583
721
|
const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
|
|
584
|
-
|
|
585
|
-
|
|
722
|
+
// A verb where one exists: db:provision revokes every grant on destroy_log but its owner's
|
|
723
|
+
// (ENSURE_DESTROY_LOG_SQL); a fresh plan revokes a governed role's grant on a lifecycle table.
|
|
724
|
+
// SQL only for a role no plan governs, which the plan leaves alone by design.
|
|
725
|
+
const fix = row.table === 'everystack.destroy_log' ? 'run db:provision.'
|
|
726
|
+
: governed.has(String(row.grantee)) ? 'mint a plan (db:plan) and db:apply it; the plan revokes this grant.'
|
|
727
|
+
: `REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`;
|
|
728
|
+
findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. Fix: ${fix}`);
|
|
586
729
|
}
|
|
587
730
|
|
|
588
731
|
for (const row of await runner(markerSql(lc))) {
|
|
@@ -641,7 +784,7 @@ async function lifecycleCatalogFindings(
|
|
|
641
784
|
+ 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
|
|
642
785
|
}
|
|
643
786
|
|
|
644
|
-
return findings;
|
|
787
|
+
return { findings, resolved };
|
|
645
788
|
}
|
|
646
789
|
|
|
647
790
|
/**
|
|
@@ -728,7 +871,7 @@ export async function lifecyclePostApplyRefusal(
|
|
|
728
871
|
if (isSessionError(slot)) throw new Error(slot.message);
|
|
729
872
|
return slot ?? [];
|
|
730
873
|
};
|
|
731
|
-
const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
|
|
874
|
+
const { findings } = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
|
|
732
875
|
if (findings.length === 0) return null;
|
|
733
876
|
return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
|
|
734
877
|
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { introspectDerived } from './derived-introspect';
|
|
|
37
37
|
import { objectGrantDelta } from './derived-plan';
|
|
38
38
|
import { isPureComment, statementBody, statementHead } from './sql-statement';
|
|
39
39
|
import { splitFunctionIdentity } from './pg-argtypes';
|
|
40
|
-
import type { StatePrerequisite } from './edge-plan';
|
|
40
|
+
import type { StateDependent, StatePrerequisite } from './edge-plan';
|
|
41
41
|
|
|
42
42
|
const NOTICE_PREFIX = '-- NOTICE';
|
|
43
43
|
export const STATE_PREREQUISITE_DRIFT_PREFIX = 'state prerequisite drift:';
|
|
@@ -366,6 +366,11 @@ export interface StateApplyOptions {
|
|
|
366
366
|
* back both, rather than leaving a derived object ahead of the state it serves.
|
|
367
367
|
*/
|
|
368
368
|
statePrerequisites?: readonly StatePrerequisite[];
|
|
369
|
+
/**
|
|
370
|
+
* Reviewed declared views and matviews a type change in this batch binds: dropped (dependents
|
|
371
|
+
* first) before it and re-created after it, in the same transaction, so they are never absent.
|
|
372
|
+
*/
|
|
373
|
+
stateDependents?: readonly StateDependent[];
|
|
369
374
|
/**
|
|
370
375
|
* An explicit write transaction over one pinned connection. Prerequisites need this because
|
|
371
376
|
* their DDL, the state edge, and their catalog-derived provenance must commit or roll back
|
|
@@ -518,6 +523,7 @@ export async function applyGeneratedStatements(
|
|
|
518
523
|
): Promise<StateApplyResult> {
|
|
519
524
|
const classified = classifyGeneratedStatements(statements);
|
|
520
525
|
const prerequisites = options.statePrerequisites ?? [];
|
|
526
|
+
const dependents = options.stateDependents ?? [];
|
|
521
527
|
// Checks the caller withheld before calling. They are outstanding from the first line of this
|
|
522
528
|
// function: the outcome written before the batch already counts them, and they come back in the
|
|
523
529
|
// result whether or not any check actually ran here.
|
|
@@ -583,7 +589,7 @@ export async function applyGeneratedStatements(
|
|
|
583
589
|
const validationSql = validations.join('\n;\n');
|
|
584
590
|
const started = now();
|
|
585
591
|
try {
|
|
586
|
-
if (prerequisites.length > 0) {
|
|
592
|
+
if (prerequisites.length > 0 || dependents.length > 0) {
|
|
587
593
|
if (!options.transaction) {
|
|
588
594
|
throw new Error('state prerequisites require a pinned write transaction; this apply lane cannot prove atomic provenance.');
|
|
589
595
|
}
|
|
@@ -591,39 +597,65 @@ export async function applyGeneratedStatements(
|
|
|
591
597
|
const [settings] = await transactionRunner("SELECT pg_catalog.current_setting('search_path') AS search_path");
|
|
592
598
|
const originalPath = settings?.search_path;
|
|
593
599
|
if (typeof originalPath !== 'string') throw new Error('could not capture prerequisite search_path; no state was applied.');
|
|
600
|
+
const reread = async () => new Map((await introspectDerived(borrowedSessionRunner(transactionRunner)))
|
|
601
|
+
.objects.map((object) => [object.identity, object]));
|
|
602
|
+
// A dependent is re-created from the reviewed SQL, so it must still be the object the mint
|
|
603
|
+
// read. Checked inside the transaction, before any write.
|
|
604
|
+
if (dependents.length > 0) {
|
|
605
|
+
const before = await reread();
|
|
606
|
+
for (const dependent of dependents) {
|
|
607
|
+
if (before.get(dependent.identity)?.defHash !== dependent.expectedDefHash) {
|
|
608
|
+
throw new Error(`${STATE_PREREQUISITE_DRIFT_PREFIX} ${dependent.kind} ${dependent.identity} is not the one this plan was minted against; the target changed since minting. Re-mint with db:plan.`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
594
612
|
// Defaults and SQL-standard bodies bind names at CREATE time. Use the full declared
|
|
595
613
|
// path recorded at mint, then restore the exact connection setting; the state DDL
|
|
596
|
-
// that follows pins the canonical path itself.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
...(
|
|
600
|
-
prerequisite
|
|
601
|
-
|
|
602
|
-
|
|
614
|
+
// that follows pins the canonical path itself. Dependents drop before the edge, children
|
|
615
|
+
// first, and come back after it, parents first, under their declared path.
|
|
616
|
+
batchSql = [
|
|
617
|
+
...(prerequisites.length > 0 ? ['SET LOCAL check_function_bodies = off'] : []),
|
|
618
|
+
...prerequisites.flatMap((prerequisite) => [
|
|
619
|
+
renderStatePrerequisiteGuard(prerequisite),
|
|
620
|
+
...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(prerequisite.searchPath)]),
|
|
621
|
+
prerequisite.sql,
|
|
622
|
+
...(prerequisite.searchPath === null ? [] : [renderLocalSearchPath(originalPath)]),
|
|
623
|
+
]),
|
|
624
|
+
...[...dependents].reverse().map((dependent) => dependent.dropSql),
|
|
625
|
+
...stateBatch,
|
|
626
|
+
...dependents.flatMap((dependent) => [renderLocalSearchPath(dependent.searchPath ?? originalPath), dependent.sql]),
|
|
627
|
+
...(dependents.length > 0 ? [renderLocalSearchPath(originalPath)] : []),
|
|
628
|
+
].join('\n;\n');
|
|
603
629
|
await transactionRunner(batchSql);
|
|
604
630
|
// `defHash` is PostgreSQL's canonical deparse, so source text cannot predict it. Read it
|
|
605
631
|
// after the CREATE through the OPEN transaction, then write provenance before COMMIT.
|
|
606
|
-
const
|
|
607
|
-
|
|
632
|
+
const created: Array<{ identity: string; kind: 'function' | StateDependent['kind']; grantContract: StatePrerequisite['grantContract'];
|
|
633
|
+
provenance: { srcHash: string; bodyHash?: string; kind: string; dropSql?: string } }> = [
|
|
634
|
+
...prerequisites.map((p) => ({ identity: p.identity, kind: 'function' as const, grantContract: p.grantContract, provenance: p.provenance })),
|
|
635
|
+
...dependents.map((d) => ({ identity: d.identity, kind: d.kind, grantContract: d.grantContract, provenance: d.provenance })),
|
|
636
|
+
];
|
|
608
637
|
let liveByIdentity = await reread();
|
|
609
|
-
|
|
638
|
+
// A function is a state prerequisite; a re-created dependent is named as the view it is.
|
|
639
|
+
const objectLabel = (object: (typeof created)[number]): string =>
|
|
640
|
+
`${object.kind === 'function' ? 'state prerequisite' : object.kind} ${object.identity}`;
|
|
641
|
+
const grantDelta = (prerequisite: (typeof created)[number]) => {
|
|
610
642
|
const live = liveByIdentity.get(prerequisite.identity);
|
|
611
643
|
if (!live || live.grants === undefined) {
|
|
612
|
-
throw new Error(
|
|
644
|
+
throw new Error(`${objectLabel(prerequisite)} has no readable grant catalog; no provenance was written.`);
|
|
613
645
|
}
|
|
614
646
|
const contract = prerequisite.grantContract;
|
|
615
647
|
if (contract.owner && live.owner !== contract.owner) {
|
|
616
|
-
throw new Error(
|
|
648
|
+
throw new Error(`${objectLabel(prerequisite)} owner did not converge to ${contract.owner}; no provenance was written.`);
|
|
617
649
|
}
|
|
618
|
-
const delta = objectGrantDelta({ ...contract, identity: prerequisite.identity, kind:
|
|
650
|
+
const delta = objectGrantDelta({ ...contract, identity: prerequisite.identity, kind: prerequisite.kind }, live);
|
|
619
651
|
if (!delta || delta.columnScoped || delta.suppressed.length > 0) {
|
|
620
|
-
throw new Error(
|
|
652
|
+
throw new Error(`${objectLabel(prerequisite)} grants cannot be verified; no provenance was written.`);
|
|
621
653
|
}
|
|
622
654
|
return delta.statements;
|
|
623
655
|
};
|
|
624
656
|
// CREATE inherits default ACLs, and ALTER OWNER can remove a declared builder grant.
|
|
625
657
|
// Settle from the actual catalog using the same grant diff as derived reconcile.
|
|
626
|
-
const settle =
|
|
658
|
+
const settle = created.flatMap((prerequisite) => {
|
|
627
659
|
const statements = grantDelta(prerequisite);
|
|
628
660
|
const { owner, ownerMode } = prerequisite.grantContract;
|
|
629
661
|
return statements.length > 0 && owner && ownerMode === 'set-role'
|
|
@@ -636,16 +668,16 @@ export async function applyGeneratedStatements(
|
|
|
636
668
|
liveByIdentity = await reread();
|
|
637
669
|
}
|
|
638
670
|
// A successful REVOKE can be a warning-only no-op. Only a second catalog read proves it.
|
|
639
|
-
for (const prerequisite of
|
|
671
|
+
for (const prerequisite of created) {
|
|
640
672
|
const remaining = grantDelta(prerequisite);
|
|
641
673
|
if (remaining.length > 0) {
|
|
642
|
-
throw new Error(
|
|
674
|
+
throw new Error(`${objectLabel(prerequisite)} grants did not converge: still needs ${remaining.join('; ')}. No provenance was written.`);
|
|
643
675
|
}
|
|
644
676
|
}
|
|
645
|
-
const provenanceSql =
|
|
677
|
+
const provenanceSql = created.map((prerequisite) => {
|
|
646
678
|
const liveObject = liveByIdentity.get(prerequisite.identity);
|
|
647
679
|
if (!liveObject) {
|
|
648
|
-
throw new Error(
|
|
680
|
+
throw new Error(`${objectLabel(prerequisite)} was created but is not introspectable before commit; provenance was not recorded.`);
|
|
649
681
|
}
|
|
650
682
|
return renderProvenanceUpsert(
|
|
651
683
|
prerequisite.identity,
|