@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,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plain-output — what db:plan, db:apply and db:check say by DEFAULT.
|
|
3
|
+
*
|
|
4
|
+
* A developer at a consumer ran the reconcile check and got 38 lines for 3 real changes.
|
|
5
|
+
* Nineteen of them were no-op notes printed at the same visual weight as the real ones, in
|
|
6
|
+
* a vocabulary only this repo understands (`≈ rebaseline`, `≠ regrant`, `= 19 up to date`).
|
|
7
|
+
* He read it as 38 things about to happen to his database and stopped. That is the output
|
|
8
|
+
* this module replaces.
|
|
9
|
+
*
|
|
10
|
+
* The whole vocabulary is three marks:
|
|
11
|
+
*
|
|
12
|
+
* + something is now true — added OR updated. A rebuild (drop + create of the
|
|
13
|
+
* same object) is ONE `+ update …` line, never a drop/create pair.
|
|
14
|
+
* - something is no longer true — dropped, revoked.
|
|
15
|
+
* ⚠ DESTRUCTIVE real data can be lost. Orthogonal to the other two: its own line, never
|
|
16
|
+
* combined with + or -.
|
|
17
|
+
*
|
|
18
|
+
* And a banned list (BANNED_DEFAULT_WORDS): words that mean something to us and nothing to the
|
|
19
|
+
* reader. Nothing is deleted — the full internal report is exactly as it was, behind --verbose.
|
|
20
|
+
*
|
|
21
|
+
* Everything here is PURE. It describes statements; it never decides what they are. The
|
|
22
|
+
* destructive set is computed upstream (state-apply's classifyDestructive — the same
|
|
23
|
+
* classification that gates --confirm, the snapshot and the approver set) and passed in, so
|
|
24
|
+
* this module cannot disagree with the gate about what destroys. Reading a statement — which of
|
|
25
|
+
* its lines are SQL and which are the emitter's `-- WARNING:` prologue — is shared with that gate
|
|
26
|
+
* too, in sql-statement.ts, so the two paths cannot disagree about what they are looking at
|
|
27
|
+
* before either one has decided anything.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { statementBody } from './sql-statement';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// The banned vocabulary.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** Words and marks that may not appear in default output. Verbose keeps every one of them. */
|
|
37
|
+
export const BANNED_DEFAULT_WORDS: readonly string[] = [
|
|
38
|
+
'state', 'derived', 'provenance', 'fingerprint', 'phase', '≈', '≠', 'rebaseline', 'regrant',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const WORD_RE = /^[a-z]+$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Which banned words appear in these lines, in BANNED_DEFAULT_WORDS order.
|
|
45
|
+
*
|
|
46
|
+
* Whole words for the alphabetic entries — `statement` is not `state`, and failing on it would
|
|
47
|
+
* make the rule unusable rather than strict.
|
|
48
|
+
*/
|
|
49
|
+
export function bannedVocabulary(lines: readonly string[]): string[] {
|
|
50
|
+
const text = lines.join('\n');
|
|
51
|
+
return BANNED_DEFAULT_WORDS.filter((word) =>
|
|
52
|
+
WORD_RE.test(word) ? new RegExp(`\\b${word}\\b`, 'i').test(text) : text.includes(word),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Reading a statement.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The whole statement as ONE line, comments and blank lines dropped, whitespace collapsed.
|
|
62
|
+
*
|
|
63
|
+
* Two things it has to survive. A `-- WARNING:` prologue must not hide the verb — so comment
|
|
64
|
+
* lines go. And a statement is NOT single-line: the reconcile emits formatted DDL, so a trigger,
|
|
65
|
+
* a policy, a grant or an `ALTER TABLE` can arrive broken across lines. Reading only the FIRST
|
|
66
|
+
* line left `ALTER TABLE public.foo\n DROP COLUMN bar;` matching nothing — it fell through to
|
|
67
|
+
* the raw fallback and printed with a `+`, the wrong sign on a statement that removes a column.
|
|
68
|
+
* Sign is the whole vocabulary here, so getting it backwards is the one unacceptable outcome.
|
|
69
|
+
*
|
|
70
|
+
* The reading itself is sql-statement's {@link statementBody}, shared with the destructive gate.
|
|
71
|
+
* All this adds is the fallback: a statement with nothing but comments in it still shows the
|
|
72
|
+
* operator its own text rather than an empty line. The result is MATCHED against, never executed,
|
|
73
|
+
* so collapsing a function body's own formatting costs the reader nothing.
|
|
74
|
+
*/
|
|
75
|
+
function head(statement: string): string {
|
|
76
|
+
const body = statementBody(statement);
|
|
77
|
+
return body === '' ? statement.replace(/\s+/g, ' ').trim() : body;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The whole statement as one line, comments stripped and truncated — for the fallback description. */
|
|
81
|
+
function oneLine(statement: string): string {
|
|
82
|
+
const body = head(statement).replace(/;$/, '');
|
|
83
|
+
return body.length > 120 ? `${body.slice(0, 119)}…` : body;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const unquote = (name: string): string => name.replace(/"/g, '').trim();
|
|
87
|
+
|
|
88
|
+
/** `public.events` → `events`; `stats.runs` → `stats.runs`. The default schema is noise. */
|
|
89
|
+
function rel(name: string): string {
|
|
90
|
+
const clean = unquote(name).replace(/;$/, '');
|
|
91
|
+
return clean.startsWith('public.') ? clean.slice('public.'.length) : clean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A grant/revoke target: drop the object-kind keyword and any argument list. */
|
|
95
|
+
function grantObject(raw: string): string {
|
|
96
|
+
const stripped = raw
|
|
97
|
+
.trim()
|
|
98
|
+
.replace(/;$/, '')
|
|
99
|
+
.replace(/^(ALL\s+(TABLES|SEQUENCES|FUNCTIONS|ROUTINES|PROCEDURES)\s+IN\s+SCHEMA|TABLE|FUNCTION|PROCEDURE|ROUTINE|SEQUENCE|SCHEMA|TYPE|DOMAIN|DATABASE)\s+/i, '')
|
|
100
|
+
.replace(/\s*\([\s\S]*\)\s*$/, '');
|
|
101
|
+
return rel(stripped);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `SELECT, INSERT` → `select, insert`. */
|
|
105
|
+
const privileges = (raw: string): string => raw.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// One statement → one plain line.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export interface PlainChange {
|
|
112
|
+
symbol: '+' | '-';
|
|
113
|
+
text: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Options for {@link describeChange}. */
|
|
117
|
+
export interface DescribeOptions {
|
|
118
|
+
/**
|
|
119
|
+
* This CREATE is the second half of a rebuild — the batch dropped the same object first.
|
|
120
|
+
* A bare `CREATE FUNCTION` after its own `DROP FUNCTION` is not an addition; the object was
|
|
121
|
+
* already there and its definition changed. Reads exactly as `CREATE OR REPLACE` does.
|
|
122
|
+
*/
|
|
123
|
+
rebuilt?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Statement shapes that only ever take something away. Used for the fallback's sign. */
|
|
127
|
+
const REMOVAL_HEAD_RE = /^(DROP|REVOKE)\b/i;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Describe ONE non-destructive statement in plain words.
|
|
131
|
+
*
|
|
132
|
+
* Deliberately not exhaustive over PostgreSQL: an unrecognised statement falls back to itself
|
|
133
|
+
* on one line, which is honest, rather than to an invented summary that could be wrong. The
|
|
134
|
+
* sign still comes out right for the shapes that matter (a DROP or REVOKE nobody matched is
|
|
135
|
+
* still a removal).
|
|
136
|
+
*/
|
|
137
|
+
export function describeChange(statement: string, opts: DescribeOptions = {}): PlainChange {
|
|
138
|
+
const s = head(statement);
|
|
139
|
+
const rebuilt = opts.rebuilt === true;
|
|
140
|
+
let m: RegExpExecArray | null;
|
|
141
|
+
|
|
142
|
+
// ALTER TABLE …
|
|
143
|
+
if ((m = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+([\s\S]+)$/i.exec(s))) {
|
|
144
|
+
const table = rel(m[1]);
|
|
145
|
+
const rest = m[2].trim();
|
|
146
|
+
let a: RegExpExecArray | null;
|
|
147
|
+
|
|
148
|
+
if ((a = /^ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
|
|
149
|
+
return { symbol: '+', text: `add column ${table}.${unquote(a[1])}` };
|
|
150
|
+
}
|
|
151
|
+
if ((a = /^DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
|
|
152
|
+
return { symbol: '-', text: `remove column ${table}.${unquote(a[1])}` };
|
|
153
|
+
}
|
|
154
|
+
if ((a = /^ADD\s+CONSTRAINT\s+("[^"]+"|[\w$]+)/i.exec(rest))) {
|
|
155
|
+
return { symbol: '+', text: `add constraint ${unquote(a[1])} on ${table}` };
|
|
156
|
+
}
|
|
157
|
+
if ((a = /^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
|
|
158
|
+
return { symbol: '-', text: `remove constraint ${unquote(a[1])} on ${table}` };
|
|
159
|
+
}
|
|
160
|
+
if ((a = /^ALTER\s+COLUMN\s+("[^"]+"|[\w$]+)\s+([\s\S]+)$/i.exec(rest))) {
|
|
161
|
+
const column = `${table}.${unquote(a[1])}`;
|
|
162
|
+
const what = a[2].trim();
|
|
163
|
+
if (/^SET\s+NOT\s+NULL/i.test(what)) return { symbol: '+', text: `require a value in column ${column}` };
|
|
164
|
+
if (/^DROP\s+NOT\s+NULL/i.test(what)) return { symbol: '+', text: `allow an empty value in column ${column}` };
|
|
165
|
+
if (/^SET\s+DEFAULT/i.test(what)) return { symbol: '+', text: `set a default for column ${column}` };
|
|
166
|
+
if (/^DROP\s+DEFAULT/i.test(what)) return { symbol: '-', text: `remove the default from column ${column}` };
|
|
167
|
+
const type = /^SET\s+DATA\s+TYPE\s+([^\s;]+)/i.exec(what);
|
|
168
|
+
if (type) return { symbol: '+', text: `change column ${column} to ${type[1].replace(/;$/, '')}` };
|
|
169
|
+
}
|
|
170
|
+
if (/^(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY/i.test(rest)) {
|
|
171
|
+
return { symbol: '+', text: `turn on row security for ${table}` };
|
|
172
|
+
}
|
|
173
|
+
if (/^(DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY/i.test(rest)) {
|
|
174
|
+
return { symbol: '-', text: `turn off row security for ${table}` };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if ((m = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s(]+)/i.exec(s))) {
|
|
179
|
+
return { symbol: '+', text: `add table ${rel(m[1])}` };
|
|
180
|
+
}
|
|
181
|
+
if ((m = /^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
182
|
+
return { symbol: '-', text: `remove table ${rel(m[1])}` };
|
|
183
|
+
}
|
|
184
|
+
if ((m = /^CREATE\s+POLICY\s+("[^"]+"|[\w$]+)\s+ON\s+([^\s;]+)/i.exec(s))) {
|
|
185
|
+
return { symbol: '+', text: `add policy ${unquote(m[1])} on ${rel(m[2])}` };
|
|
186
|
+
}
|
|
187
|
+
if ((m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)\s+ON\s+([^\s;]+)/i.exec(s))) {
|
|
188
|
+
return { symbol: '-', text: `remove policy ${unquote(m[1])} on ${rel(m[2])}` };
|
|
189
|
+
}
|
|
190
|
+
if ((m = /^GRANT\s+([\s\S]+?)\s+ON\s+([\s\S]+?)\s+TO\s+([^\s;]+)/i.exec(s))) {
|
|
191
|
+
return { symbol: '+', text: `grant ${unquote(m[3])}: ${privileges(m[1])} ${grantObject(m[2])}` };
|
|
192
|
+
}
|
|
193
|
+
if ((m = /^REVOKE\s+([\s\S]+?)\s+ON\s+([\s\S]+?)\s+FROM\s+([^\s;]+)/i.exec(s))) {
|
|
194
|
+
return { symbol: '-', text: `revoke ${unquote(m[3])}: ${privileges(m[1])} ${grantObject(m[2])}` };
|
|
195
|
+
}
|
|
196
|
+
// A "rebuild" — the same function coming back with a new body — is ONE update line. The
|
|
197
|
+
// drop half is never shown: the object does not go away, its definition changes.
|
|
198
|
+
if ((m = /^CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\s+([^\s(]+)/i.exec(s))) {
|
|
199
|
+
const what = m[2].toLowerCase();
|
|
200
|
+
return m[1] || rebuilt
|
|
201
|
+
? { symbol: '+', text: `update ${what} ${rel(m[3])}(...): definition updated` }
|
|
202
|
+
: { symbol: '+', text: `add ${what} ${rel(m[3])}(...)` };
|
|
203
|
+
}
|
|
204
|
+
if ((m = /^DROP\s+(FUNCTION|PROCEDURE)\s+(?:IF\s+EXISTS\s+)?([^\s(;]+)/i.exec(s))) {
|
|
205
|
+
return { symbol: '-', text: `remove ${m[1].toLowerCase()} ${rel(m[2])}(...)` };
|
|
206
|
+
}
|
|
207
|
+
if ((m = /^CREATE\s+(OR\s+REPLACE\s+)?(?:MATERIALIZED\s+)?VIEW\s+([^\s(;]+)/i.exec(s))) {
|
|
208
|
+
return m[1] || rebuilt
|
|
209
|
+
? { symbol: '+', text: `update view ${rel(m[2])}: definition updated` }
|
|
210
|
+
: { symbol: '+', text: `add view ${rel(m[2])}` };
|
|
211
|
+
}
|
|
212
|
+
if ((m = /^REFRESH\s+MATERIALIZED\s+VIEW\s+(?:CONCURRENTLY\s+)?([^\s;]+)/i.exec(s))) {
|
|
213
|
+
return { symbol: '+', text: `refresh view ${rel(m[1])}` };
|
|
214
|
+
}
|
|
215
|
+
if ((m = /^DROP\s+(?:MATERIALIZED\s+)?VIEW\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
216
|
+
return { symbol: '-', text: `remove view ${rel(m[1])}` };
|
|
217
|
+
}
|
|
218
|
+
if ((m = /^CREATE\s+(?:CONSTRAINT\s+)?TRIGGER\s+("[^"]+"|[\w$]+)[\s\S]*?\sON\s+([^\s;]+)/i.exec(s))) {
|
|
219
|
+
return { symbol: '+', text: `add trigger ${unquote(m[1])} on ${rel(m[2])}` };
|
|
220
|
+
}
|
|
221
|
+
if ((m = /^DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)\s+ON\s+([^\s;]+)/i.exec(s))) {
|
|
222
|
+
return { symbol: '-', text: `remove trigger ${unquote(m[1])} on ${rel(m[2])}` };
|
|
223
|
+
}
|
|
224
|
+
if ((m = /^CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?("[^"]+"|[\w$]+)\s+ON\s+([^\s(;]+)/i.exec(s))) {
|
|
225
|
+
return { symbol: '+', text: `add index ${unquote(m[2])} on ${rel(m[3])}` };
|
|
226
|
+
}
|
|
227
|
+
if ((m = /^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
228
|
+
return { symbol: '-', text: `remove index ${rel(m[1])}` };
|
|
229
|
+
}
|
|
230
|
+
if ((m = /^CREATE\s+TYPE\s+([^\s;]+)/i.exec(s))) {
|
|
231
|
+
return { symbol: '+', text: `add type ${rel(m[1])}` };
|
|
232
|
+
}
|
|
233
|
+
if ((m = /^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
234
|
+
return { symbol: '-', text: `remove type ${rel(m[1])}` };
|
|
235
|
+
}
|
|
236
|
+
if ((m = /^CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
237
|
+
return { symbol: '+', text: `add schema ${unquote(m[1])}` };
|
|
238
|
+
}
|
|
239
|
+
if ((m = /^CREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
240
|
+
return { symbol: '+', text: `add extension ${unquote(m[1])}` };
|
|
241
|
+
}
|
|
242
|
+
if ((m = /^CREATE\s+SEQUENCE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
243
|
+
return { symbol: '+', text: `add sequence ${rel(m[1])}` };
|
|
244
|
+
}
|
|
245
|
+
if ((m = /^DROP\s+SEQUENCE\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
246
|
+
return { symbol: '-', text: `remove sequence ${rel(m[1])}` };
|
|
247
|
+
}
|
|
248
|
+
if ((m = /^COMMENT\s+ON\s+\w+(?:\s+\w+)?\s+([^\s;]+)\s+IS\b/i.exec(s))) {
|
|
249
|
+
return { symbol: '+', text: `update the comment on ${rel(m[1])}` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { symbol: REMOVAL_HEAD_RE.test(s) ? '-' : '+', text: oneLine(statement) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Describe ONE destructive statement — the wording says what is LOST, because that is the
|
|
257
|
+
* only question the reader has. No sign: the ⚠ line stands alone.
|
|
258
|
+
*/
|
|
259
|
+
export function describeDestructive(statement: string): string {
|
|
260
|
+
const s = head(statement);
|
|
261
|
+
let m: RegExpExecArray | null;
|
|
262
|
+
|
|
263
|
+
if ((m = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(s))) {
|
|
264
|
+
return `drops column ${rel(m[1])}.${unquote(m[2])}`;
|
|
265
|
+
}
|
|
266
|
+
if ((m = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+ALTER\s+COLUMN\s+("[^"]+"|[\w$]+)\s+DROP\s+IDENTITY/i.exec(s))) {
|
|
267
|
+
return `drops the auto-value on column ${rel(m[1])}.${unquote(m[2])} (its counter is not recoverable)`;
|
|
268
|
+
}
|
|
269
|
+
if ((m = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+ALTER\s+COLUMN\s+("[^"]+"|[\w$]+)\s+SET\s+DATA\s+TYPE\s+([^\s;]+)/i.exec(s))) {
|
|
270
|
+
return `changes the type of column ${rel(m[1])}.${unquote(m[2])} to ${m[3].replace(/;$/, '')} (values that do not fit are lost)`;
|
|
271
|
+
}
|
|
272
|
+
if ((m = /^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
273
|
+
return `drops table ${rel(m[1])}`;
|
|
274
|
+
}
|
|
275
|
+
if ((m = /^DROP\s+TYPE\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
276
|
+
return `drops type ${rel(m[1])}`;
|
|
277
|
+
}
|
|
278
|
+
if ((m = /^REVOKE\s+([\s\S]+?)\s+ON\s+([\s\S]+?)\s+FROM\s+([^\s;]+)/i.exec(s))) {
|
|
279
|
+
return `revokes ${privileges(m[1])} on ${grantObject(m[2])} from ${unquote(m[3])} — nothing declares it, so it cannot be put back`;
|
|
280
|
+
}
|
|
281
|
+
return oneLine(statement);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Rebuilds: a drop and a create of the same object are ONE change.
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The object a statement drops or creates, as `kind:name`, or null for a statement that does
|
|
290
|
+
* neither. A matview and a view share the `view` kind because the reader does too.
|
|
291
|
+
*
|
|
292
|
+
* A function's key deliberately omits its argument list: the DROP carries catalog argument
|
|
293
|
+
* TYPES and the CREATE carries declared argument NAMES, and the two never match textually. The
|
|
294
|
+
* cost is that dropping one overload while creating another would read as one update line
|
|
295
|
+
* instead of two — a display difference on a rare shape, in exchange for every ordinary
|
|
296
|
+
* function rebuild reading correctly.
|
|
297
|
+
*/
|
|
298
|
+
function objectKey(s: string): { verb: 'drop' | 'create'; key: string } | null {
|
|
299
|
+
let m: RegExpExecArray | null;
|
|
300
|
+
if ((m = /^DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)\s+ON\s+([^\s;]+)/i.exec(s))) {
|
|
301
|
+
return { verb: 'drop', key: `trigger:${rel(m[2])}.${unquote(m[1])}` };
|
|
302
|
+
}
|
|
303
|
+
if ((m = /^CREATE\s+(?:CONSTRAINT\s+)?TRIGGER\s+("[^"]+"|[\w$]+)[\s\S]*?\sON\s+([^\s;]+)/i.exec(s))) {
|
|
304
|
+
return { verb: 'create', key: `trigger:${rel(m[2])}.${unquote(m[1])}` };
|
|
305
|
+
}
|
|
306
|
+
if ((m = /^DROP\s+(?:MATERIALIZED\s+)?VIEW\s+(?:IF\s+EXISTS\s+)?([^\s;]+)/i.exec(s))) {
|
|
307
|
+
return { verb: 'drop', key: `view:${rel(m[1])}` };
|
|
308
|
+
}
|
|
309
|
+
if ((m = /^CREATE\s+(?:OR\s+REPLACE\s+)?(?:MATERIALIZED\s+)?VIEW\s+([^\s(;]+)/i.exec(s))) {
|
|
310
|
+
return { verb: 'create', key: `view:${rel(m[1])}` };
|
|
311
|
+
}
|
|
312
|
+
if ((m = /^DROP\s+(FUNCTION|PROCEDURE)\s+(?:IF\s+EXISTS\s+)?([^\s(;]+)/i.exec(s))) {
|
|
313
|
+
return { verb: 'drop', key: `${m[1].toLowerCase()}:${rel(m[2])}` };
|
|
314
|
+
}
|
|
315
|
+
if ((m = /^CREATE\s+(?:OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\s+([^\s(;]+)/i.exec(s))) {
|
|
316
|
+
return { verb: 'create', key: `${m[1].toLowerCase()}:${rel(m[2])}` };
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Drop the DROP half of every rebuild in a batch.
|
|
323
|
+
*
|
|
324
|
+
* The function/view reconcile rebuilds an object by dropping it and creating it again. That is
|
|
325
|
+
* how the DDL has to be written; it is not what happened to the database. Shown raw, one
|
|
326
|
+
* changed view reads as a removal AND an addition — two lines, one of them frightening, for a
|
|
327
|
+
* definition edit. So the drop is removed and its create is marked `rebuilt`, which renders as
|
|
328
|
+
* the update line it always was.
|
|
329
|
+
*
|
|
330
|
+
* Statements this does not recognise pass through untouched, in order.
|
|
331
|
+
*/
|
|
332
|
+
export function collapseRebuilds(statements: readonly string[]): {
|
|
333
|
+
statements: string[];
|
|
334
|
+
rebuilt: ReadonlySet<string>;
|
|
335
|
+
} {
|
|
336
|
+
const created = new Map<string, string>();
|
|
337
|
+
for (const s of statements) {
|
|
338
|
+
const k = objectKey(head(s));
|
|
339
|
+
if (k?.verb === 'create') created.set(k.key, s);
|
|
340
|
+
}
|
|
341
|
+
const kept: string[] = [];
|
|
342
|
+
const rebuilt = new Set<string>();
|
|
343
|
+
for (const s of statements) {
|
|
344
|
+
const k = objectKey(head(s));
|
|
345
|
+
if (k?.verb === 'drop' && created.has(k.key)) {
|
|
346
|
+
rebuilt.add(created.get(k.key)!);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
kept.push(s);
|
|
350
|
+
}
|
|
351
|
+
return { statements: kept, rebuilt };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// The reports.
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/** How many change lines a report prints before it collapses to a count. */
|
|
359
|
+
const MAX_LISTED_CHANGES = 50;
|
|
360
|
+
/** Above this, db:apply's receipt is a count rather than a list — "not per object if there are many". */
|
|
361
|
+
const MAX_RECEIPT_CHANGES = 20;
|
|
362
|
+
|
|
363
|
+
const DESTRUCTIVE_MARK = '⚠ DESTRUCTIVE';
|
|
364
|
+
|
|
365
|
+
const plural = (n: number, one: string, many: string): string => (n === 1 ? one : many);
|
|
366
|
+
|
|
367
|
+
/** One change line, indented, already carrying its mark. */
|
|
368
|
+
function changeLine(statement: string, destructive: ReadonlySet<string>, rebuilt?: ReadonlySet<string>): string {
|
|
369
|
+
if (destructive.has(statement)) return ` ${DESTRUCTIVE_MARK} — ${describeDestructive(statement)}`;
|
|
370
|
+
const { symbol, text } = describeChange(statement, { rebuilt: rebuilt?.has(statement) === true });
|
|
371
|
+
return ` ${symbol} ${text}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function changeLines(
|
|
375
|
+
executable: readonly string[],
|
|
376
|
+
destructive: ReadonlySet<string>,
|
|
377
|
+
limit: number,
|
|
378
|
+
rebuilt?: ReadonlySet<string>,
|
|
379
|
+
): string[] {
|
|
380
|
+
const shown = executable.slice(0, limit).map((s) => changeLine(s, destructive, rebuilt));
|
|
381
|
+
const hidden = executable.length - shown.length;
|
|
382
|
+
if (hidden > 0) shown.push(` … and ${hidden} more (--verbose for the full list)`);
|
|
383
|
+
return shown;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export interface PlanReportInput {
|
|
387
|
+
/** The real DDL the plan will run — notices and held drops are not changes. */
|
|
388
|
+
executable: readonly string[];
|
|
389
|
+
/** The destructive subset, as the apply gate classifies it. Must be members of `executable`. */
|
|
390
|
+
destructive: readonly string[];
|
|
391
|
+
/** What the plan is aimed at, in the reader's words: a stage name, or `local`. */
|
|
392
|
+
target: string;
|
|
393
|
+
/** The stage flag to repeat in the Run line, when there is one. */
|
|
394
|
+
stage?: string | null;
|
|
395
|
+
/** The plan file to name in the Run line; null when the plan went to stdout. */
|
|
396
|
+
planPath: string | null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** db:plan's default output — the whole thing. */
|
|
400
|
+
export function renderPlanReport(input: PlanReportInput): string[] {
|
|
401
|
+
const { executable, target, stage, planPath } = input;
|
|
402
|
+
if (executable.length === 0) return ['Up to date. Nothing to plan.'];
|
|
403
|
+
|
|
404
|
+
const destructive = new Set(input.destructive);
|
|
405
|
+
const lines: string[] = [
|
|
406
|
+
`Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
|
|
407
|
+
'',
|
|
408
|
+
...changeLines(executable, destructive, MAX_LISTED_CHANGES),
|
|
409
|
+
'',
|
|
410
|
+
];
|
|
411
|
+
|
|
412
|
+
lines.push(
|
|
413
|
+
destructive.size > 0
|
|
414
|
+
? `Verify the destructive ${plural(destructive.size, 'change', 'changes')}, then run db:backup before applying.`
|
|
415
|
+
: 'Non-destructive. Safe to apply.',
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// No file, no command to give — `--out -` piped the plan somewhere only the caller knows.
|
|
419
|
+
if (planPath !== null) {
|
|
420
|
+
const confirm = destructive.size > 0 ? ' --confirm' : '';
|
|
421
|
+
const stageFlag = stage ? ` --stage ${stage}` : '';
|
|
422
|
+
lines.push('', `Run: db:apply --plan ${planPath}${confirm}${stageFlag}`);
|
|
423
|
+
}
|
|
424
|
+
return lines;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export interface ApplyReceiptInput {
|
|
428
|
+
executable: readonly string[];
|
|
429
|
+
destructive: readonly string[];
|
|
430
|
+
/** The target was already where the plan ends — an idempotent re-apply. */
|
|
431
|
+
alreadyApplied?: boolean;
|
|
432
|
+
/**
|
|
433
|
+
* The function/view/trigger DDL db:apply ran after the plan, on the same target.
|
|
434
|
+
*
|
|
435
|
+
* It joins the SAME list and the SAME count: the operator ran one command, so they read one
|
|
436
|
+
* receipt. Rebuild pairs collapse to a single update line on the way in.
|
|
437
|
+
*/
|
|
438
|
+
catchUp?: readonly string[];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** db:apply's default output: the plan's change list back as a receipt, then the count. */
|
|
442
|
+
export function renderApplyReceipt(input: ApplyReceiptInput): string[] {
|
|
443
|
+
const { statements: caughtUp, rebuilt } = collapseRebuilds(input.catchUp ?? []);
|
|
444
|
+
// An idempotent re-apply ran no plan statements; only the catch-up can have done anything.
|
|
445
|
+
const executable = input.alreadyApplied ? caughtUp : [...input.executable, ...caughtUp];
|
|
446
|
+
if (input.alreadyApplied && executable.length === 0) return ['Up to date. Nothing to apply.'];
|
|
447
|
+
const n = executable.length;
|
|
448
|
+
const tally = `Applied ${n} ${plural(n, 'change', 'changes')}.`;
|
|
449
|
+
if (n === 0 || n > MAX_RECEIPT_CHANGES) return [tally];
|
|
450
|
+
return [...changeLines(executable, new Set(input.destructive), MAX_RECEIPT_CHANGES, rebuilt), '', tally];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export interface CatchUpFailureInput {
|
|
454
|
+
/** How many table/column changes are already committed. */
|
|
455
|
+
applied: number;
|
|
456
|
+
/** The exact command that retries ONLY the functions and views. */
|
|
457
|
+
rerun: string;
|
|
458
|
+
/** Why it did not finish, as the failure itself said it. Its first line rides line 1. */
|
|
459
|
+
reason?: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The whole default output when the plan landed and the functions/views then did not.
|
|
464
|
+
*
|
|
465
|
+
* LINE 1 IS ALWAYS ONE LINE, and it names TWO subjects explicitly — the table and column
|
|
466
|
+
* changes, then the functions and views — because the reader's one question is "is my
|
|
467
|
+
* database half-changed and what do I type now", and a single sentence covering both halves
|
|
468
|
+
* at once reads as self-contradictory ("nothing was sent" inside "applied and safe").
|
|
469
|
+
* Two subjects, two verdicts, then the rerun. Nothing else may precede it.
|
|
470
|
+
*
|
|
471
|
+
* The cause does not get folded onto that line. A real refusal is structured and
|
|
472
|
+
* copy-pasteable — four lines of prose and then `CREATE ROLE app_owner NOLOGIN;` — and
|
|
473
|
+
* flattening it ran past 900 characters and pushed `Rerun:` off the end of the screen. So
|
|
474
|
+
* line 1 takes the HEADLINE only (the cause's first line), and a multi-line cause is
|
|
475
|
+
* reprinted verbatim below, indented, ALWAYS — not behind --verbose, because an operator who
|
|
476
|
+
* cannot see the cause reruns the command that just failed and fails the same way. It appears
|
|
477
|
+
* exactly once, in exactly one place, at every verbosity.
|
|
478
|
+
*/
|
|
479
|
+
export function renderCatchUpFailure(input: CatchUpFailureInput): string[] {
|
|
480
|
+
const n = input.applied;
|
|
481
|
+
const state = n === 0
|
|
482
|
+
? 'Table and column changes: none applied.'
|
|
483
|
+
: `Table and column changes: applied ${n} ${plural(n, 'change', 'changes')}, committed.`;
|
|
484
|
+
const cause = causeLines(input.reason);
|
|
485
|
+
const head = cause.length === 0
|
|
486
|
+
? 'Functions and views: not finished.'
|
|
487
|
+
: `Functions and views: not finished — ${sentence(headline(cause[0]))}`;
|
|
488
|
+
const line = `${state} ${head} Rerun: ${input.rerun}`;
|
|
489
|
+
if (cause.length <= 1) return [line];
|
|
490
|
+
return [line, '', ...cause.map((text) => (text.trim() === '' ? '' : ` ${text}`))];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The cause as it was written — its own line breaks kept, its blank top and tail dropped. */
|
|
494
|
+
function causeLines(reason: string | undefined): string[] {
|
|
495
|
+
const lines = (reason ?? '').split('\n').map((l) => l.replace(/\s+$/, ''));
|
|
496
|
+
while (lines.length > 0 && lines[0].trim() === '') lines.shift();
|
|
497
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
|
|
498
|
+
return lines;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The cause's first line as a headline: whitespace tidied, its own full stop dropped. */
|
|
502
|
+
function headline(first: string): string {
|
|
503
|
+
return first.replace(/\s+/g, ' ').trim().replace(/\.+$/, '');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* End the headline with a stop — unless it already ends in one of its own.
|
|
508
|
+
*
|
|
509
|
+
* A refusal's first line can end in a SQL statement, and `;` is that statement's terminator.
|
|
510
|
+
* Appending ours produced `SET TRUE;.`, which reads as corruption. So `.` closes a headline
|
|
511
|
+
* that has no terminator, and a headline that has one keeps it.
|
|
512
|
+
*/
|
|
513
|
+
function sentence(text: string): string {
|
|
514
|
+
return /[;:!?]$/.test(text) ? text : `${text}.`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** db:check's default output: one line, always. */
|
|
518
|
+
export function renderCheckVerdict(pending: number): string[] {
|
|
519
|
+
if (pending === 0) return ['Up to date.'];
|
|
520
|
+
return [`${pending} ${plural(pending, 'change', 'changes')} pending. Run db:plan to review.`];
|
|
521
|
+
}
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -143,6 +143,12 @@ export type TableMoveMap = Record<string, string>;
|
|
|
143
143
|
|
|
144
144
|
// ---------------------------------------------------------------------------
|
|
145
145
|
// Normalization — so semantically-equal expressions don't read as drift.
|
|
146
|
+
//
|
|
147
|
+
// These normalizers are LEAF PRIMITIVES the frozen retained canons reuse (schema-fingerprint-v10,
|
|
148
|
+
// -v11). Changing what any of them return moves EVERY recorded fingerprint, current and retained,
|
|
149
|
+
// and would corrupt descent. It is not free to tweak: the golden-hash tests
|
|
150
|
+
// (schema-fingerprint-v11-freeze.test.ts) will fail first, by design — treat that as the signal
|
|
151
|
+
// that the change needs its own FINGERPRINT_VERSION bump, not a test to update.
|
|
146
152
|
// ---------------------------------------------------------------------------
|
|
147
153
|
|
|
148
154
|
/**
|