@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/COMPATIBILITY.md +31 -0
  3. package/README.md +7 -3
  4. package/dist/package.json +3 -1
  5. package/dist/src/cli/arguments.mjs +1 -0
  6. package/dist/src/cli/basic_command_handlers.mjs +4 -0
  7. package/dist/src/cli/editor_command_handlers.mjs +24 -0
  8. package/dist/src/runtime/migration_history.d.mts +5 -1
  9. package/dist/src/runtime/migration_history.mjs +24 -6
  10. package/dist/src/runtime/migration_impact.d.mts +125 -0
  11. package/dist/src/runtime/migration_impact.mjs +278 -0
  12. package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
  13. package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
  14. package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
  15. package/dist/src/runtime/migration_impact_plan.mjs +297 -0
  16. package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
  17. package/dist/src/runtime/migration_impact_queries.mjs +270 -0
  18. package/dist/src/runtime/migration_insights.d.mts +137 -0
  19. package/dist/src/runtime/migration_insights.mjs +143 -0
  20. package/dist/src/runtime/migration_insights_input.d.mts +13 -0
  21. package/dist/src/runtime/migration_insights_input.mjs +153 -0
  22. package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
  23. package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
  24. package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
  25. package/dist/src/runtime/migration_sql_scan.mjs +369 -0
  26. package/dist/src/runtime/plan.d.mts +2 -1
  27. package/dist/src/runtime/plan.mjs +4 -4
  28. package/dist/src/runtime/topology.d.mts +1 -1
  29. package/dist/src/runtime/topology.mjs +2 -2
  30. package/dist/src/shared/cancellation.mjs +1 -0
  31. package/dist/src/shared/held_path_ancestry.d.mts +7 -0
  32. package/dist/src/shared/held_path_ancestry.mjs +110 -0
  33. package/dist/src/shared/operation_guard.mjs +1 -0
  34. package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
  35. package/dist/src/targets/migration_impact_runtime.mjs +367 -0
  36. package/docs/README.md +20 -7
  37. package/docs/adapters.md +10 -0
  38. package/docs/architecture.md +75 -1
  39. package/docs/commands.md +169 -34
  40. package/docs/configuration.md +23 -5
  41. package/docs/index.md +1 -0
  42. package/docs/releasing.md +11 -9
  43. package/docs/roadmap.md +19 -0
  44. package/docs/runtime-lifecycle.md +10 -0
  45. package/docs/updating.md +73 -0
  46. package/docs/vscode.md +23 -17
  47. package/package.json +3 -1
