@rehearsal-db/core 1.0.2 → 1.2.0
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 +56 -4
- package/COMPATIBILITY.md +41 -4
- package/README.md +8 -4
- package/dist/package.json +3 -1
- package/dist/src/cli/arguments.mjs +1 -0
- package/dist/src/cli/basic_command_handlers.mjs +4 -0
- package/dist/src/cli/editor_command_handlers.mjs +24 -0
- package/dist/src/project/configuration_contract.d.mts +1 -0
- package/dist/src/project/configuration_normalization.mjs +2 -1
- package/dist/src/project/configuration_rendering.mjs +3 -2
- package/dist/src/project/configuration_resolution.mjs +0 -2
- package/dist/src/project/project_detection.d.mts +2 -0
- package/dist/src/project/project_detection.mjs +1 -0
- package/dist/src/project/runtime_project_identity.d.mts +5 -0
- package/dist/src/project/runtime_project_identity.mjs +41 -0
- package/dist/src/project/setup.mjs +3 -1
- package/dist/src/runtime/migration_history.d.mts +5 -1
- package/dist/src/runtime/migration_history.mjs +24 -6
- package/dist/src/runtime/migration_impact.d.mts +125 -0
- package/dist/src/runtime/migration_impact.mjs +278 -0
- package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
- package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
- package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
- package/dist/src/runtime/migration_impact_plan.mjs +297 -0
- package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
- package/dist/src/runtime/migration_impact_queries.mjs +270 -0
- package/dist/src/runtime/migration_insights.d.mts +137 -0
- package/dist/src/runtime/migration_insights.mjs +143 -0
- package/dist/src/runtime/migration_insights_input.d.mts +13 -0
- package/dist/src/runtime/migration_insights_input.mjs +153 -0
- package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
- package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
- package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
- package/dist/src/runtime/migration_sql_scan.mjs +369 -0
- package/dist/src/runtime/plan.d.mts +2 -1
- package/dist/src/runtime/plan.mjs +4 -4
- package/dist/src/runtime/policy.d.mts +2 -0
- package/dist/src/runtime/policy.mjs +13 -1
- package/dist/src/runtime/privileges.d.mts +25 -0
- package/dist/src/runtime/privileges.mjs +198 -0
- package/dist/src/runtime/topology.d.mts +1 -1
- package/dist/src/runtime/topology.mjs +2 -2
- package/dist/src/shared/cancellation.mjs +1 -0
- package/dist/src/shared/held_path_ancestry.d.mts +7 -0
- package/dist/src/shared/held_path_ancestry.mjs +110 -0
- package/dist/src/shared/operation_guard.mjs +1 -0
- package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
- package/dist/src/targets/migration_impact_runtime.mjs +367 -0
- package/dist/src/targets/supabase.mjs +1 -0
- package/docs/README.md +20 -7
- package/docs/adapters.md +10 -0
- package/docs/architecture.md +75 -1
- package/docs/commands.md +169 -34
- package/docs/configuration.md +42 -9
- package/docs/getting-started.md +4 -4
- package/docs/index.md +1 -0
- package/docs/releasing.md +15 -11
- package/docs/roadmap.md +49 -9
- package/docs/runtime-lifecycle.md +15 -4
- package/docs/runtime-policies.md +145 -0
- package/docs/stability.md +8 -4
- package/docs/updating.md +73 -0
- package/docs/vscode.md +30 -19
- package/package.json +3 -1
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { lexMigrationSql, } from "./migration_sql_lexer.mjs";
|
|
2
|
+
const word = (token, value) => token?.kind === "word" && token.value === value;
|
|
3
|
+
const name = (token) => token?.kind === "word" || token?.kind === "identifier";
|
|
4
|
+
const qualifiedNameEnd = (tokens, start) => {
|
|
5
|
+
if (!name(tokens[start]))
|
|
6
|
+
return start;
|
|
7
|
+
let end = start + 1;
|
|
8
|
+
while (tokens[end]?.value === "." && name(tokens[end + 1]))
|
|
9
|
+
end += 2;
|
|
10
|
+
return end;
|
|
11
|
+
};
|
|
12
|
+
const topTokens = (tokens, depth) => tokens.filter((token) => token.depth === depth);
|
|
13
|
+
/** Static, local-only review hints. Never executes SQL or returns input text. */
|
|
14
|
+
export const scanMigrationSql = (source) => {
|
|
15
|
+
const lexical = lexMigrationSql(source);
|
|
16
|
+
const reasons = new Set(lexical.unknownReasons);
|
|
17
|
+
// Runtime-dependent plain-string escape settings can change statement boundaries.
|
|
18
|
+
if (lexical.ambiguousQuotes)
|
|
19
|
+
return {
|
|
20
|
+
complete: false,
|
|
21
|
+
findings: [],
|
|
22
|
+
unknownReasons: [...reasons].sort(),
|
|
23
|
+
};
|
|
24
|
+
const findings = [];
|
|
25
|
+
const findingKeys = new Set();
|
|
26
|
+
const add = (rule, token) => {
|
|
27
|
+
const key = `${rule}:${token.line}:${token.column}`;
|
|
28
|
+
if (findingKeys.has(key))
|
|
29
|
+
return;
|
|
30
|
+
if (findings.length >= 1000) {
|
|
31
|
+
reasons.add("input_limit");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
findingKeys.add(key);
|
|
35
|
+
findings.push({
|
|
36
|
+
rule,
|
|
37
|
+
severity: ["not_null", "new_constraint"].includes(rule)
|
|
38
|
+
? "warning"
|
|
39
|
+
: "danger",
|
|
40
|
+
line: token.line,
|
|
41
|
+
column: token.column,
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
const unsupported = () => {
|
|
45
|
+
reasons.add("unsupported_sql");
|
|
46
|
+
};
|
|
47
|
+
const malformed = () => {
|
|
48
|
+
reasons.add("malformed_sql");
|
|
49
|
+
};
|
|
50
|
+
const cascade = (tokens, trailing = true) => {
|
|
51
|
+
const last = tokens.at(-1);
|
|
52
|
+
if (trailing && word(last, "CASCADE"))
|
|
53
|
+
add("cascade", last);
|
|
54
|
+
for (let index = 2; index < tokens.length; index += 1)
|
|
55
|
+
if (word(tokens[index], "CASCADE") &&
|
|
56
|
+
(word(tokens[index - 1], "DELETE") ||
|
|
57
|
+
word(tokens[index - 1], "UPDATE")) &&
|
|
58
|
+
word(tokens[index - 2], "ON"))
|
|
59
|
+
add("cascade", tokens[index]);
|
|
60
|
+
};
|
|
61
|
+
const relationList = (tokens, start) => {
|
|
62
|
+
if (start >= tokens.length)
|
|
63
|
+
return false;
|
|
64
|
+
let index = start;
|
|
65
|
+
while (index < tokens.length) {
|
|
66
|
+
if (word(tokens[index], "ONLY"))
|
|
67
|
+
index += 1;
|
|
68
|
+
const end = qualifiedNameEnd(tokens, index);
|
|
69
|
+
if (end === index)
|
|
70
|
+
return false;
|
|
71
|
+
index = end;
|
|
72
|
+
if (tokens[index]?.value === "*")
|
|
73
|
+
index += 1;
|
|
74
|
+
if (tokens[index]?.value !== ",")
|
|
75
|
+
break;
|
|
76
|
+
index += 1;
|
|
77
|
+
if (index === tokens.length)
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if ((word(tokens[index], "RESTART") || word(tokens[index], "CONTINUE")) &&
|
|
81
|
+
word(tokens[index + 1], "IDENTITY"))
|
|
82
|
+
index += 2;
|
|
83
|
+
if (word(tokens[index], "CASCADE") || word(tokens[index], "RESTRICT"))
|
|
84
|
+
index += 1;
|
|
85
|
+
return index === tokens.length;
|
|
86
|
+
};
|
|
87
|
+
const analyze = (tokens, depth) => {
|
|
88
|
+
if (tokens.length === 0)
|
|
89
|
+
return;
|
|
90
|
+
const top = topTokens(tokens, depth);
|
|
91
|
+
const first = top[0];
|
|
92
|
+
if (!first || first.kind !== "word") {
|
|
93
|
+
unsupported();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (word(first, "WITH")) {
|
|
97
|
+
let index = 1;
|
|
98
|
+
if (word(tokens[index], "RECURSIVE"))
|
|
99
|
+
index += 1;
|
|
100
|
+
const afterGroup = (start) => {
|
|
101
|
+
const opening = tokens[start];
|
|
102
|
+
if (opening?.value !== "(")
|
|
103
|
+
return start;
|
|
104
|
+
const close = tokens.findIndex((token, candidate) => candidate > start &&
|
|
105
|
+
token.value === ")" &&
|
|
106
|
+
token.depth === opening.depth);
|
|
107
|
+
return close < 0 ? start : close + 1;
|
|
108
|
+
};
|
|
109
|
+
while (index < tokens.length) {
|
|
110
|
+
if (!name(tokens[index])) {
|
|
111
|
+
unsupported();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
index += 1;
|
|
115
|
+
if (tokens[index]?.value === "(") {
|
|
116
|
+
const next = afterGroup(index);
|
|
117
|
+
if (next === index) {
|
|
118
|
+
malformed();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
index = next;
|
|
122
|
+
}
|
|
123
|
+
if (!word(tokens[index], "AS")) {
|
|
124
|
+
unsupported();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
index += 1;
|
|
128
|
+
if (word(tokens[index], "NOT")) {
|
|
129
|
+
index += 1;
|
|
130
|
+
if (!word(tokens[index], "MATERIALIZED")) {
|
|
131
|
+
unsupported();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (word(tokens[index], "MATERIALIZED"))
|
|
136
|
+
index += 1;
|
|
137
|
+
const end = afterGroup(index);
|
|
138
|
+
if (end === index) {
|
|
139
|
+
unsupported();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (end === index + 2) {
|
|
143
|
+
malformed();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
analyze(tokens.slice(index + 1, end - 1), depth + 1);
|
|
147
|
+
index = end;
|
|
148
|
+
if (tokens[index]?.value !== ",")
|
|
149
|
+
break;
|
|
150
|
+
index += 1;
|
|
151
|
+
}
|
|
152
|
+
if (index >= tokens.length) {
|
|
153
|
+
malformed();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
analyze(tokens.slice(index), depth);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (word(first, "DROP") && word(top[1], "TABLE")) {
|
|
160
|
+
let start = 2;
|
|
161
|
+
if (word(top[start], "IF") && word(top[start + 1], "EXISTS"))
|
|
162
|
+
start += 2;
|
|
163
|
+
if (!relationList(top, start)) {
|
|
164
|
+
malformed();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
add("drop_table", first);
|
|
168
|
+
cascade(top);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (word(first, "TRUNCATE")) {
|
|
172
|
+
const start = word(top[1], "TABLE") ? 2 : 1;
|
|
173
|
+
if (!relationList(top, start)) {
|
|
174
|
+
malformed();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
add("truncate", first);
|
|
178
|
+
cascade(top);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (word(first, "UPDATE") || word(first, "DELETE")) {
|
|
182
|
+
const update = word(first, "UPDATE");
|
|
183
|
+
let start = update ? 1 : 2;
|
|
184
|
+
if (!update && !word(top[1], "FROM")) {
|
|
185
|
+
malformed();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (word(top[start], "ONLY"))
|
|
189
|
+
start += 1;
|
|
190
|
+
const end = qualifiedNameEnd(top, start);
|
|
191
|
+
if (end === start) {
|
|
192
|
+
malformed();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const where = top.findIndex((token, index) => index >= end && word(token, "WHERE"));
|
|
196
|
+
const returning = top.findIndex((token, index) => index >= end && word(token, "RETURNING"));
|
|
197
|
+
const set = top.findIndex((token, index) => index >= end && word(token, "SET"));
|
|
198
|
+
const boundary = Math.min(...[where, returning, top.length].filter((position) => position >= 0));
|
|
199
|
+
const assignment = top.slice(set + 1, boundary);
|
|
200
|
+
const equals = assignment.findIndex((token) => token.value === "=");
|
|
201
|
+
if (update &&
|
|
202
|
+
(set < 0 ||
|
|
203
|
+
equals <= 0 ||
|
|
204
|
+
equals === assignment.length - 1 ||
|
|
205
|
+
assignment.at(-1)?.value === ",")) {
|
|
206
|
+
malformed();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (where >= 0 &&
|
|
210
|
+
(where === top.length - 1 || (returning >= 0 && returning <= where + 1))) {
|
|
211
|
+
malformed();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (where < 0 || (returning >= 0 && returning < where))
|
|
215
|
+
add(update ? "broad_update" : "broad_delete", first);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (word(first, "ALTER") && word(top[1], "TABLE")) {
|
|
219
|
+
let index = 2;
|
|
220
|
+
if (word(top[index], "IF") && word(top[index + 1], "EXISTS"))
|
|
221
|
+
index += 2;
|
|
222
|
+
if (word(top[index], "ONLY"))
|
|
223
|
+
index += 1;
|
|
224
|
+
const end = qualifiedNameEnd(top, index);
|
|
225
|
+
if (end === index) {
|
|
226
|
+
malformed();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
index = end + (top[end]?.value === "*" ? 1 : 0);
|
|
230
|
+
if (index >= top.length) {
|
|
231
|
+
malformed();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
while (index < top.length) {
|
|
235
|
+
const comma = top.findIndex((token, candidate) => candidate >= index && token.value === ",");
|
|
236
|
+
const clause = top.slice(index, comma < 0 ? undefined : comma);
|
|
237
|
+
if (clause.length === 0) {
|
|
238
|
+
malformed();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (word(clause[0], "DROP")) {
|
|
242
|
+
let column = 1;
|
|
243
|
+
if (word(clause[column], "COLUMN"))
|
|
244
|
+
column += 1;
|
|
245
|
+
if (word(clause[column], "IF") && word(clause[column + 1], "EXISTS"))
|
|
246
|
+
column += 2;
|
|
247
|
+
if (word(clause[1], "CONSTRAINT"))
|
|
248
|
+
unsupported();
|
|
249
|
+
else if (name(clause[column]) &&
|
|
250
|
+
(column + 1 === clause.length ||
|
|
251
|
+
((word(clause[column + 1], "CASCADE") ||
|
|
252
|
+
word(clause[column + 1], "RESTRICT")) &&
|
|
253
|
+
column + 2 === clause.length)))
|
|
254
|
+
add("drop_column", clause[0]);
|
|
255
|
+
else
|
|
256
|
+
malformed();
|
|
257
|
+
cascade(clause);
|
|
258
|
+
}
|
|
259
|
+
else if (word(clause[0], "ALTER")) {
|
|
260
|
+
const column = word(clause[1], "COLUMN") ? 2 : 1;
|
|
261
|
+
if (name(clause[column]) &&
|
|
262
|
+
word(clause[column + 1], "SET") &&
|
|
263
|
+
word(clause[column + 2], "NOT") &&
|
|
264
|
+
word(clause[column + 3], "NULL") &&
|
|
265
|
+
column + 4 === clause.length)
|
|
266
|
+
add("not_null", clause[column + 2]);
|
|
267
|
+
else
|
|
268
|
+
unsupported();
|
|
269
|
+
}
|
|
270
|
+
else if (word(clause[0], "ADD")) {
|
|
271
|
+
let caseDepth = 0;
|
|
272
|
+
let constraint = false;
|
|
273
|
+
for (let cursor = 1; cursor < clause.length; cursor += 1) {
|
|
274
|
+
const token = clause[cursor];
|
|
275
|
+
if (word(token, "CASE"))
|
|
276
|
+
caseDepth += 1;
|
|
277
|
+
if (word(token, "END"))
|
|
278
|
+
caseDepth = Math.max(0, caseDepth - 1);
|
|
279
|
+
if (caseDepth > 0)
|
|
280
|
+
continue;
|
|
281
|
+
if (word(token, "NOT") &&
|
|
282
|
+
word(clause[cursor + 1], "NULL") &&
|
|
283
|
+
!word(clause[cursor - 1], "IS"))
|
|
284
|
+
add("not_null", token);
|
|
285
|
+
if (["CONSTRAINT", "CHECK", "UNIQUE", "REFERENCES", "EXCLUDE"].some((keyword) => word(token, keyword)) ||
|
|
286
|
+
(word(token, "PRIMARY") && word(clause[cursor + 1], "KEY")) ||
|
|
287
|
+
(word(token, "FOREIGN") && word(clause[cursor + 1], "KEY")))
|
|
288
|
+
constraint = true;
|
|
289
|
+
}
|
|
290
|
+
if (constraint)
|
|
291
|
+
add("new_constraint", clause[0]);
|
|
292
|
+
// Column definitions/types/expressions are deliberately not parsed.
|
|
293
|
+
unsupported();
|
|
294
|
+
cascade(clause, false);
|
|
295
|
+
}
|
|
296
|
+
else
|
|
297
|
+
unsupported();
|
|
298
|
+
if (comma < 0)
|
|
299
|
+
break;
|
|
300
|
+
index = comma + 1;
|
|
301
|
+
if (index >= top.length)
|
|
302
|
+
malformed();
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Other DDL can contain a meaningful trailing CASCADE, but remains unknown.
|
|
307
|
+
if (word(first, "DROP")) {
|
|
308
|
+
let target = 2;
|
|
309
|
+
if ((word(top[1], "MATERIALIZED") && word(top[2], "VIEW")) ||
|
|
310
|
+
(word(top[1], "FOREIGN") && word(top[2], "TABLE")))
|
|
311
|
+
target += 1;
|
|
312
|
+
if (word(top[target], "IF") && word(top[target + 1], "EXISTS"))
|
|
313
|
+
target += 2;
|
|
314
|
+
const end = qualifiedNameEnd(top, target);
|
|
315
|
+
if (end > target && end === top.length - 1 && word(top[end], "CASCADE"))
|
|
316
|
+
cascade(top);
|
|
317
|
+
}
|
|
318
|
+
if (word(first, "CREATE") && word(top[1], "TABLE"))
|
|
319
|
+
cascade(tokens, false);
|
|
320
|
+
unsupported();
|
|
321
|
+
};
|
|
322
|
+
let start = 0;
|
|
323
|
+
let atomicDepth = 0;
|
|
324
|
+
let opaqueBody = false;
|
|
325
|
+
for (let index = 0; index <= lexical.tokens.length; index += 1) {
|
|
326
|
+
const token = lexical.tokens[index];
|
|
327
|
+
let routine = start + 1;
|
|
328
|
+
if (word(lexical.tokens[routine], "OR") &&
|
|
329
|
+
word(lexical.tokens[routine + 1], "REPLACE"))
|
|
330
|
+
routine += 2;
|
|
331
|
+
const routineStatement = word(lexical.tokens[start], "CREATE") &&
|
|
332
|
+
(word(lexical.tokens[routine], "FUNCTION") ||
|
|
333
|
+
word(lexical.tokens[routine], "PROCEDURE"));
|
|
334
|
+
if (routineStatement &&
|
|
335
|
+
token?.depth === 0 &&
|
|
336
|
+
word(token, "BEGIN") &&
|
|
337
|
+
word(lexical.tokens[index + 1], "ATOMIC")) {
|
|
338
|
+
opaqueBody = true;
|
|
339
|
+
atomicDepth += 1;
|
|
340
|
+
}
|
|
341
|
+
else if (opaqueBody &&
|
|
342
|
+
token?.depth === 0 &&
|
|
343
|
+
(word(token, "BEGIN") || word(token, "CASE")))
|
|
344
|
+
atomicDepth += 1;
|
|
345
|
+
else if (opaqueBody && token?.depth === 0 && word(token, "END"))
|
|
346
|
+
atomicDepth -= 1;
|
|
347
|
+
if (index === lexical.tokens.length ||
|
|
348
|
+
(token?.value === ";" && token.depth === 0 && atomicDepth === 0)) {
|
|
349
|
+
const statement = lexical.tokens.slice(start, index);
|
|
350
|
+
if (opaqueBody)
|
|
351
|
+
unsupported();
|
|
352
|
+
else if (index !== lexical.tokens.length ||
|
|
353
|
+
!lexical.unknownReasons.some((reason) => reason === "input_limit" || reason === "malformed_sql"))
|
|
354
|
+
analyze(statement, 0);
|
|
355
|
+
start = index + 1;
|
|
356
|
+
opaqueBody = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (atomicDepth > 0)
|
|
360
|
+
malformed();
|
|
361
|
+
findings.sort((left, right) => left.line - right.line ||
|
|
362
|
+
left.column - right.column ||
|
|
363
|
+
left.rule.localeCompare(right.rule));
|
|
364
|
+
return {
|
|
365
|
+
complete: reasons.size === 0,
|
|
366
|
+
findings,
|
|
367
|
+
unknownReasons: [...reasons].sort(),
|
|
368
|
+
};
|
|
369
|
+
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* this module is reusable script infrastructure.
|
|
5
5
|
*/
|
|
6
6
|
import type { RehearsalConfigPathOptions } from "../project/configuration.mjs";
|
|
7
|
+
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
7
8
|
export declare const buildRehearsalPlan: (options?: RehearsalConfigPathOptions) => Promise<{
|
|
8
9
|
config: {
|
|
9
10
|
schemaVersion: 1;
|
|
@@ -228,7 +229,7 @@ export declare const inspectRehearsalBaseline: (options?: RehearsalConfigPathOpt
|
|
|
228
229
|
};
|
|
229
230
|
};
|
|
230
231
|
}>;
|
|
231
|
-
export declare const inspectRehearsalMigrations: (options?: RehearsalConfigPathOptions) => Promise<{
|
|
232
|
+
export declare const inspectRehearsalMigrations: (options?: RehearsalConfigPathOptions, inventoryReader?: typeof readMigrationFileInventory) => Promise<{
|
|
232
233
|
baselineGenerationId: string;
|
|
233
234
|
baselineCutoff: string;
|
|
234
235
|
candidateSha256: string;
|
|
@@ -155,7 +155,7 @@ const assertConfiguredCommand = async ({ command, projectRoot, }) => {
|
|
|
155
155
|
}
|
|
156
156
|
return `package script ${scriptName} exists`;
|
|
157
157
|
};
|
|
158
|
-
const loadPlanInputs = async (options = {}) => {
|
|
158
|
+
const loadPlanInputs = async (options = {}, inventoryReader = readMigrationFileInventory) => {
|
|
159
159
|
const loaded = await loadRehearsalConfig(options);
|
|
160
160
|
const [baseline, policyBytes] = await Promise.all([
|
|
161
161
|
verifyActiveBaseline({
|
|
@@ -167,7 +167,7 @@ const loadPlanInputs = async (options = {}) => {
|
|
|
167
167
|
bytes: policyBytes,
|
|
168
168
|
expectedSha256: baseline.sanitizationPolicySha256,
|
|
169
169
|
});
|
|
170
|
-
const currentFiles = await
|
|
170
|
+
const currentFiles = await inventoryReader(new URL("./", pathToFileURL(`${loaded.paths.migrationDirectory}/`)));
|
|
171
171
|
const candidateReceipt = createCandidateMigrationReceipt({
|
|
172
172
|
baselineManifest: baseline,
|
|
173
173
|
currentFiles,
|
|
@@ -363,8 +363,8 @@ export const inspectRehearsalBaseline = async (options = {}) => {
|
|
|
363
363
|
review: plan.baselineReview,
|
|
364
364
|
};
|
|
365
365
|
};
|
|
366
|
-
export const inspectRehearsalMigrations = async (options = {}) => {
|
|
367
|
-
const inputs = await loadPlanInputs(options);
|
|
366
|
+
export const inspectRehearsalMigrations = async (options = {}, inventoryReader = readMigrationFileInventory) => {
|
|
367
|
+
const inputs = await loadPlanInputs(options, inventoryReader);
|
|
368
368
|
return {
|
|
369
369
|
baselineGenerationId: inputs.baseline.generationId,
|
|
370
370
|
baselineCutoff: inputs.baseline.migrationCutoff,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Build narrowly validated local restore prerequisites and structural checks. */
|
|
2
|
+
import { type RuntimePrivilege } from "./privileges.mjs";
|
|
2
3
|
type PolicyObject = Record<string, unknown>;
|
|
3
4
|
interface RuntimeRelation {
|
|
4
5
|
readonly schema: string;
|
|
@@ -55,6 +56,7 @@ export interface RuntimePolicy {
|
|
|
55
56
|
readonly triggers: readonly RuntimeTrigger[];
|
|
56
57
|
readonly localRows: readonly RuntimeLocalRow[];
|
|
57
58
|
readonly expectations: readonly RuntimeExpectation[];
|
|
59
|
+
readonly privileges?: readonly RuntimePrivilege[];
|
|
58
60
|
}
|
|
59
61
|
export declare const IDENTITY_CLAIM_RECEIPT_SQL = "create schema if not exists rehearsal_internal;\ncreate table if not exists rehearsal_internal.identity_claims (\n identity_name text primary key,\n placeholder_user_id uuid not null unique,\n local_user_id uuid not null,\n claimed_at timestamptz not null default now()\n);";
|
|
60
62
|
export declare const validateRuntimePolicy: (input: unknown) => RuntimePolicy;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Build narrowly validated local restore prerequisites and structural checks. */
|
|
2
|
+
import { buildRuntimePrivilegeVerificationChecks, validateRuntimePrivileges, } from "./privileges.mjs";
|
|
2
3
|
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
3
4
|
const IDENTITY_NAME = /^[a-z0-9][a-z0-9-]{1,62}$/u;
|
|
4
5
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
|
|
@@ -58,7 +59,14 @@ create table if not exists rehearsal_internal.identity_claims (
|
|
|
58
59
|
);`;
|
|
59
60
|
export const validateRuntimePolicy = (input) => {
|
|
60
61
|
const policy = object(input, "runtimePolicy");
|
|
61
|
-
keys(policy, [
|
|
62
|
+
keys(policy, [
|
|
63
|
+
"policyVersion",
|
|
64
|
+
"prerequisites",
|
|
65
|
+
"triggers",
|
|
66
|
+
"localRows",
|
|
67
|
+
"expectations",
|
|
68
|
+
"privileges",
|
|
69
|
+
], "runtimePolicy");
|
|
62
70
|
if (policy.policyVersion !== 1)
|
|
63
71
|
throw new Error("Runtime policy must use policyVersion 1.");
|
|
64
72
|
const prerequisites = object(policy.prerequisites ?? {}, "runtimePolicy.prerequisites");
|
|
@@ -216,6 +224,9 @@ export const validateRuntimePolicy = (input) => {
|
|
|
216
224
|
triggers: Object.freeze(triggers),
|
|
217
225
|
localRows: Object.freeze(localRows),
|
|
218
226
|
expectations: Object.freeze(expectations),
|
|
227
|
+
...(Object.hasOwn(policy, "privileges")
|
|
228
|
+
? { privileges: validateRuntimePrivileges(policy.privileges) }
|
|
229
|
+
: {}),
|
|
219
230
|
});
|
|
220
231
|
};
|
|
221
232
|
export const buildRuntimePrerequisiteSql = (input) => {
|
|
@@ -360,6 +371,7 @@ export const buildRuntimeVerificationSql = (input) => {
|
|
|
360
371
|
}
|
|
361
372
|
checks.push(`if exists (select 1 from pg_constraint where not convalidated) then
|
|
362
373
|
raise exception 'Rehearsal found an unvalidated structural constraint'; end if;`);
|
|
374
|
+
checks.push(...buildRuntimePrivilegeVerificationChecks(policy.privileges ?? []));
|
|
363
375
|
return `do $rehearsal_verify$
|
|
364
376
|
begin
|
|
365
377
|
${checks.join("\n ")}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Validate opt-in effective privileges and build catalog-only verification checks. */
|
|
2
|
+
export interface RuntimePrivilegeArgumentType {
|
|
3
|
+
readonly schema: "pg_catalog";
|
|
4
|
+
readonly name: string;
|
|
5
|
+
}
|
|
6
|
+
interface RuntimePrivilegeBase {
|
|
7
|
+
readonly schema: string;
|
|
8
|
+
readonly role: string;
|
|
9
|
+
readonly required: readonly string[];
|
|
10
|
+
readonly forbidden: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export type RuntimePrivilege = RuntimePrivilegeBase & ({
|
|
13
|
+
readonly kind: "schema";
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: "table" | "sequence";
|
|
16
|
+
readonly name: string;
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: "function";
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly argumentTypes: readonly RuntimePrivilegeArgumentType[];
|
|
21
|
+
});
|
|
22
|
+
export declare const validateRuntimePrivileges: (input: unknown) => readonly RuntimePrivilege[];
|
|
23
|
+
/** Blocks are embedded inside the policy verifier; no ACL or object writes are emitted. */
|
|
24
|
+
export declare const buildRuntimePrivilegeVerificationChecks: (entries: readonly RuntimePrivilege[]) => readonly string[];
|
|
25
|
+
export {};
|