@everystack/cli 0.4.90 → 0.4.91
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/package.json
CHANGED
|
@@ -476,6 +476,7 @@ export function buildReconcileReport(plan: ReconcilePlan): string[] {
|
|
|
476
476
|
for (const a of plan.actions) {
|
|
477
477
|
const cost = a.bytes !== undefined ? `; ${formatBytes(a.bytes)}${a.rows ? `, ~${a.rows} rows` : ''}` : '';
|
|
478
478
|
lines.push(`${VERB_GLYPH[a.action] ?? '?'} ${a.action} ${a.identity} (${a.reason}${cost})`);
|
|
479
|
+
for (const r of a.revokes ?? []) lines.push(` - ${r} (no longer declared)`);
|
|
479
480
|
}
|
|
480
481
|
// Pending identity migrations. Bookkeeping only — no DDL, no object changed — but they ARE a
|
|
481
482
|
// write, and they were invisible here while also being excluded from `checkFails`. Silence in
|
package/src/cli/derived-apply.ts
CHANGED
|
@@ -140,9 +140,11 @@ export type OwnerApplyMode = 'set-role' | 'alter-owner';
|
|
|
140
140
|
* carry pg_dump's WITH NO DATA ordering.)
|
|
141
141
|
*
|
|
142
142
|
* A declared owner is enacted one of two ways; see {@link OwnerApplyMode} for which and why.
|
|
143
|
+
* `revokes` (a replace's REVOKEs of dropped grantees) trail the attachments, inside the owner
|
|
144
|
+
* block for the same reason the attachments are.
|
|
143
145
|
*/
|
|
144
|
-
function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role'): string[] {
|
|
145
|
-
const statements = [obj.sql, ...obj.attachments.map((a) => a.sql)];
|
|
146
|
+
function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role', revokes: readonly string[] = []): string[] {
|
|
147
|
+
const statements = [obj.sql, ...obj.attachments.map((a) => a.sql), ...revokes];
|
|
146
148
|
if (obj.kind === 'materialized view' && WITH_NO_DATA_RE.test(normalizeSql(obj.sql))) {
|
|
147
149
|
statements.push(`REFRESH MATERIALIZED VIEW ${quoteQualified(obj.identity)}`);
|
|
148
150
|
}
|
|
@@ -457,7 +459,7 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[],
|
|
|
457
459
|
case 'replace': {
|
|
458
460
|
const obj = srcById.get(action.identity);
|
|
459
461
|
if (!obj) throw new Error(`replace action for ${action.identity} has no source object`);
|
|
460
|
-
statements.push(...objectSql(obj, ownerMode).map(ensureOrReplace));
|
|
462
|
+
statements.push(...objectSql(obj, ownerMode, action.revokes).map(ensureOrReplace));
|
|
461
463
|
record.push(action.identity);
|
|
462
464
|
break;
|
|
463
465
|
}
|
|
@@ -62,6 +62,12 @@ export function isPlainGrantAttachment(a: Attachment): boolean {
|
|
|
62
62
|
return g !== null && !g[2]; // g[2] is the (columns) group — present => column-scoped => not plain
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Whether the attachments run a `REVOKE ALL … FROM PUBLIC` — PUBLIC holds nothing after them
|
|
66
|
+
* beyond what a later attachment grants it back. */
|
|
67
|
+
export function revokesPublic(attachments: readonly Attachment[]): boolean {
|
|
68
|
+
return attachments.some((a) => a.kind === 'grant' && REVOKE_PUBLIC_RE.test(a.sql));
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
/** The declared grant contract, read back from an object's own grant attachments. */
|
|
66
72
|
export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
|
|
67
73
|
const grants: Record<string, Set<string>> = {};
|
|
@@ -86,7 +92,12 @@ export function parseGrantAttachments(attachments: readonly Attachment[]): Parse
|
|
|
86
92
|
target ??= onTarget.trim();
|
|
87
93
|
const privileges = privilegeList.split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
88
94
|
for (const role of roleList.split(',').map((r) => r.trim()).filter(Boolean)) {
|
|
89
|
-
|
|
95
|
+
// PUBLIC is a keyword, not a role: `TO public` and `TO PUBLIC` are one grantee, and the live
|
|
96
|
+
// read spells it `PUBLIC` (the table path's granteeKey, same rule). Keyed as written, a
|
|
97
|
+
// lowercase declaration read as a REVOKE from PUBLIC plus a GRANT to `public` — a replace,
|
|
98
|
+
// which keeps only the REVOKE, removed every caller's access.
|
|
99
|
+
const grantee = role.toLowerCase() === 'public' ? 'PUBLIC' : role;
|
|
100
|
+
for (const privilege of privileges) (grants[grantee] ??= new Set()).add(privilege);
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { type ParsedSources, type SourceObject, type DerivedKind } from './derived-source';
|
|
28
28
|
import type { DerivedCatalog, LiveObject } from './derived-introspect';
|
|
29
29
|
import { triggerDropSql } from './derived-apply';
|
|
30
|
-
import { parseGrantAttachments, diffObjectGrants } from './derived-grants';
|
|
30
|
+
import { parseGrantAttachments, diffObjectGrants, revokesPublic } from './derived-grants';
|
|
31
31
|
import { legacyFunctionIdentity, splitFunctionIdentity } from './pg-argtypes';
|
|
32
32
|
|
|
33
33
|
export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune' | 'backfill';
|
|
@@ -43,6 +43,10 @@ export interface ReconcileAction {
|
|
|
43
43
|
/** Drops for the structural kinds — a trigger's `DROP TRIGGER … ON table`, a 'sql'
|
|
44
44
|
* object's recorded/declared drop. Legacy kinds keep the keyword rendering. */
|
|
45
45
|
dropSql?: string;
|
|
46
|
+
/** A replace's REVOKEs of grantees the source no longer declares. CREATE OR REPLACE keeps the
|
|
47
|
+
* live ACL and the attachments only add, so without these a dropped role keeps its privilege.
|
|
48
|
+
* They run with the replace, inside its owner block — REVOKE on a function needs its owner. */
|
|
49
|
+
revokes?: string[];
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export interface DriftFinding {
|
|
@@ -461,6 +465,10 @@ export function planReconcile(
|
|
|
461
465
|
|
|
462
466
|
const regrants: RegrantFinding[] = [];
|
|
463
467
|
const dependencyDrift: DependencyDriftFinding[] = [];
|
|
468
|
+
const ownerContext = (src: SourceObject, liveObj: LiveObject): { declared?: string; live?: string } => ({
|
|
469
|
+
...(src.owner !== undefined ? { declared: src.owner } : {}),
|
|
470
|
+
...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
|
|
471
|
+
});
|
|
464
472
|
// Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
|
|
465
473
|
// and running the checks NOW means a migration converges (grants ride the same apply)
|
|
466
474
|
// instead of surfacing on the next plan.
|
|
@@ -493,11 +501,7 @@ export function planReconcile(
|
|
|
493
501
|
// KNOWN LIMIT: a privilege an owner has deliberately self-revoked is not restored — that
|
|
494
502
|
// is a failure to ADD access (availability), never to remove it, and unnormalized code did
|
|
495
503
|
// not converge on it either.
|
|
496
|
-
const
|
|
497
|
-
...(src.owner !== undefined ? { declared: src.owner } : {}),
|
|
498
|
-
...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
|
|
499
|
-
};
|
|
500
|
-
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
|
|
504
|
+
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, ownerContext(src, liveObj));
|
|
501
505
|
if (statements.length > 0) regrants.push({ identity: src.identity, statements });
|
|
502
506
|
// Never silent: a withheld revoke is reported every run until the owner is declared.
|
|
503
507
|
for (const note of suppressed) extraWarnings.push(note);
|
|
@@ -631,6 +635,34 @@ export function planReconcile(
|
|
|
631
635
|
return { action, identity: id, kind, reason, ...cost, ...structural };
|
|
632
636
|
};
|
|
633
637
|
|
|
638
|
+
/**
|
|
639
|
+
* The REVOKEs a function replace needs to converge its ACL in the same apply.
|
|
640
|
+
*
|
|
641
|
+
* CREATE OR REPLACE keeps the live ACL, and the compiled attachments only ADD: a REVOKE ALL
|
|
642
|
+
* FROM PUBLIC (unless PUBLIC is declared), then one GRANT per declared role. So a role dropped
|
|
643
|
+
* from the abilities in the same change as a body edit kept its EXECUTE until a second apply
|
|
644
|
+
* found it as grant drift. The ACL after the replace is the live one minus PUBLIC (when the
|
|
645
|
+
* attachments revoke it) plus the declared grants, so only the REVOKEs of the diff are left
|
|
646
|
+
* to do. They ride the replace itself rather than the regrant pass, because the replace runs
|
|
647
|
+
* inside its owner's SET ROLE block and a REVOKE on a function needs its owner.
|
|
648
|
+
*
|
|
649
|
+
* Read after the dependency cascade: a function it promoted to drop+create is recreated with a
|
|
650
|
+
* fresh ACL from its attachments alone, and is no longer in fnReplace.
|
|
651
|
+
*/
|
|
652
|
+
const replaceRevokes = (id: string): string[] => {
|
|
653
|
+
const src = srcById.get(id);
|
|
654
|
+
const liveObj = liveById.get(id);
|
|
655
|
+
if (!src || !liveObj || liveObj.grants === undefined) return [];
|
|
656
|
+
const parsed = parseGrantAttachments(src.attachments);
|
|
657
|
+
if (parsed.hasColumnGrants) return [];
|
|
658
|
+
const target = parsed.target ?? `FUNCTION ${src.identity}`;
|
|
659
|
+
const afterReplace = { ...liveObj.grants };
|
|
660
|
+
if (revokesPublic(src.attachments)) delete afterReplace.PUBLIC;
|
|
661
|
+
const { statements, suppressed } = diffObjectGrants(target, parsed.grants, afterReplace, ownerContext(src, liveObj));
|
|
662
|
+
for (const note of suppressed) extraWarnings.push(note);
|
|
663
|
+
return statements.filter((st) => /^REVOKE\b/i.test(st));
|
|
664
|
+
};
|
|
665
|
+
|
|
634
666
|
const actions: ReconcileAction[] = [
|
|
635
667
|
...orderDrops(dropSet).map((id) => actionFor(id, 'drop', rebuild.get(id) ?? drop.get(id) ?? '')),
|
|
636
668
|
// 'sql'-kind drops (rebuilds + removals) — invisible to the live catalog, so they
|
|
@@ -638,7 +670,10 @@ export function planReconcile(
|
|
|
638
670
|
...[...sqlDrops.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
639
671
|
.map(([id, d]) => ({ action: 'drop' as const, identity: id, kind: 'sql' as DerivedKind, reason: d.reason, dropSql: d.dropSql })),
|
|
640
672
|
...[...fnReplace.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
|
|
641
|
-
.map(([id, reason]) =>
|
|
673
|
+
.map(([id, reason]) => {
|
|
674
|
+
const revokes = replaceRevokes(id);
|
|
675
|
+
return { ...actionFor(id, 'replace', reason), ...(revokes.length ? { revokes } : {}) };
|
|
676
|
+
}),
|
|
642
677
|
...createSet.sort((a, b) => seqOf(a) - seqOf(b))
|
|
643
678
|
.map((id) => actionFor(id, 'create', rebuild.get(id) ?? create.get(id) ?? '')),
|
|
644
679
|
...[...refresh.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
|