@everystack/cli 0.4.81 → 0.4.83
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 +41 -0
- package/package.json +9 -1
- package/src/cli/authz-compile.ts +126 -6
- package/src/cli/authz-contract.ts +5 -3
- package/src/cli/authz-derive.ts +31 -6
- package/src/cli/authz-reconcile.ts +18 -15
- package/src/cli/aws.ts +17 -1
- package/src/cli/commands/db-apply.ts +445 -60
- package/src/cli/commands/db-check.ts +39 -20
- package/src/cli/commands/db-plan.ts +61 -27
- package/src/cli/commands/db-pull.ts +67 -11
- package/src/cli/commands/db-reconcile.ts +22 -1
- package/src/cli/commands/db.ts +137 -19
- package/src/cli/db-source.ts +8 -1
- package/src/cli/derived-catchup.ts +154 -0
- package/src/cli/derived-render.ts +531 -245
- package/src/cli/derived-source.ts +25 -90
- package/src/cli/edge-plan.ts +76 -14
- package/src/cli/index.ts +7 -7
- package/src/cli/model-wire-lane.ts +143 -0
- package/src/cli/output.ts +12 -0
- package/src/cli/plain-output.ts +521 -0
- package/src/cli/schema-diff.ts +6 -0
- package/src/cli/schema-fingerprint-v11.ts +234 -0
- package/src/cli/schema-fingerprint.ts +20 -6
- package/src/cli/sql-statement.ts +98 -0
- package/src/cli/state-apply.ts +46 -24
- package/src/wire.ts +30 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FROZEN v11 canonicalizer — the exact live-side hash the CLI produced under
|
|
3
|
+
* FINGERPRINT_VERSION 11, before serial-sequence grants narrowed to USAGE-only at v12.
|
|
4
|
+
*
|
|
5
|
+
* DO NOT EDIT this to track new behaviour. It exists for ONE purpose: `db:apply`'s
|
|
6
|
+
* fast-forward proof recomputes a `schema_log` row recorded at `fp_version = 11` against a
|
|
7
|
+
* CURRENT live read, and comparing an old-format hash against a current-format one reports a
|
|
8
|
+
* phantom mismatch. So the descent registry recomputes the LIVE side under the ROW's canon —
|
|
9
|
+
* this function — per the standing rule in `RETAINED_CANONS`.
|
|
10
|
+
*
|
|
11
|
+
* It is a SELF-CONTAINED copy of the v11 canonical form: the command fan and effective-privilege
|
|
12
|
+
* deadness are byte-identical to v12 (frozen here so a FUTURE canon change to the live authz form
|
|
13
|
+
* cannot silently move a recorded v11 hash), and the ONLY behavioural difference from v12 is the
|
|
14
|
+
* serial-sequence inheritance rule — pinned here to the v11 set `SELECT + UPDATE + USAGE`, which
|
|
15
|
+
* v12 narrowed to USAGE alone. It reuses only the stable leaf normalizers (deparse + structure)
|
|
16
|
+
* and the authz-contract privilege oracles, whose behaviour is equal across every version.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import type { PolicyContract, TableContract, SequenceGrantContract } from './authz-contract';
|
|
21
|
+
import { holdsPrivilegeEffective, policyCommands } from './authz-contract';
|
|
22
|
+
import type { SchemaSnapshot, TableSchema } from './schema-introspect';
|
|
23
|
+
import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
|
|
24
|
+
import { stableStringify, schemaOf, type CanonicalizeOptions } from './schema-fingerprint';
|
|
25
|
+
|
|
26
|
+
const PUBLIC_ROLE = 'public';
|
|
27
|
+
|
|
28
|
+
const byKey = <T>(items: T[], key: (item: T) => string): T[] =>
|
|
29
|
+
[...items].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
30
|
+
|
|
31
|
+
// --- v11 authz canonical form (frozen: the command fan, identical to v12) ---
|
|
32
|
+
|
|
33
|
+
interface PolicyArm {
|
|
34
|
+
role: string;
|
|
35
|
+
command: string;
|
|
36
|
+
permissive: boolean;
|
|
37
|
+
using: string;
|
|
38
|
+
check: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function policyArms(p: PolicyContract): PolicyArm[] {
|
|
42
|
+
const roles = p.roles.includes(PUBLIC_ROLE) ? [PUBLIC_ROLE] : [...p.roles].sort();
|
|
43
|
+
const arms: PolicyArm[] = [];
|
|
44
|
+
for (const command of policyCommands(p)) {
|
|
45
|
+
const using = command === 'INSERT' ? '' : (p.using ?? '');
|
|
46
|
+
const check = command === 'SELECT' || command === 'DELETE' ? '' : ((p.check ?? p.using) ?? '');
|
|
47
|
+
for (const role of roles) arms.push({ role, command, permissive: p.permissive, using, check });
|
|
48
|
+
}
|
|
49
|
+
return arms;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function armKey(a: PolicyArm): string {
|
|
53
|
+
return `${a.role}|${JSON.stringify([a.command, a.permissive, a.using, a.check])}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function multiset(entries: string[]): Array<[string, number]> {
|
|
57
|
+
const counts = new Map<string, number>();
|
|
58
|
+
for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
59
|
+
return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function governedArm(governed: ReadonlySet<string>, role: string): boolean {
|
|
63
|
+
return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function armDead(t: TableContract, a: PolicyArm): boolean {
|
|
67
|
+
if (a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') {
|
|
68
|
+
const grantees = t.effectivePrivileges
|
|
69
|
+
? Object.keys(t.effectivePrivileges)
|
|
70
|
+
: [...new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})])];
|
|
71
|
+
return !grantees.some((r) => holdsPrivilegeEffective(t, r, a.command));
|
|
72
|
+
}
|
|
73
|
+
return !holdsPrivilegeEffective(t, a.role, a.command);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function armSubsumed(a: PolicyArm, alive: readonly PolicyArm[]): boolean {
|
|
77
|
+
if (!a.permissive || a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') return false;
|
|
78
|
+
return alive.some((o) =>
|
|
79
|
+
(o.role === PUBLIC_ROLE || o.role.toUpperCase() === 'PUBLIC')
|
|
80
|
+
&& o.permissive && o.command === a.command && o.using === a.using && o.check === a.check);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canonicalPolicyArms(contract: TableContract, governed?: ReadonlySet<string>): Array<[string, number]> {
|
|
84
|
+
const alive = contract.policies.flatMap(policyArms).filter((a) => !armDead(contract, a));
|
|
85
|
+
const kept = alive
|
|
86
|
+
.filter((a) => !armSubsumed(a, alive))
|
|
87
|
+
.filter((a) => (governed ? governedArm(governed, a.role) : true));
|
|
88
|
+
return multiset(kept.map(armKey));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function governedOnly<T>(map: Record<string, T> | undefined, governed: ReadonlySet<string> | undefined): Record<string, T> {
|
|
92
|
+
if (!map) return {};
|
|
93
|
+
if (!governed) return map;
|
|
94
|
+
return Object.fromEntries(
|
|
95
|
+
Object.entries(map).filter(([grantee]) => governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function canonicalAuthzV11(contract: TableContract, governed?: ReadonlySet<string>): Record<string, unknown> {
|
|
100
|
+
const grants = governedOnly(contract.grants, governed);
|
|
101
|
+
const columnGrants = governedOnly(contract.columnGrants, governed);
|
|
102
|
+
return {
|
|
103
|
+
table: contract.table,
|
|
104
|
+
rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
|
|
105
|
+
grants: Object.fromEntries(
|
|
106
|
+
Object.entries(grants).map(([role, privs]) => [role, [...(privs as string[])].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
107
|
+
),
|
|
108
|
+
...(Object.keys(columnGrants).length > 0
|
|
109
|
+
? {
|
|
110
|
+
columnGrants: Object.fromEntries(
|
|
111
|
+
Object.entries(columnGrants)
|
|
112
|
+
.map(([role, byPriv]) => [
|
|
113
|
+
role,
|
|
114
|
+
Object.fromEntries(
|
|
115
|
+
Object.entries(byPriv as Record<string, string[]>).map(([priv, cols]) => [priv, [...cols].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
116
|
+
),
|
|
117
|
+
] as const)
|
|
118
|
+
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
: {}),
|
|
122
|
+
policies: canonicalPolicyArms(contract, governed),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- v11 base-schema canonical form (identical to v12; frozen here for self-containment) ---
|
|
127
|
+
|
|
128
|
+
function canonicalTableV11(table: TableSchema): Record<string, unknown> {
|
|
129
|
+
return {
|
|
130
|
+
table: table.table,
|
|
131
|
+
columns: byKey(
|
|
132
|
+
table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
|
|
133
|
+
(c) => c.name,
|
|
134
|
+
),
|
|
135
|
+
primaryKey: table.primaryKey,
|
|
136
|
+
uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
|
|
137
|
+
checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
|
|
138
|
+
foreignKeys: byKey(
|
|
139
|
+
table.foreignKeys.map((f) => ({
|
|
140
|
+
columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
|
|
141
|
+
...(f.onDelete ? { onDelete: f.onDelete } : {}),
|
|
142
|
+
...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
|
|
143
|
+
})),
|
|
144
|
+
(f) => stableStringify(f),
|
|
145
|
+
),
|
|
146
|
+
indexes: [...new Set(table.indexes.map(indexKey))].sort(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- v11 serial-sequence ACL slice (frozen: the derived set is SELECT + UPDATE + USAGE) ---
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The v11 inheritance rule: an INSERT-holder inherits SELECT + UPDATE + USAGE on the table's
|
|
154
|
+
* serial sequence. This is the ONLY line that differs from the live (v12) form, which derives
|
|
155
|
+
* USAGE alone. Pinned as a literal here so this frozen canon never tracks the live change.
|
|
156
|
+
*/
|
|
157
|
+
function inheritedSequenceGrantsV11(table: TableContract): Record<string, string[]> {
|
|
158
|
+
const out: Record<string, string[]> = {};
|
|
159
|
+
for (const [grantee, privileges] of Object.entries(table.grants)) {
|
|
160
|
+
if (privileges.includes('INSERT')) out[grantee] = ['SELECT', 'UPDATE', 'USAGE'];
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isGovernedGrantee(governed: ReadonlySet<string> | undefined, grantee: string): boolean {
|
|
166
|
+
if (!governed) return true;
|
|
167
|
+
return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function seqShape(grants: Record<string, string[]>, governed?: ReadonlySet<string>): string {
|
|
171
|
+
const kept = Object.entries(grants)
|
|
172
|
+
.filter(([grantee, privs]) => privs.length > 0 && isGovernedGrantee(governed, grantee))
|
|
173
|
+
.map(([grantee, privs]) => [grantee, [...privs].sort()] as const);
|
|
174
|
+
return stableStringify(Object.fromEntries(kept));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function canonicalSequenceAuthzV11(
|
|
178
|
+
tables: TableSchema[],
|
|
179
|
+
authzTables: TableContract[],
|
|
180
|
+
measured: SequenceGrantContract[] | undefined,
|
|
181
|
+
governed: ReadonlySet<string> | undefined,
|
|
182
|
+
keepTable: (name: string) => boolean,
|
|
183
|
+
): Array<[string, string[]]> {
|
|
184
|
+
const snapshotByTable = new Map(tables.map((t) => [t.table, t]));
|
|
185
|
+
const measuredByTable = new Map<string, SequenceGrantContract[]>();
|
|
186
|
+
for (const s of measured ?? []) {
|
|
187
|
+
(measuredByTable.get(s.table) ?? measuredByTable.set(s.table, []).get(s.table)!).push(s);
|
|
188
|
+
}
|
|
189
|
+
const out: Array<[string, string[]]> = [];
|
|
190
|
+
for (const az of authzTables) {
|
|
191
|
+
if (!keepTable(az.table)) continue;
|
|
192
|
+
let shapes: string[];
|
|
193
|
+
if (measuredByTable.has(az.table)) {
|
|
194
|
+
shapes = measuredByTable.get(az.table)!.filter((s) => !s.identity).map((s) => seqShape(s.grants, governed));
|
|
195
|
+
} else {
|
|
196
|
+
const snap = snapshotByTable.get(az.table);
|
|
197
|
+
const serialCount = (snap?.columns ?? []).filter((c) => nextvalSequence(normalizeDefault(c.default)) != null).length;
|
|
198
|
+
if (serialCount === 0) continue;
|
|
199
|
+
const inherited = seqShape(inheritedSequenceGrantsV11(az), governed);
|
|
200
|
+
shapes = Array.from({ length: serialCount }, () => inherited);
|
|
201
|
+
}
|
|
202
|
+
if (shapes.length === 0) continue;
|
|
203
|
+
out.push([az.table, shapes.sort()]);
|
|
204
|
+
}
|
|
205
|
+
return out.sort(([a], [b]) => (a < b ? -1 : 1));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The frozen v11 live hash for (snapshot, authzTables, opts). */
|
|
209
|
+
export function fingerprintV11(snapshot: SchemaSnapshot, authzTables: TableContract[], opts: CanonicalizeOptions = {}): string {
|
|
210
|
+
const inScope = opts.schemas ? new Set(opts.schemas) : null;
|
|
211
|
+
const keepTable = (name: string) => inScope === null || inScope.has(schemaOf(name));
|
|
212
|
+
const tables = snapshot.tables.filter((t) => keepTable(t.table));
|
|
213
|
+
const usedEnum = new Set<string>();
|
|
214
|
+
for (const t of tables) for (const c of t.columns) usedEnum.add(schemaOf(c.type) === 'public' ? c.type : c.type.slice(c.type.indexOf('.') + 1));
|
|
215
|
+
const sequences = byKey(
|
|
216
|
+
(snapshot.sequences ?? [])
|
|
217
|
+
.filter((s) => inScope === null || inScope.has(schemaOf(s.name)))
|
|
218
|
+
.map((s) => ({ name: s.name, as: s.as, ...(s.start != null ? { start: s.start } : {}), ...(s.increment != null ? { increment: s.increment } : {}) })),
|
|
219
|
+
(s) => String(s.name),
|
|
220
|
+
);
|
|
221
|
+
const sequenceAuthz = canonicalSequenceAuthzV11(tables, authzTables, opts.sequenceGrants, opts.governedRoles, keepTable);
|
|
222
|
+
const canonical = {
|
|
223
|
+
v: 11,
|
|
224
|
+
tables: byKey(tables.map(canonicalTableV11), (t) => String(t.table)),
|
|
225
|
+
enums: byKey(
|
|
226
|
+
(snapshot.enums ?? []).filter((e) => inScope === null || usedEnum.has(e.name)).map((e) => ({ name: e.name, values: e.values })),
|
|
227
|
+
(e) => e.name,
|
|
228
|
+
),
|
|
229
|
+
authz: byKey(authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzV11(t, opts.governedRoles)), (t) => String(t.table)),
|
|
230
|
+
...(sequences.length ? { sequences } : {}),
|
|
231
|
+
...(sequenceAuthz.length ? { sequenceAuthz } : {}),
|
|
232
|
+
};
|
|
233
|
+
return createHash('sha256').update(stableStringify(canonical)).digest('hex');
|
|
234
|
+
}
|
|
@@ -60,6 +60,7 @@ import { compileTableContract } from './authz-compile';
|
|
|
60
60
|
import { governedRoleSet, inheritedSequenceGrants } from './authz-reconcile';
|
|
61
61
|
import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
|
|
62
62
|
import { fingerprintV10 } from './schema-fingerprint-v10';
|
|
63
|
+
import { fingerprintV11 } from './schema-fingerprint-v11';
|
|
63
64
|
|
|
64
65
|
// v2: expression fields (defaults, checks, partial-index WHERE) normalize through
|
|
65
66
|
// the diff's pg-deparse normalizers before hashing, so the model form and the live
|
|
@@ -130,7 +131,17 @@ import { fingerprintV10 } from './schema-fingerprint-v10';
|
|
|
130
131
|
// plan or baseline minted under v10 refuses as a FORMAT CHANGE and must be re-minted (db:plan) or
|
|
131
132
|
// re-baselined (db:reconcile --rebaseline) — no DDL, the fingerprint is a content address. The v10
|
|
132
133
|
// canon is frozen in RETAINED_CANONS so a schema_log row recorded under it still descends.
|
|
133
|
-
|
|
134
|
+
// v12 — SERIAL-SEQUENCE grants narrowed to LEAST PRIVILEGE. The v11 A6 rule derived SELECT + UPDATE
|
|
135
|
+
// + USAGE for every INSERT-holder, handing each one `setval` control of a surrogate key it never
|
|
136
|
+
// needs; it widened a consumer's governed prod migrator by 28 UPDATE grants nobody asked for. The
|
|
137
|
+
// derived set is now USAGE alone (exactly what `nextval` needs — see SEQUENCE_PRIVILEGES_FOR_INSERT),
|
|
138
|
+
// so `canonicalSequenceAuthz`'s DERIVED shape moves for any serial table with a governed INSERT, and
|
|
139
|
+
// the fingerprint with it. Same consequence as every bump: a plan or baseline minted under v11
|
|
140
|
+
// refuses as a FORMAT CHANGE and must be re-minted — no DDL, the fingerprint is a content address.
|
|
141
|
+
// On every deployed stage the next db:plan mints a REVOKE narrowing those sequence grants to USAGE
|
|
142
|
+
// (the report classifies it as narrowing access). The v11 canon is frozen in schema-fingerprint-v11
|
|
143
|
+
// and registered in RETAINED_CANONS so a schema_log row recorded under it still descends.
|
|
144
|
+
export const FINGERPRINT_VERSION = 12;
|
|
134
145
|
|
|
135
146
|
// ---------------------------------------------------------------------------
|
|
136
147
|
// Canonical form.
|
|
@@ -489,11 +500,13 @@ export type CanonHasher = (
|
|
|
489
500
|
* absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
|
|
490
501
|
* SKIPS that row (fails closed), never guesses a hash.
|
|
491
502
|
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
503
|
+
* Retired entries: `fingerprintV10` (no command fan, direct-grant deadness, no sequence ACLs) and
|
|
504
|
+
* `fingerprintV11` (the command fan and sequence ACLs, but the pre-least-privilege derivation that
|
|
505
|
+
* gave an INSERT-holder SELECT + UPDATE + USAGE on its sequence).
|
|
494
506
|
*/
|
|
495
507
|
export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
|
|
496
508
|
10: (snapshot, tables, opts) => fingerprintV10(snapshot, tables, opts),
|
|
509
|
+
11: (snapshot, tables, opts) => fingerprintV11(snapshot, tables, opts),
|
|
497
510
|
[FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
|
|
498
511
|
};
|
|
499
512
|
|
|
@@ -527,7 +540,7 @@ export function fingerprintLiveAtVersion(
|
|
|
527
540
|
* manifest (derived-introspect): triggers, domains, composite types, foreign
|
|
528
541
|
* tables, partitioned tables, installed extensions — and identity columns, which
|
|
529
542
|
* the models can now say and the builder now builds, but which the hash does not
|
|
530
|
-
* yet cover (deferred past
|
|
543
|
+
* yet cover (deferred past v12 by scope). Serial-sequence ACLs left this list at
|
|
531
544
|
* v11: they are fingerprinted now (see canonicalSequenceAuthz).
|
|
532
545
|
*/
|
|
533
546
|
export const UNFINGERPRINTED_SQL = `
|
|
@@ -573,8 +586,9 @@ UNION ALL
|
|
|
573
586
|
-- A14. A column's GENERATED … AS IDENTITY is READ, DECLARED and BUILT, but it is NOT hashed:
|
|
574
587
|
-- the fingerprint's coverage is a version contract, and a property entering the hash inside a
|
|
575
588
|
-- version makes two operators on different CLI builds disagree about the same database. v11
|
|
576
|
-
-- took the sequence ACLs and deliberately left this and the schema posture out
|
|
577
|
-
--
|
|
589
|
+
-- took the sequence ACLs and deliberately left this and the schema posture out (v12 narrowed
|
|
590
|
+
-- the sequence derivation and carried neither); both stay named here until a later canon bump
|
|
591
|
+
-- carries them together. The gap is real and
|
|
578
592
|
-- narrow: an identity that is silently dropped or flipped ALWAYS to BY DEFAULT leaves the MATCH
|
|
579
593
|
-- claim green. It is not silent on the write path — db:generate/db:plan diff it (the differ
|
|
580
594
|
-- compares identity) — and the round-trip oracle's pg_dump comparison catches it.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sql-statement — reading ONE generated statement, before anybody classifies it.
|
|
3
|
+
*
|
|
4
|
+
* Every classifier over db:generate's statement stream starts by answering the same question:
|
|
5
|
+
* which lines of this statement are SQL, and which are the `-- WARNING:` / `-- NOTICE:` prologue
|
|
6
|
+
* the emitter attached to it? The answer decides what a destructive check tests, which bucket a
|
|
7
|
+
* histogram counts, and what verb the plain change list prints. It was written out by hand eight
|
|
8
|
+
* times across plain-output.ts, state-apply.ts and edge-plan.ts — the same
|
|
9
|
+
* `split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--'))`, with two different
|
|
10
|
+
* fallbacks and no shared name. Two classifiers that read a statement differently disagree about
|
|
11
|
+
* it before either one has decided anything, so the reading lives here, once.
|
|
12
|
+
*
|
|
13
|
+
* There are TWO readings, and which one a caller wants is a real decision:
|
|
14
|
+
*
|
|
15
|
+
* statementBody the WHOLE statement on one line. What you MATCH against. A generated
|
|
16
|
+
* statement is not single-line — the emitters format `ALTER TABLE`,
|
|
17
|
+
* `CREATE TRIGGER`, `GRANT` and every function body across lines — so a
|
|
18
|
+
* matcher that sees only line 1 sees a subject with no verb.
|
|
19
|
+
* statementHead the FIRST SQL line. What you PRINT when one line of an operator-facing
|
|
20
|
+
* preview is all there is room for.
|
|
21
|
+
*
|
|
22
|
+
* This module READS. It does not classify: what counts as destructive is state-apply's
|
|
23
|
+
* `classifyDestructive` (the single source the --confirm / snapshot / approver gate and
|
|
24
|
+
* plain-output's ⚠ line both run on), what counts as a histogram kind is state-apply's
|
|
25
|
+
* `KIND_MATCHERS`, and what a change is called in plain words is plain-output's `describeChange`.
|
|
26
|
+
* Those are three different questions on three different axes, deliberately not merged.
|
|
27
|
+
*
|
|
28
|
+
* Pure, and dependency-free on purpose — plain-output is a pure renderer and must stay one.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The SQL lines of a statement: everything that is not blank and not a `--` comment,
|
|
33
|
+
* each still carrying its own leading whitespace.
|
|
34
|
+
*
|
|
35
|
+
* A statement's comment prologue is documentation the emitter wrote for the operator. It is
|
|
36
|
+
* never the verb, and a reader that stops at line 1 reports `-- WARNING: DROP IDENTITY …` as
|
|
37
|
+
* the change rather than the ALTER underneath it.
|
|
38
|
+
*/
|
|
39
|
+
export function statementBodyLines(statement: string): string[] {
|
|
40
|
+
return statement.split('\n').filter((l) => l.trim() !== '' && !l.trim().startsWith('--'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The WHOLE statement as one line: comments and blank lines dropped, whitespace collapsed,
|
|
45
|
+
* CASE PRESERVED. This is the reading every MATCHER wants.
|
|
46
|
+
*
|
|
47
|
+
* Two failures it exists to stop, one in each direction.
|
|
48
|
+
*
|
|
49
|
+
* UNDER-reading, the dangerous one. A matcher anchored at `^` against the first line only sees
|
|
50
|
+
* `ALTER TABLE public.foo` for `ALTER TABLE public.foo\n DROP COLUMN bar;` — the verb clause is
|
|
51
|
+
* on line 2. The plain change list matched nothing and printed `+` on a statement that REMOVES a
|
|
52
|
+
* column, and `revokeTarget` returned null for a `REVOKE` sitting under a `-- WARNING:` prologue,
|
|
53
|
+
* so a real privilege strip skipped the --confirm / snapshot / approver ceremony.
|
|
54
|
+
*
|
|
55
|
+
* OVER-reading, the other one. A matcher run against the RAW text reads the emitter's prose:
|
|
56
|
+
* `-- WARNING: this used to DROP TABLE …` ahead of an `ADD COLUMN` classified the addition as a
|
|
57
|
+
* drop. The comment is documentation, not DDL.
|
|
58
|
+
*
|
|
59
|
+
* Case is left exactly as the emitter wrote it. Callers whose matchers are upper-case uppercase
|
|
60
|
+
* the result themselves — the grantee `revokeTarget` returns out of this string is an identifier,
|
|
61
|
+
* and `"Odd-Role"` is not `"ODD-ROLE"`.
|
|
62
|
+
*
|
|
63
|
+
* The result is MATCHED against, never executed: a function body losing its own formatting here
|
|
64
|
+
* costs nothing, because the statement that runs is always the original string.
|
|
65
|
+
*/
|
|
66
|
+
export function statementBody(statement: string): string {
|
|
67
|
+
return statementBodyLines(statement).join(' ').replace(/\s+/g, ' ').trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The first SQL line, trimmed — the verb. Empty string when the statement is all comment.
|
|
72
|
+
*
|
|
73
|
+
* For callers that want ONE line of a statement, not the statement: the `! <ddl>` previews in
|
|
74
|
+
* the plan summary. Anything that MATCHES wants {@link statementBody} instead — see the
|
|
75
|
+
* under-reading failure documented there. Callers that PRINT the head want
|
|
76
|
+
* {@link statementHeadOrSelf}, so a pure-comment statement still shows the operator something.
|
|
77
|
+
*/
|
|
78
|
+
export function statementHead(statement: string): string {
|
|
79
|
+
const [first] = statementBodyLines(statement);
|
|
80
|
+
return (first ?? '').trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The first SQL line, trimmed, falling back to the whole statement when there is none.
|
|
85
|
+
*
|
|
86
|
+
* For the paths that ECHO the statement at the operator (`! <ddl>` in the plan summary):
|
|
87
|
+
* printing nothing for a statement that is all comment would silently drop a line the reader
|
|
88
|
+
* is counting.
|
|
89
|
+
*/
|
|
90
|
+
export function statementHeadOrSelf(statement: string): string {
|
|
91
|
+
const [first] = statementBodyLines(statement);
|
|
92
|
+
return (first ?? statement).trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every line is blank or a comment — nothing executable inside. */
|
|
96
|
+
export function isPureComment(statement: string): boolean {
|
|
97
|
+
return statementBodyLines(statement).length === 0;
|
|
98
|
+
}
|
package/src/cli/state-apply.ts
CHANGED
|
@@ -27,16 +27,10 @@ import { generateMigrationSql, HELD_DROP_PREFIX } from './migration-generate';
|
|
|
27
27
|
import type { SequenceDescriptor } from '@everystack/model';
|
|
28
28
|
import { fingerprintLive, FINGERPRINT_VERSION } from './schema-fingerprint';
|
|
29
29
|
import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
|
|
30
|
+
import { isPureComment, statementBody, statementHead } from './sql-statement';
|
|
30
31
|
|
|
31
32
|
const NOTICE_PREFIX = '-- NOTICE';
|
|
32
33
|
|
|
33
|
-
/** Every line is a comment (or blank) — nothing executable inside. */
|
|
34
|
-
function isPureComment(statement: string): boolean {
|
|
35
|
-
return statement
|
|
36
|
-
.split('\n')
|
|
37
|
-
.every((line) => line.trim() === '' || line.trim().startsWith('--'));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
34
|
export interface ClassifiedStatements {
|
|
41
35
|
/** Real DDL (possibly carrying leading -- WARNING lines) — what apply runs. */
|
|
42
36
|
executable: string[];
|
|
@@ -105,11 +99,19 @@ export interface DestructiveBreakdown {
|
|
|
105
99
|
* the same rule the reconciler already applies to grants: absence of knowledge means LEAVE IT
|
|
106
100
|
* ALONE and say so, never "assume it is fine".
|
|
107
101
|
*/
|
|
102
|
+
// TODO(PR-1b): classify on emitter kind, not rendered text — see the fast-follow item on the board.
|
|
108
103
|
const ADDITIVE_MATCHERS: RegExp[] = [
|
|
109
104
|
/^CREATE\s+(TABLE|POLICY|INDEX|UNIQUE\s+INDEX|SEQUENCE|EXTENSION|TYPE|SCHEMA)\b/,
|
|
110
105
|
/^GRANT\b/,
|
|
111
106
|
/^COMMENT\s+ON\b/,
|
|
112
|
-
|
|
107
|
+
// ADD COLUMN alone. Its siblings `ADD CONSTRAINT` (FOREIGN KEY / CHECK / UNIQUE) and
|
|
108
|
+
// `ADD PRIMARY KEY` are deliberately NOT here: each one VALIDATES every row already in the
|
|
109
|
+
// table and then RESTRICTS every write after it. An FK add fails on orphan rows, a CHECK add
|
|
110
|
+
// fails on any violating row, a UNIQUE add fails on existing duplicates, and ADD PRIMARY KEY
|
|
111
|
+
// fails on duplicates or NULLs *and* silently makes every included column NOT NULL. Calling
|
|
112
|
+
// those additive tells the reviewer the plan is safe when it can abort the apply outright —
|
|
113
|
+
// the same false zero described above. They read as `unclassified`, like `SET NOT NULL`.
|
|
114
|
+
/^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+COLUMN\b/,
|
|
113
115
|
// Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
|
|
114
116
|
/^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
|
|
115
117
|
// Relaxing nullability and setting a default add capability; they remove nothing.
|
|
@@ -148,12 +150,16 @@ export interface StatementPartition {
|
|
|
148
150
|
unclassified: string[];
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
/**
|
|
154
|
+
* The first non-comment line, upper-cased — WARNING prologues must not hide the verb.
|
|
155
|
+
*
|
|
156
|
+
* The reading is shared with the plain change list (sql-statement.ts); the upper-casing is
|
|
157
|
+
* this module's own, because every matcher below is written in upper case. Note this is the
|
|
158
|
+
* FIRST-LINE reading, deliberately: `partitionStatements` and `statementKind` bucket a statement
|
|
159
|
+
* by the verb it opens with. What DESTROYS is a different question and uses {@link statementBody}
|
|
160
|
+
* — see `classifyDestructive`.
|
|
161
|
+
*/
|
|
162
|
+
const upperHead = (statement: string): string => statementHead(statement).toUpperCase();
|
|
157
163
|
|
|
158
164
|
/**
|
|
159
165
|
* Partition an executable stream into exactly one bucket per statement.
|
|
@@ -174,7 +180,7 @@ export function partitionStatements(
|
|
|
174
180
|
|
|
175
181
|
for (const statement of executable) {
|
|
176
182
|
if (destructive.has(statement)) continue;
|
|
177
|
-
const head =
|
|
183
|
+
const head = upperHead(statement);
|
|
178
184
|
if (AUTHZ_REMOVAL_MATCHERS.some((re) => re.test(head))) authzRemovals.push(statement);
|
|
179
185
|
else if (ADDITIVE_MATCHERS.some((re) => re.test(head))) additive.push(statement);
|
|
180
186
|
else unclassified.push(statement);
|
|
@@ -182,12 +188,22 @@ export function partitionStatements(
|
|
|
182
188
|
return { additive, drops, narrowings, strips, authzRemovals, unclassified };
|
|
183
189
|
}
|
|
184
190
|
|
|
185
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke.
|
|
193
|
+
*
|
|
194
|
+
* Read from {@link statementBody}, not the raw text. The regex is anchored at `^REVOKE`, and the
|
|
195
|
+
* emitter welds a `-- WARNING:` prologue onto statements it wants the operator to read — so the
|
|
196
|
+
* raw text of a revoke worth warning about starts with `--`, matched nothing, and returned null.
|
|
197
|
+
* Null here means "not a revoke", which UNDER-classifies a real privilege strip and skips the
|
|
198
|
+
* whole --confirm / snapshot / approver ceremony on it. That is the failure direction that
|
|
199
|
+
* matters: it removes a gate rather than adding one.
|
|
200
|
+
*
|
|
201
|
+
* Case is NOT touched. A quoted identifier can hold anything (`"Odd-Role"`, a reserved word,
|
|
202
|
+
* mixed case) and the grantee returned here is compared against the declared set, where
|
|
203
|
+
* `Odd-Role` and `ODD-ROLE` are two different roles.
|
|
204
|
+
*/
|
|
186
205
|
export function revokeTarget(statement: string): string | null {
|
|
187
|
-
|
|
188
|
-
// reading one as null would silently UNDER-classify a strip — the failure direction that
|
|
189
|
-
// matters, since it skips the ceremony rather than adding one.
|
|
190
|
-
const m = /^REVOKE\s+[\s\S]+?\sFROM\s+("[^"]*"|[A-Za-z_][A-Za-z0-9_$]*)\s*;?\s*$/i.exec(statement.trim());
|
|
206
|
+
const m = /^REVOKE\s+[\s\S]+?\sFROM\s+("[^"]*"|[A-Za-z_][A-Za-z0-9_$]*)\s*;?\s*$/i.exec(statementBody(statement));
|
|
191
207
|
return m ? m[1].replace(/^"|"$/g, '') : null;
|
|
192
208
|
}
|
|
193
209
|
|
|
@@ -199,10 +215,17 @@ export function classifyDestructive(
|
|
|
199
215
|
const narrowings: string[] = [];
|
|
200
216
|
const strips: string[] = [];
|
|
201
217
|
for (const statement of executable) {
|
|
218
|
+
// Every matcher here reads the statement's BODY, never its raw text. The emitter's
|
|
219
|
+
// `-- WARNING:` prologue is prose written for the operator, and matching it both ways round
|
|
220
|
+
// was wrong: a comment that merely MENTIONS `DROP TABLE` ahead of an `ADD COLUMN` classified
|
|
221
|
+
// the addition as a drop, and a real `REVOKE` hiding under a prologue was not seen at all.
|
|
222
|
+
// The body preserves case, which these upper-case matchers rely on — the emitters write SQL
|
|
223
|
+
// keywords in upper case and nothing here lower-cases or upper-cases anything.
|
|
224
|
+
const body = statementBody(statement);
|
|
202
225
|
// DROP IDENTITY is a drop: it takes the backing sequence and its counter with it, and a
|
|
203
226
|
// counter is state no re-run reconstructs. The column's rows survive; its auto-value does not.
|
|
204
|
-
if (/\bDROP\s+(TABLE|COLUMN|TYPE|IDENTITY)\b/.test(
|
|
205
|
-
else if (/\bSET DATA TYPE\b/.test(
|
|
227
|
+
if (/\bDROP\s+(TABLE|COLUMN|TYPE|IDENTITY)\b/.test(body)) drops.push(statement);
|
|
228
|
+
else if (/\bSET DATA TYPE\b/.test(body) && /\bUSING\b/.test(body)) narrowings.push(statement);
|
|
206
229
|
else if (opts.declaredGrantees) {
|
|
207
230
|
// Only classified when the caller supplied the declared side — without it there is
|
|
208
231
|
// no way to tell a strip from ordinary churn, and guessing would newly force
|
|
@@ -240,8 +263,7 @@ const KIND_MATCHERS: Array<[RegExp, string]> = [
|
|
|
240
263
|
];
|
|
241
264
|
|
|
242
265
|
function statementKind(statement: string): string {
|
|
243
|
-
const
|
|
244
|
-
const s = first.trim().toUpperCase();
|
|
266
|
+
const s = upperHead(statement);
|
|
245
267
|
for (const [re, kind] of KIND_MATCHERS) {
|
|
246
268
|
if (re.test(s)) return kind;
|
|
247
269
|
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/cli/wire — the Model-derived wire contract, as the ops Lambda reaches it.
|
|
3
|
+
*
|
|
4
|
+
* The definitions live in `@everystack/model/wire` (a neutral package, so the shapes have ONE
|
|
5
|
+
* home rather than a CLI copy and a server copy that agree by convention). This file is a pure
|
|
6
|
+
* re-export, and it exists for resolution, not for taste: `@everystack/server` does not declare
|
|
7
|
+
* `@everystack/model` — under pnpm's strict layout it therefore cannot resolve
|
|
8
|
+
* `@everystack/model/wire` from its own files. It CAN resolve `@everystack/cli/*` (an optional
|
|
9
|
+
* peer it already loads `@everystack/cli/apply` from), and the CLI depends on `@everystack/model`
|
|
10
|
+
* outright, so the constant reaches the server through here.
|
|
11
|
+
*
|
|
12
|
+
* That routing is also what makes the guard measure the RIGHT thing. The server reads
|
|
13
|
+
* MODEL_WIRE_CONTRACT from the `@everystack/cli` bundled into its Lambda; the operator's CLI reads
|
|
14
|
+
* it from its own install. Those are the two versions that can drift, which is precisely the skew
|
|
15
|
+
* the handshake exists to refuse — the same axis `APPLY_CONTRACT_VERSION` already rides.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { MODEL_WIRE_CONTRACT } from '@everystack/model/wire';
|
|
19
|
+
export type {
|
|
20
|
+
ModelWireEnvelope,
|
|
21
|
+
DeclaredOwner,
|
|
22
|
+
AdopteeKind,
|
|
23
|
+
Adoptee,
|
|
24
|
+
ProvisionWirePayload,
|
|
25
|
+
OwnershipWirePayload,
|
|
26
|
+
DerivedKind,
|
|
27
|
+
DerivedAttachment,
|
|
28
|
+
DerivedDescriptor,
|
|
29
|
+
ReconcileWirePayload,
|
|
30
|
+
} from '@everystack/model/wire';
|