@rehearsal-db/core 1.1.0 → 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 +27 -1
- package/COMPATIBILITY.md +31 -0
- package/README.md +7 -3
- 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/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/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/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 +23 -5
- package/docs/index.md +1 -0
- package/docs/releasing.md +11 -9
- package/docs/roadmap.md +19 -0
- package/docs/runtime-lifecycle.md +10 -0
- package/docs/updating.md +73 -0
- package/docs/vscode.md +23 -17
- 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,
|
|
@@ -36,7 +36,7 @@ declare const candidateSummary: (inspection: MigrationInspection) => Readonly<{
|
|
|
36
36
|
status: string;
|
|
37
37
|
}[];
|
|
38
38
|
}>;
|
|
39
|
-
export declare const inspectRuntimeTopologyCandidates: (options?: RehearsalConfigPathOptions) => Promise<Readonly<{
|
|
39
|
+
export declare const inspectRuntimeTopologyCandidates: (options?: RehearsalConfigPathOptions, inspectMigrations?: typeof inspectRehearsalMigrations) => Promise<Readonly<{
|
|
40
40
|
topology: Readonly<{
|
|
41
41
|
projectRoot: string;
|
|
42
42
|
primary: RuntimeTopologyTarget;
|
|
@@ -103,13 +103,13 @@ const candidateSummary = (inspection) => {
|
|
|
103
103
|
candidates: Object.freeze(candidates),
|
|
104
104
|
});
|
|
105
105
|
};
|
|
106
|
-
export const inspectRuntimeTopologyCandidates = async (options = {}) => {
|
|
106
|
+
export const inspectRuntimeTopologyCandidates = async (options = {}, inspectMigrations = inspectRehearsalMigrations) => {
|
|
107
107
|
const topology = await loadRuntimeTopology(options);
|
|
108
108
|
const targets = [];
|
|
109
109
|
for (const target of topology.targets) {
|
|
110
110
|
targets.push(Object.freeze({
|
|
111
111
|
...target,
|
|
112
|
-
summary: candidateSummary(await
|
|
112
|
+
summary: candidateSummary(await inspectMigrations({
|
|
113
113
|
projectRoot: topology.projectRoot,
|
|
114
114
|
configPath: target.configPath,
|
|
115
115
|
})),
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const HELD_PATH_ANCESTRY_MAXIMUM_DIRECTORIES = 128;
|
|
2
|
+
export interface HeldPathAncestry {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
assertUnchanged(): Promise<void>;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare const holdPathAncestry: (directory: string) => Promise<HeldPathAncestry>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamp-based concurrent-change guard for a canonical directory ancestry.
|
|
3
|
+
* This is not atomic openat resolution or a sandbox: it cannot prevent every
|
|
4
|
+
* transient access or privileged timestamp manipulation. Callers retain their
|
|
5
|
+
* own project-containment, regular-file and exact-input identity checks, use
|
|
6
|
+
* only `path` for reads, assert before/after those reads, and close in finally.
|
|
7
|
+
*/
|
|
8
|
+
import { constants } from "node:fs";
|
|
9
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
10
|
+
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
11
|
+
export const HELD_PATH_ANCESTRY_MAXIMUM_DIRECTORIES = 128;
|
|
12
|
+
const unavailable = () => new Error("Path ancestry is unavailable, unsafe or changed.");
|
|
13
|
+
const identity = (details) => {
|
|
14
|
+
if (!details.isDirectory() ||
|
|
15
|
+
details.isSymbolicLink() ||
|
|
16
|
+
typeof details.dev !== "bigint" ||
|
|
17
|
+
typeof details.ino !== "bigint" ||
|
|
18
|
+
typeof details.ctimeNs !== "bigint" ||
|
|
19
|
+
typeof details.mtimeNs !== "bigint")
|
|
20
|
+
throw unavailable();
|
|
21
|
+
return {
|
|
22
|
+
dev: details.dev,
|
|
23
|
+
ino: details.ino,
|
|
24
|
+
ctimeNs: details.ctimeNs,
|
|
25
|
+
mtimeNs: details.mtimeNs,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const same = (left, right) => left.dev === right.dev &&
|
|
29
|
+
left.ino === right.ino &&
|
|
30
|
+
left.ctimeNs === right.ctimeNs &&
|
|
31
|
+
left.mtimeNs === right.mtimeNs;
|
|
32
|
+
export const holdPathAncestry = async (directory) => {
|
|
33
|
+
const handles = [];
|
|
34
|
+
const entries = [];
|
|
35
|
+
let closed = false;
|
|
36
|
+
let closing;
|
|
37
|
+
const close = () => {
|
|
38
|
+
if (closing)
|
|
39
|
+
return closing;
|
|
40
|
+
closed = true;
|
|
41
|
+
closing = (async () => {
|
|
42
|
+
let failed = false;
|
|
43
|
+
for (const handle of handles.splice(0).reverse()) {
|
|
44
|
+
try {
|
|
45
|
+
await handle.close();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
failed = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (failed)
|
|
52
|
+
throw unavailable();
|
|
53
|
+
})();
|
|
54
|
+
return closing;
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
if (!isAbsolute(directory))
|
|
58
|
+
throw unavailable();
|
|
59
|
+
const path = resolve(directory);
|
|
60
|
+
const root = parse(path).root;
|
|
61
|
+
const segments = relative(root, path).split(sep).filter(Boolean);
|
|
62
|
+
if (segments.length + 1 > HELD_PATH_ANCESTRY_MAXIMUM_DIRECTORIES)
|
|
63
|
+
throw unavailable();
|
|
64
|
+
// Reject aliases and all symlink ancestors, including ancestors above the
|
|
65
|
+
// caller's project root. Root aliases must be canonicalized by the caller.
|
|
66
|
+
if ((await realpath(path)) !== path)
|
|
67
|
+
throw unavailable();
|
|
68
|
+
const paths = [root];
|
|
69
|
+
for (const segment of segments)
|
|
70
|
+
paths.push(join(paths.at(-1), segment));
|
|
71
|
+
for (const current of paths) {
|
|
72
|
+
const before = identity(await lstat(current, { bigint: true }));
|
|
73
|
+
const handle = await open(current, constants.O_RDONLY |
|
|
74
|
+
constants.O_DIRECTORY |
|
|
75
|
+
constants.O_NOFOLLOW |
|
|
76
|
+
constants.O_NONBLOCK);
|
|
77
|
+
// Retain immediately: a later stat failure must still close this handle.
|
|
78
|
+
handles.push(handle);
|
|
79
|
+
const opened = identity(await handle.stat({ bigint: true }));
|
|
80
|
+
const after = identity(await lstat(current, { bigint: true }));
|
|
81
|
+
if (!same(before, opened) || !same(before, after))
|
|
82
|
+
throw unavailable();
|
|
83
|
+
entries.push({ path: current, handle, identity: before });
|
|
84
|
+
}
|
|
85
|
+
const assertUnchanged = async () => {
|
|
86
|
+
try {
|
|
87
|
+
if (closed || (await realpath(path)) !== path)
|
|
88
|
+
throw unavailable();
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const held = identity(await entry.handle.stat({ bigint: true }));
|
|
91
|
+
const lexical = identity(await lstat(entry.path, { bigint: true }));
|
|
92
|
+
if (!same(entry.identity, held) || !same(entry.identity, lexical))
|
|
93
|
+
throw unavailable();
|
|
94
|
+
}
|
|
95
|
+
if ((await realpath(path)) !== path)
|
|
96
|
+
throw unavailable();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
await close().catch(() => { });
|
|
100
|
+
throw unavailable();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
await assertUnchanged();
|
|
104
|
+
return Object.freeze({ path, assertUnchanged, close });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
await close().catch(() => { });
|
|
108
|
+
throw unavailable();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BaselineManifest } from "../baseline/artifact.mjs";
|
|
2
|
+
import type { RuntimeTopologyTarget } from "../runtime/topology.mjs";
|
|
3
|
+
import { runOwnedProcess } from "../shared/owned_process.mjs";
|
|
4
|
+
export type MigrationImpactRuntimeFailureReason = "runtime_unavailable" | "ownership_mismatch" | "stale_runtime" | "query_failed" | "lock_timeout" | "statement_timeout" | "transaction_timeout" | "cancelled";
|
|
5
|
+
export declare class MigrationImpactRuntimeError extends Error {
|
|
6
|
+
readonly reason: MigrationImpactRuntimeFailureReason;
|
|
7
|
+
constructor(reason: MigrationImpactRuntimeFailureReason);
|
|
8
|
+
}
|
|
9
|
+
export interface MigrationImpactRuntime {
|
|
10
|
+
readonly identitySha256: string;
|
|
11
|
+
runSql(sql: string | (() => string)): Promise<string>;
|
|
12
|
+
assertCurrent(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface OpenMigrationImpactRuntimeOptions {
|
|
15
|
+
readonly projectRoot: string;
|
|
16
|
+
readonly target: RuntimeTopologyTarget;
|
|
17
|
+
readonly baseline: BaselineManifest;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
readonly runProcessImplementation?: typeof runOwnedProcess;
|
|
20
|
+
}
|
|
21
|
+
/** No lifecycle, adapter, source-reader, URL or credential-file access is permitted here. */
|
|
22
|
+
export declare function openMigrationImpactRuntime(options: OpenMigrationImpactRuntimeOptions): Promise<MigrationImpactRuntime>;
|