@@ -0,0 +1,270 @@
1
+ import { nativeMigrationImpactIndexes, nativeMigrationImpactStatistics, nativeMigrationImpactComparison, } from "./migration_impact_catalog.mjs";
2
+ export const MIGRATION_IMPACT_LIMITS = Object.freeze({
3
+ maximumMeasurements: 100,
4
+ lockTimeoutMilliseconds: 1_000,
5
+ statementTimeoutMilliseconds: 5_000,
6
+ totalTimeoutMilliseconds: 30_000,
7
+ maximumOutputBytes: 131_072,
8
+ maximumScriptBytes: 262_144,
9
+ });
10
+ const identifier = (value) => {
11
+ if (typeof value !== "string" || !/^[a-z_][a-z0-9_$]{0,62}$/u.test(value))
12
+ throw new Error("The private measurement identifier is unsupported.");
13
+ return value;
14
+ };
15
+ const quoted = (value) => `"${identifier(value)}"`;
16
+ const literal = (value) => `'${identifier(value)}'`;
17
+ const relationName = (relation) => `${quoted(relation.schema)}.${quoted(relation.name)}`;
18
+ const relationLiteral = (relation) => `'${relationName(relation)}'`;
19
+ const relationPredicate = (relation, alias) => `${alias}.relnamespace = (select oid from pg_catalog.pg_namespace where nspname = ${literal(relation.schema)})
20
+ and ${alias}.relname = ${literal(relation.name)}
21
+ and ${alias}.relkind = 'r' and ${alias}.relpersistence in ('p', 'u')
22
+ and not ${alias}.relrowsecurity and not ${alias}.relispartition
23
+ and ${alias}.relam = (select oid from pg_catalog.pg_am where amname = 'heap')
24
+ and not exists (select 1 from pg_catalog.pg_inherits h
25
+ where h.inhparent = ${alias}.oid or h.inhrelid = ${alias}.oid)
26
+ and pg_catalog.to_regclass(${relationLiteral(relation)})::oid = ${alias}.oid
27
+ and ${nativeMigrationImpactIndexes(alias)}
28
+ and ${nativeMigrationImpactStatistics(alias)}`;
29
+ const columnPredicate = (relationAlias, column, compare) => `exists (
30
+ select 1 from pg_catalog.pg_attribute a
31
+ join pg_catalog.pg_type t on t.oid = a.atttypid
32
+ join pg_catalog.pg_namespace tn on tn.oid = t.typnamespace
33
+ where a.attrelid = ${relationAlias}.oid and a.attname = ${literal(column)}
34
+ and a.attnum > 0 and not a.attisdropped and a.attgenerated in ('', 's')
35
+ and tn.nspname = 'pg_catalog' and t.oid < 16384 and t.typbasetype = 0
36
+ and t.typname in ('bool', 'int2', 'int4', 'int8', 'numeric', 'float4', 'float8',
37
+ 'text', ${compare ? "" : "'varchar', 'bpchar',"} 'uuid', 'date', 'timestamp', 'timestamptz')
38
+ ${compare ? `and ${nativeMigrationImpactComparison("t")}` : ""}
39
+ ${compare ? `and (a.attcollation = 0 or exists (select 1 from pg_catalog.pg_collation co join pg_catalog.pg_namespace cn on cn.oid = co.collnamespace where co.oid = a.attcollation and co.oid < 16384 and cn.nspname = 'pg_catalog' and co.collisdeterministic))` : ""}
40
+ )`;
41
+ const validateTask = (task) => {
42
+ relationName(task.relation);
43
+ if (!Number.isSafeInteger(task.line) ||
44
+ task.line < 1 ||
45
+ !Number.isSafeInteger(task.column) ||
46
+ task.column < 1)
47
+ throw new Error("The private measurement location is unsupported.");
48
+ const operations = {
49
+ rows: ["drop_table", "truncate", "broad_delete"],
50
+ populated: ["drop_column"],
51
+ nulls: ["not_null"],
52
+ duplicate_rows: ["new_constraint"],
53
+ orphan_rows: ["new_constraint"],
54
+ };
55
+ if (!operations[task.metric]?.includes(task.operation))
56
+ throw new Error("The private measurement kind is unsupported.");
57
+ if (task.metric !== "rows") {
58
+ if (!Array.isArray(task.columns) ||
59
+ task.columns.length < 1 ||
60
+ task.columns.length > 8 ||
61
+ new Set(task.columns).size !== task.columns.length ||
62
+ (["populated", "nulls"].includes(task.metric) &&
63
+ task.columns.length !== 1))
64
+ throw new Error("The private measurement columns are unsupported.");
65
+ task.columns.forEach(identifier);
66
+ }
67
+ if (task.metric === "orphan_rows") {
68
+ relationName(task.referencedRelation);
69
+ if (!Array.isArray(task.referencedColumns) ||
70
+ task.referencedColumns.length !== task.columns.length ||
71
+ new Set(task.referencedColumns).size !== task.referencedColumns.length)
72
+ throw new Error("The private measurement reference is unsupported.");
73
+ task.referencedColumns.forEach(identifier);
74
+ }
75
+ };
76
+ const metadataCheck = (task, relationIds) => {
77
+ const compare = ["duplicate_rows", "orphan_rows"].includes(task.metric);
78
+ const expectedOid = (relation, alias) => `${alias}.oid = :'impact_relation_${relationIds.get(relationName(relation))}_oid'::pg_catalog.oid`;
79
+ const predicates = [
80
+ relationPredicate(task.relation, "r"),
81
+ expectedOid(task.relation, "r"),
82
+ ];
83
+ if (task.metric !== "rows")
84
+ predicates.push(...task.columns.map((column) => columnPredicate("r", column, compare)));
85
+ if (task.metric === "orphan_rows") {
86
+ const pairedColumns = task.columns.map((column, index) => `exists (select 1 from pg_catalog.pg_attribute ca join pg_catalog.pg_attribute pa
87
+ on pa.attrelid = p.oid and pa.attname = ${literal(task.referencedColumns[index])}
88
+ where ca.attrelid = r.oid and ca.attname = ${literal(column)}
89
+ and ca.atttypid = pa.atttypid and ca.attcollation = pa.attcollation)`);
90
+ predicates.push(`exists (select 1 from pg_catalog.pg_class p where
91
+ ${relationPredicate(task.referencedRelation, "p")} and ${expectedOid(task.referencedRelation, "p")} and
92
+ ${task.referencedColumns.map((column) => columnPredicate("p", column, true)).join(" and ")} and
93
+ ${pairedColumns.join(" and ")})`);
94
+ }
95
+ return `select exists (select 1 from pg_catalog.pg_class r where ${predicates.join(" and ")}) as ok`;
96
+ };
97
+ const countQuery = (task) => {
98
+ const table = relationName(task.relation);
99
+ if (task.metric === "rows")
100
+ return `select pg_catalog.count(*)::text as count from only ${table}`;
101
+ if (task.metric === "nulls" || task.metric === "populated")
102
+ return `select pg_catalog.count(*)::text as count from only ${table}
103
+ where ${quoted(task.columns[0])} is ${task.metric === "populated" ? "not " : ""}null`;
104
+ const populated = task.columns
105
+ .map((column) => `c.${quoted(column)} is not null`)
106
+ .join(" and ");
107
+ if (task.metric === "duplicate_rows")
108
+ return `select coalesce(pg_catalog.sum(n), 0)::text as count from (
109
+ select pg_catalog.count(*) as n from only ${table} c where ${populated}
110
+ group by ${task.columns.map((column) => `c.${quoted(column)}`).join(", ")}
111
+ having pg_catalog.count(*) > 1) duplicates`;
112
+ const equal = task.columns
113
+ .map((column, index) => `p.${quoted(task.referencedColumns[index])} operator(pg_catalog.=) c.${quoted(column)}`)
114
+ .join(" and ");
115
+ return `select pg_catalog.count(*)::text as count from only ${table} c
116
+ where ${populated} and not exists (select 1 from only ${relationName(task.referencedRelation)} p where ${equal})`;
117
+ };
118
+ /** One consistent current-row snapshot. No pending migration statement is executed. */
119
+ export const buildMigrationImpactScript = (tasks, transactionTimeoutMilliseconds = MIGRATION_IMPACT_LIMITS.totalTimeoutMilliseconds) => {
120
+ if (tasks.length > MIGRATION_IMPACT_LIMITS.maximumMeasurements)
121
+ throw new Error("The measurement count exceeds its fixed bound.");
122
+ tasks.forEach(validateTask);
123
+ if (!Number.isSafeInteger(transactionTimeoutMilliseconds) ||
124
+ transactionTimeoutMilliseconds < 1 ||
125
+ transactionTimeoutMilliseconds >
126
+ MIGRATION_IMPACT_LIMITS.totalTimeoutMilliseconds)
127
+ throw new Error("The server transaction deadline exceeds its fixed bound.");
128
+ const relations = [
129
+ ...new Map(tasks.flatMap((task) => [
130
+ task.relation,
131
+ ...(task.metric === "orphan_rows" ? [task.referencedRelation] : []),
132
+ ].map((relation) => [relationName(relation), relation]))).entries(),
133
+ ].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
134
+ const relationIds = new Map(relations.map(([name], index) => [name, index]));
135
+ const script = [
136
+ "\\set ON_ERROR_STOP on",
137
+ "set default_transaction_read_only = on;",
138
+ "set lock_timeout = '1s';",
139
+ "set statement_timeout = '5s';",
140
+ "set idle_in_transaction_session_timeout = '5s';",
141
+ "set search_path = pg_catalog;",
142
+ "set row_security = off;",
143
+ "set max_parallel_workers_per_gather = 0;",
144
+ "set work_mem = '1MB';",
145
+ "set temp_file_limit = '8MB';",
146
+ "set constraint_exclusion = off;",
147
+ "select pg_catalog.current_setting('server_version_num')::integer >= 170000 as supported \\gset impact_server_",
148
+ "\\if :impact_server_supported",
149
+ `set transaction_timeout = '${transactionTimeoutMilliseconds}ms';`,
150
+ "select pg_catalog.clock_timestamp()::text as started \\gset impact_clock_",
151
+ "begin isolation level read committed read only;",
152
+ ...relations.map(([, relation], index) => `with eligible as (select r.oid from pg_catalog.pg_class r where ${relationPredicate(relation, "r")})
153
+ select exists (select 1 from eligible) as ok, coalesce((select oid from eligible), 0)::pg_catalog.oid as oid \\gset impact_relation_${index}_`),
154
+ "rollback;",
155
+ // Preflight must not freeze the later repeatable-read catalog snapshot.
156
+ `select greatest(1, least(${transactionTimeoutMilliseconds}, ${transactionTimeoutMilliseconds} - elapsed))::text as milliseconds,
157
+ elapsed < ${transactionTimeoutMilliseconds} as available from
158
+ (select pg_catalog.ceil(extract(epoch from (pg_catalog.clock_timestamp() - :'impact_clock_started'::pg_catalog.timestamptz)) * 1000) as elapsed) budget \\gset impact_remaining_`,
159
+ "\\if :impact_remaining_available",
160
+ "begin isolation level repeatable read read only;",
161
+ "set local transaction_timeout = :'impact_remaining_milliseconds';",
162
+ // Schema names also participate in query resolution. A database-local
163
+ // namespace-catalog lock prevents schema rename/swap after the checks;
164
+ // relation locks alone do not stabilize that mapping.
165
+ "lock table pg_catalog.pg_namespace in share mode;",
166
+ // Globally ordered strong locks exclude ordinary/concurrent index and statistics
167
+ // DDL before the first repeatable-read SELECT establishes the count snapshot.
168
+ // A name swapped to a view can transiently lock its dependencies; the retained
169
+ // OID/ordinary-table checks still prohibit reading it or executing its query.
170
+ ...relations.flatMap(([name], index) => [
171
+ `\\if :impact_relation_${index}_ok`,
172
+ `lock table only ${name} in share row exclusive mode;`,
173
+ "\\endif",
174
+ ]),
175
+ `select pg_catalog.json_build_object('snapshotAt', pg_catalog.to_char(pg_catalog.statement_timestamp() at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));`,
176
+ ];
177
+ tasks.forEach((task, index) => {
178
+ const unknown = `select pg_catalog.json_build_object('index', ${index}, 'count', null, 'reason', 'unsupported_runtime_metadata');`;
179
+ script.push(`${metadataCheck(task, relationIds)} \\gset locked_${index}_`, `\\if :locked_${index}_ok`, `select pg_catalog.json_build_object('index', ${index}, 'count', measured.count, 'reason', null) from (${countQuery(task)}) measured;`, "\\else", unknown, "\\endif");
180
+ });
181
+ script.push("rollback;", "\\else", "select pg_catalog.json_build_object('totalTimeout', true);", "\\endif", "\\else", "select pg_catalog.json_build_object('unsupportedServerVersion', true);", "\\endif", "");
182
+ const result = script.join("\n");
183
+ if (Buffer.byteLength(result) > MIGRATION_IMPACT_LIMITS.maximumScriptBytes)
184
+ throw new Error("The fixed measurement script exceeds its byte bound.");
185
+ return result;
186
+ };
187
+ export const parseMigrationImpactBatchOutput = (output, expectedCount) => {
188
+ const invalid = () => {
189
+ throw new Error("The sandbox returned an unsupported aggregate snapshot.");
190
+ };
191
+ if (Buffer.byteLength(output) > MIGRATION_IMPACT_LIMITS.maximumOutputBytes)
192
+ return invalid();
193
+ const lines = output.trim().split(/\r?\n/u);
194
+ let header;
195
+ try {
196
+ header = JSON.parse(lines.shift() ?? "");
197
+ }
198
+ catch {
199
+ return invalid();
200
+ }
201
+ if (!header || typeof header !== "object" || Array.isArray(header))
202
+ return invalid();
203
+ const value = header;
204
+ if ((Object.keys(value).join(",") === "unsupportedServerVersion" &&
205
+ value.unsupportedServerVersion === true) ||
206
+ (Object.keys(value).join(",") === "totalTimeout" &&
207
+ value.totalTimeout === true)) {
208
+ if (lines.length ||
209
+ !Number.isSafeInteger(expectedCount) ||
210
+ expectedCount < 0 ||
211
+ expectedCount > MIGRATION_IMPACT_LIMITS.maximumMeasurements)
212
+ return invalid();
213
+ return {
214
+ snapshotAt: null,
215
+ counts: Array.from({ length: expectedCount }, (_, index) => ({
216
+ index,
217
+ count: null,
218
+ reason: value.totalTimeout === true
219
+ ? "total_timeout"
220
+ : "unsupported_server_version",
221
+ })),
222
+ };
223
+ }
224
+ if (Object.keys(value).join(",") !== "snapshotAt" ||
225
+ typeof value.snapshotAt !== "string" ||
226
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value.snapshotAt) ||
227
+ !Number.isFinite(Date.parse(value.snapshotAt)) ||
228
+ new Date(value.snapshotAt).toISOString() !== value.snapshotAt)
229
+ return invalid();
230
+ return {
231
+ snapshotAt: value.snapshotAt,
232
+ counts: parseMigrationImpactOutput(lines.join("\n"), expectedCount),
233
+ };
234
+ };
235
+ export const parseMigrationImpactOutput = (output, expectedCount) => {
236
+ const invalid = () => {
237
+ throw new Error("The sandbox returned an unsupported aggregate report.");
238
+ };
239
+ if (!Number.isSafeInteger(expectedCount) ||
240
+ expectedCount < 0 ||
241
+ expectedCount > MIGRATION_IMPACT_LIMITS.maximumMeasurements ||
242
+ Buffer.byteLength(output) > MIGRATION_IMPACT_LIMITS.maximumOutputBytes)
243
+ return invalid();
244
+ const lines = output.trim() ? output.trim().split(/\r?\n/u) : [];
245
+ if (lines.length !== expectedCount)
246
+ return invalid();
247
+ return lines.map((line, index) => {
248
+ let parsed;
249
+ try {
250
+ parsed = JSON.parse(line);
251
+ }
252
+ catch {
253
+ return invalid();
254
+ }
255
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
256
+ return invalid();
257
+ const value = parsed;
258
+ if (Object.keys(value).sort().join(",") !== "count,index,reason" ||
259
+ value.index !== index)
260
+ return invalid();
261
+ if (value.count === null && value.reason === "unsupported_runtime_metadata")
262
+ return { index, count: null, reason: value.reason };
263
+ if (typeof value.count !== "string" ||
264
+ !/^(?:0|[1-9][0-9]{0,18})$/u.test(value.count) ||
265
+ BigInt(value.count) > 9223372036854775807n ||
266
+ value.reason !== null)
267
+ return invalid();
268
+ return { index, count: value.count, reason: null };
269
+ });
270
+ };
@@ -0,0 +1,137 @@
1
+ import type { RehearsalConfigPathOptions } from "../project/configuration.mjs";
2
+ export declare const MIGRATION_INSIGHT_RULES: Readonly<{
3
+ drop_table: {
4
+ summary: string;
5
+ suggestion: string;
6
+ };
7
+ drop_column: {
8
+ summary: string;
9
+ suggestion: string;
10
+ };
11
+ truncate: {
12
+ summary: string;
13
+ suggestion: string;
14
+ };
15
+ broad_update: {
16
+ summary: string;
17
+ suggestion: string;
18
+ };
19
+ broad_delete: {
20
+ summary: string;
21
+ suggestion: string;
22
+ };
23
+ not_null: {
24
+ summary: string;
25
+ suggestion: string;
26
+ };
27
+ new_constraint: {
28
+ summary: string;
29
+ suggestion: string;
30
+ };
31
+ cascade: {
32
+ summary: string;
33
+ suggestion: string;
34
+ };
35
+ }>;
36
+ type Verdict = "static_warnings" | "no_detected_warning" | "unknown";
37
+ export declare const inspectMigrationInsights: (options?: RehearsalConfigPathOptions) => Promise<{
38
+ schemaVersion: 1;
39
+ inspectorVersion: string;
40
+ candidateSha256: string;
41
+ candidateCount: number;
42
+ verdict: Verdict;
43
+ summary: {
44
+ findingCount: number;
45
+ unknownCount: number;
46
+ };
47
+ targets: {
48
+ name: string;
49
+ provider: "postgresql" | "supabase";
50
+ baselineGenerationId: string;
51
+ candidateSha256: string;
52
+ entries: {
53
+ version: string;
54
+ filename: string;
55
+ sha256: string;
56
+ workspaceRelativePath: string | null;
57
+ verdict: Verdict;
58
+ coverage: "incomplete" | "recognized_lexical_rules";
59
+ unknownReasons: import("./migration_sql_lexer.mjs").ScanUnknownReason[];
60
+ findings: ({
61
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
62
+ severity: "warning" | "danger";
63
+ line: number;
64
+ column: number;
65
+ summary: string;
66
+ suggestion: string;
67
+ } | {
68
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
69
+ severity: "warning" | "danger";
70
+ line: number;
71
+ column: number;
72
+ summary: string;
73
+ suggestion: string;
74
+ } | {
75
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
76
+ severity: "warning" | "danger";
77
+ line: number;
78
+ column: number;
79
+ summary: string;
80
+ suggestion: string;
81
+ } | {
82
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
83
+ severity: "warning" | "danger";
84
+ line: number;
85
+ column: number;
86
+ summary: string;
87
+ suggestion: string;
88
+ } | {
89
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
90
+ severity: "warning" | "danger";
91
+ line: number;
92
+ column: number;
93
+ summary: string;
94
+ suggestion: string;
95
+ } | {
96
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
97
+ severity: "warning" | "danger";
98
+ line: number;
99
+ column: number;
100
+ summary: string;
101
+ suggestion: string;
102
+ } | {
103
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
104
+ severity: "warning" | "danger";
105
+ line: number;
106
+ column: number;
107
+ summary: string;
108
+ suggestion: string;
109
+ } | {
110
+ rule: import("./migration_sql_scan.mjs").MigrationSqlRule;
111
+ severity: "warning" | "danger";
112
+ line: number;
113
+ column: number;
114
+ summary: string;
115
+ suggestion: string;
116
+ })[];
117
+ }[];
118
+ }[];
119
+ limits: Readonly<{
120
+ maximumFiles: 10000;
121
+ maximumDirectoryEntries: 10000;
122
+ maximumCandidates: 1000;
123
+ maximumFileBytes: number;
124
+ maximumTotalBytes: number;
125
+ maximumFindings: 5000;
126
+ }>;
127
+ safety: {
128
+ readsSqlBodies: boolean;
129
+ executesSql: boolean;
130
+ startsEngine: boolean;
131
+ contactsDatabase: boolean;
132
+ includesSqlOrRowValues: boolean;
133
+ };
134
+ limitation: string;
135
+ }>;
136
+ export declare const renderMigrationInsights: (report: Awaited<ReturnType<typeof inspectMigrationInsights>>) => string;
137
+ export {};
@@ -0,0 +1,143 @@
1
+ /** Static warnings bound to the existing complete candidate plan, never a safety proof. */
2
+ import { join, relative, resolve, sep } from "node:path";
3
+ import { realpath } from "node:fs/promises";
4
+ import { REHEARSAL_VERSION, RehearsalError } from "../shared/diagnostics.mjs";
5
+ import { isMigrationWorkspacePath } from "./migration_failure_source.mjs";
6
+ import { createMigrationInsightsInput, MIGRATION_INSIGHTS_LIMITS, } from "./migration_insights_input.mjs";
7
+ import { scanMigrationSql } from "./migration_sql_scan.mjs";
8
+ import { inspectRehearsalMigrations } from "./plan.mjs";
9
+ import { inspectRuntimeTopologyCandidates } from "./topology.mjs";
10
+ export const MIGRATION_INSIGHT_RULES = Object.freeze({
11
+ drop_table: {
12
+ summary: "A table may be removed.",
13
+ suggestion: "Review dependent objects and preserve any required rows before dropping the table.",
14
+ },
15
+ drop_column: {
16
+ summary: "A column and its stored values may be removed.",
17
+ suggestion: "Review populated values and dependent application code before dropping the column.",
18
+ },
19
+ truncate: {
20
+ summary: "All rows in the selected table may be removed.",
21
+ suggestion: "Confirm the intended data loss and review identity, foreign-key and cascade behavior.",
22
+ },
23
+ broad_update: {
24
+ summary: "An UPDATE has no top-level WHERE clause.",
25
+ suggestion: "Review the intended row scope; a nested WHERE does not restrict the outer UPDATE.",
26
+ },
27
+ broad_delete: {
28
+ summary: "A DELETE has no top-level WHERE clause.",
29
+ suggestion: "Review the intended row scope; a nested WHERE does not restrict the outer DELETE.",
30
+ },
31
+ not_null: {
32
+ summary: "A NOT NULL requirement may reject existing rows.",
33
+ suggestion: "Check existing NULL values and apply a reviewed backfill before enforcing the requirement.",
34
+ },
35
+ new_constraint: {
36
+ summary: "A new constraint may reject existing rows.",
37
+ suggestion: "Check NULL, duplicate, orphan and CHECK violations as appropriate; SQL text alone cannot establish compliance.",
38
+ },
39
+ cascade: {
40
+ summary: "CASCADE can affect dependent objects or rows.",
41
+ suggestion: "Review the dependency graph; this static warning does not measure the cascade's actual reach.",
42
+ },
43
+ });
44
+ const verdict = (complete, warnings) => !complete ? "unknown" : warnings ? "static_warnings" : "no_detected_warning";
45
+ const inspectionError = (message) => new RehearsalError({
46
+ category: "configuration_invalid",
47
+ code: "CONFIGURATION_INVALID",
48
+ message,
49
+ refused: "No SQL was executed and no database or engine was contacted.",
50
+ suggestions: [
51
+ "Review the documented input limits and request a fresh complete migration inspection.",
52
+ ],
53
+ });
54
+ export const inspectMigrationInsights = async (options = {}) => {
55
+ const projectRoot = await realpath(resolve(options.projectRoot ?? process.cwd()));
56
+ const input = createMigrationInsightsInput(projectRoot);
57
+ const candidates = await inspectRuntimeTopologyCandidates({ ...options, projectRoot }, (selected) => inspectRehearsalMigrations(selected, input.readInventory));
58
+ if (candidates.candidateCount > MIGRATION_INSIGHTS_LIMITS.maximumCandidates)
59
+ throw inspectionError("Migration Insights exceeds its bounded 1,000-candidate plan.");
60
+ let findingCount = 0;
61
+ let unknownCount = 0;
62
+ const targets = candidates.targets.map((target) => {
63
+ const entries = target.summary.candidates.map((candidate) => {
64
+ const path = join(target.paths.migrationDirectory, candidate.filename);
65
+ const source = input.sourceFor(path, candidate.sha256);
66
+ if (source === undefined)
67
+ throw inspectionError("Migration Insights could not retain its exact reviewed input. Refresh the plan.");
68
+ const scan = scanMigrationSql(source);
69
+ const remaining = MIGRATION_INSIGHTS_LIMITS.maximumFindings - findingCount;
70
+ const limited = scan.findings.length > remaining;
71
+ const findings = scan.findings
72
+ .slice(0, Math.max(0, remaining))
73
+ .map((finding) => ({
74
+ ...finding,
75
+ ...MIGRATION_INSIGHT_RULES[finding.rule],
76
+ }));
77
+ findingCount += findings.length;
78
+ const complete = scan.complete && !limited;
79
+ if (!complete)
80
+ unknownCount++;
81
+ const workspacePath = relative(projectRoot, path).split(sep).join("/");
82
+ return {
83
+ version: candidate.version,
84
+ filename: candidate.filename,
85
+ sha256: candidate.sha256,
86
+ workspaceRelativePath: isMigrationWorkspacePath(workspacePath)
87
+ ? workspacePath
88
+ : null,
89
+ verdict: verdict(complete, findings.length),
90
+ coverage: complete
91
+ ? "recognized_lexical_rules"
92
+ : "incomplete",
93
+ unknownReasons: [
94
+ ...new Set([
95
+ ...scan.unknownReasons,
96
+ ...(limited ? ["input_limit"] : []),
97
+ ]),
98
+ ],
99
+ findings,
100
+ };
101
+ });
102
+ return {
103
+ name: target.name,
104
+ provider: target.config.runtime.target,
105
+ baselineGenerationId: target.summary.baselineGenerationId,
106
+ candidateSha256: target.summary.candidateSha256,
107
+ entries,
108
+ };
109
+ });
110
+ return {
111
+ schemaVersion: 1,
112
+ inspectorVersion: REHEARSAL_VERSION,
113
+ candidateSha256: candidates.candidateSha256,
114
+ candidateCount: candidates.candidateCount,
115
+ verdict: verdict(unknownCount === 0, findingCount),
116
+ summary: { findingCount, unknownCount },
117
+ targets,
118
+ limits: MIGRATION_INSIGHTS_LIMITS,
119
+ safety: {
120
+ readsSqlBodies: true,
121
+ executesSql: false,
122
+ startsEngine: false,
123
+ contactsDatabase: false,
124
+ includesSqlOrRowValues: false,
125
+ },
126
+ limitation: "Static lexical warnings only. No detected warning is not a safety or syntax-validity verdict; data impact, permissions, locks and production behavior are not measured.",
127
+ };
128
+ };
129
+ export const renderMigrationInsights = (report) => [
130
+ "MIGRATION INSIGHTS — STATIC, READ ONLY",
131
+ `${report.candidateCount} pending candidates · ${report.summary.findingCount} warnings · ${report.summary.unknownCount} incomplete analyses`,
132
+ `Exact candidate digest: ${report.candidateSha256}`,
133
+ ...report.targets.flatMap((target) => target.entries.flatMap((entry) => [
134
+ `${target.name}/${entry.filename}: ${entry.verdict.replaceAll("_", " ")}`,
135
+ ...entry.findings.map((finding) => ` ${finding.line}:${finding.column} ${finding.rule}: ${finding.summary} ${finding.suggestion}`),
136
+ ...(entry.unknownReasons.length
137
+ ? [
138
+ " Unsupported, malformed or bounded input remains unknown; review this file manually.",
139
+ ]
140
+ : []),
141
+ ])),
142
+ report.limitation,
143
+ ].join("\n");
@@ -0,0 +1,13 @@
1
+ import { readMigrationFileInventory } from "./migration_history.mjs";
2
+ export declare const MIGRATION_INSIGHTS_LIMITS: Readonly<{
3
+ maximumFiles: 10000;
4
+ maximumDirectoryEntries: 10000;
5
+ maximumCandidates: 1000;
6
+ maximumFileBytes: number;
7
+ maximumTotalBytes: number;
8
+ maximumFindings: 5000;
9
+ }>;
10
+ export declare const createMigrationInsightsInput: (projectRoot: string) => {
11
+ readInventory: typeof readMigrationFileInventory;
12
+ sourceFor: (path: string, sha256: string) => string | undefined;
13
+ };