mcp-migration-advisor 0.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/LICENSE +21 -0
- package/README.md +158 -0
- package/build/analyzers/conflicts.js +167 -0
- package/build/analyzers/data-loss.js +131 -0
- package/build/analyzers/lock-risk.js +174 -0
- package/build/generators/rollback.js +197 -0
- package/build/index.js +285 -0
- package/build/license.js +115 -0
- package/build/parsers/flyway-sql.js +189 -0
- package/build/parsers/liquibase-xml.js +263 -0
- package/build/parsers/liquibase-yaml.js +381 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmytro Lisnichenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/mcp-migration-advisor)
|
|
2
|
+
[](https://opensource.org/licenses/MIT)
|
|
3
|
+
|
|
4
|
+
# MCP Migration Advisor
|
|
5
|
+
|
|
6
|
+
MCP server for database migration risk analysis. Detects dangerous schema changes before they hit production.
|
|
7
|
+
|
|
8
|
+
## Why This Tool?
|
|
9
|
+
|
|
10
|
+
Database migration failures cause outages. This tool analyzes your Flyway and Liquibase migrations **before** they run — detecting destructive operations, lock risks, and data loss patterns.
|
|
11
|
+
|
|
12
|
+
Unlike MigrationPilot (PG-only, raw SQL analysis), this tool **parses Liquibase XML and YAML changelogs natively** — extracting changeSets, detecting conflicts between changeSet IDs, and validating rollback completeness. It works across database types, not just PostgreSQL.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Lock Risk Analysis**: Detects DDL operations that acquire heavy table locks (ACCESS EXCLUSIVE, SHARE locks)
|
|
17
|
+
- **Data Loss Detection**: Finds column drops, type changes that truncate, TRUNCATE/DELETE without WHERE
|
|
18
|
+
- **Risk Scoring**: Calculates 0-100 risk score based on severity of detected issues
|
|
19
|
+
- **Flyway Support**: Parses V__*.sql and R__*.sql migration filenames
|
|
20
|
+
- **Liquibase Support**: Parses XML and YAML changelogs with changeSet extraction
|
|
21
|
+
- **Conflict Detection**: Identifies duplicate changeSet IDs and ordering issues
|
|
22
|
+
- **Rollback Validation**: Checks rollback completeness for each changeSet
|
|
23
|
+
- **Actionable Recommendations**: Every risk includes a specific safe alternative
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx mcp-migration-advisor
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install globally:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g mcp-migration-advisor
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Claude Desktop Configuration
|
|
38
|
+
|
|
39
|
+
Add to `~/.claude/claude_desktop_config.json`:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"migration-advisor": {
|
|
45
|
+
"command": "npx",
|
|
46
|
+
"args": ["-y", "mcp-migration-advisor"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Demo
|
|
53
|
+
|
|
54
|
+
Try these prompts in Claude:
|
|
55
|
+
|
|
56
|
+
1. **"Analyze this migration for risks: ALTER TABLE users DROP COLUMN email;"** — Returns risk score (0-100), lock risks, data loss warnings, and safe alternatives
|
|
57
|
+
2. **"Generate a rollback for this migration: CREATE TABLE orders (...); CREATE INDEX idx_orders_user ON orders(user_id);"** — Produces reverse DDL in correct order
|
|
58
|
+
3. **"Score this Liquibase changelog: [paste XML]"** — Parses changeSets and calculates overall risk
|
|
59
|
+
|
|
60
|
+
## Tools
|
|
61
|
+
|
|
62
|
+
### `analyze_migration`
|
|
63
|
+
|
|
64
|
+
Full analysis of a SQL migration file. Returns lock risks, data loss analysis, and recommendations.
|
|
65
|
+
|
|
66
|
+
**Parameters:**
|
|
67
|
+
- `filename` — Migration filename (e.g., `V2__add_user_email.sql`)
|
|
68
|
+
- `sql` — The SQL content
|
|
69
|
+
|
|
70
|
+
### `analyze_liquibase`
|
|
71
|
+
|
|
72
|
+
Full analysis of a Liquibase XML changelog. Parses changeSets and applies the same lock risk and data loss analysis.
|
|
73
|
+
|
|
74
|
+
**Parameters:**
|
|
75
|
+
- `xml` — The Liquibase XML changelog content
|
|
76
|
+
|
|
77
|
+
### `analyze_liquibase_yaml`
|
|
78
|
+
|
|
79
|
+
Full analysis of a Liquibase YAML changelog. Parses changeSets from YAML format and applies the same lock risk and data loss analysis. Supports all standard change types: createTable, dropTable, addColumn, dropColumn, modifyDataType, createIndex, renameTable, renameColumn, and more.
|
|
80
|
+
|
|
81
|
+
**Parameters:**
|
|
82
|
+
- `yaml` — The Liquibase YAML changelog content
|
|
83
|
+
|
|
84
|
+
### `score_risk`
|
|
85
|
+
|
|
86
|
+
Quick risk score (0-100) with verdict (LOW/MODERATE/HIGH RISK).
|
|
87
|
+
|
|
88
|
+
**Parameters:**
|
|
89
|
+
- `filename` — Migration filename
|
|
90
|
+
- `sql` — The SQL content
|
|
91
|
+
|
|
92
|
+
### `generate_rollback`
|
|
93
|
+
|
|
94
|
+
Generate reverse DDL to undo a SQL migration. Produces rollback SQL with warnings for irreversible operations.
|
|
95
|
+
|
|
96
|
+
**Parameters:**
|
|
97
|
+
- `filename` — Migration filename
|
|
98
|
+
- `sql` — The SQL content
|
|
99
|
+
|
|
100
|
+
**Reverses automatically:**
|
|
101
|
+
- CREATE TABLE → DROP TABLE
|
|
102
|
+
- ADD COLUMN → ALTER TABLE ... DROP COLUMN
|
|
103
|
+
- CREATE INDEX → DROP INDEX (preserves CONCURRENTLY)
|
|
104
|
+
- ADD CONSTRAINT → DROP CONSTRAINT
|
|
105
|
+
- SET NOT NULL → DROP NOT NULL
|
|
106
|
+
- RENAME → reverse RENAME
|
|
107
|
+
|
|
108
|
+
**Warns on irreversible:**
|
|
109
|
+
- DROP TABLE, DROP COLUMN (data loss)
|
|
110
|
+
- Column type changes (original type unknown)
|
|
111
|
+
- DROP INDEX, DROP CONSTRAINT (original definition unknown)
|
|
112
|
+
|
|
113
|
+
Includes Flyway `schema_history` cleanup statement.
|
|
114
|
+
|
|
115
|
+
### `detect_conflicts`
|
|
116
|
+
|
|
117
|
+
Detect conflicts between two SQL migration files. Identifies same-table modifications, same-column changes, lock contention risks, and drop dependencies that could cause failures if applied concurrently or in the wrong order.
|
|
118
|
+
|
|
119
|
+
**Parameters:**
|
|
120
|
+
- `filename_a` — First migration filename (e.g., `V3__add_email.sql`)
|
|
121
|
+
- `sql_a` — SQL content of the first migration
|
|
122
|
+
- `filename_b` — Second migration filename (e.g., `V4__modify_users.sql`)
|
|
123
|
+
- `sql_b` — SQL content of the second migration
|
|
124
|
+
|
|
125
|
+
**Detects:**
|
|
126
|
+
- **Same column conflicts** (CRITICAL) — both migrations modify the same column on the same table
|
|
127
|
+
- **Drop dependencies** (CRITICAL) — one migration drops a table the other modifies, causing order-dependent failures
|
|
128
|
+
- **Lock contention** (WARNING) — both migrations require exclusive locks on the same table, risking lock wait timeouts
|
|
129
|
+
|
|
130
|
+
Returns a conflict report with severity levels, affected tables/columns, and whether the migrations can safely run concurrently.
|
|
131
|
+
|
|
132
|
+
## What It Detects
|
|
133
|
+
|
|
134
|
+
| Pattern | Severity | Why It's Dangerous |
|
|
135
|
+
|---------|----------|--------------------|
|
|
136
|
+
| NOT NULL without DEFAULT | CRITICAL | Full table rewrite with ACCESS EXCLUSIVE lock |
|
|
137
|
+
| Column type change | CRITICAL | Table rewrite, data truncation risk |
|
|
138
|
+
| CREATE INDEX (not CONCURRENTLY) | HIGH | SHARE lock blocks all writes |
|
|
139
|
+
| DROP TABLE CASCADE | CRITICAL | Drops all dependent objects |
|
|
140
|
+
| DROP COLUMN | HIGH | Irreversible data loss |
|
|
141
|
+
| SET NOT NULL | HIGH | Full table scan with lock |
|
|
142
|
+
| FOREIGN KEY constraint | MEDIUM | Locks both tables for validation |
|
|
143
|
+
| TRUNCATE / DELETE without WHERE | CERTAIN data loss | All rows permanently deleted |
|
|
144
|
+
|
|
145
|
+
## Limitations & Known Issues
|
|
146
|
+
|
|
147
|
+
- **Static analysis only**: Analyzes SQL text without database connectivity. Cannot check actual table sizes, row counts, or existing schema to calibrate risk.
|
|
148
|
+
- **PostgreSQL-focused**: Lock risk recommendations are primarily for PostgreSQL. MySQL and SQLite lock behaviors differ and may not be fully covered.
|
|
149
|
+
- **Complex DDL**: Stored procedures, triggers, and dynamic SQL within migrations are classified as "OTHER" and receive generic risk assessment.
|
|
150
|
+
- **Liquibase support**: Handles standard XML and YAML changesets. JSON Liquibase format and custom change types are not supported.
|
|
151
|
+
- **Rollback limitations**: Auto-generated rollbacks cannot reverse DROP TABLE, DROP COLUMN, or type changes (data is lost). These produce warnings instead of rollback SQL.
|
|
152
|
+
- **Multi-statement transactions**: The parser splits on semicolons. Statements containing semicolons inside strings or dollar-quoted blocks may be split incorrectly.
|
|
153
|
+
- **No execution**: The advisor analyzes but never executes migrations. All recommendations are advisory.
|
|
154
|
+
- **Database-specific DDL**: Parser targets PostgreSQL/MySQL DDL syntax. Oracle PL/SQL or SQL Server T-SQL may not be fully recognized.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration conflict detector.
|
|
3
|
+
*
|
|
4
|
+
* Given two parsed migrations, detects if they modify the same table/column
|
|
5
|
+
* and could conflict when applied concurrently or in the wrong order.
|
|
6
|
+
*
|
|
7
|
+
* Conflict types:
|
|
8
|
+
* - Same table modification (both ALTER/DROP the same table)
|
|
9
|
+
* - Same column modification (both modify the same column)
|
|
10
|
+
* - Dependent order (one adds a column the other references)
|
|
11
|
+
* - Lock contention (both require exclusive locks on the same table)
|
|
12
|
+
*/
|
|
13
|
+
// Statement types that take exclusive locks
|
|
14
|
+
const EXCLUSIVE_LOCK_TYPES = new Set([
|
|
15
|
+
"ALTER_TABLE",
|
|
16
|
+
"DROP_TABLE",
|
|
17
|
+
"ADD_COLUMN",
|
|
18
|
+
"DROP_COLUMN",
|
|
19
|
+
"MODIFY_COLUMN",
|
|
20
|
+
"ADD_CONSTRAINT",
|
|
21
|
+
"DROP_CONSTRAINT",
|
|
22
|
+
"RENAME",
|
|
23
|
+
]);
|
|
24
|
+
// Statement types that modify a column
|
|
25
|
+
const COLUMN_MODIFYING_TYPES = new Set([
|
|
26
|
+
"ADD_COLUMN",
|
|
27
|
+
"DROP_COLUMN",
|
|
28
|
+
"MODIFY_COLUMN",
|
|
29
|
+
]);
|
|
30
|
+
export function detectConflicts(migrationA, migrationB) {
|
|
31
|
+
const conflicts = [];
|
|
32
|
+
// Build table→statements map for each migration
|
|
33
|
+
const tablesA = groupByTable(migrationA.statements);
|
|
34
|
+
const tablesB = groupByTable(migrationB.statements);
|
|
35
|
+
// Find overlapping tables
|
|
36
|
+
for (const [table, stmtsA] of tablesA) {
|
|
37
|
+
const stmtsB = tablesB.get(table);
|
|
38
|
+
if (!stmtsB)
|
|
39
|
+
continue;
|
|
40
|
+
// Check for column-level conflicts first (more specific)
|
|
41
|
+
const columnConflicts = detectColumnConflicts(table, stmtsA, stmtsB);
|
|
42
|
+
conflicts.push(...columnConflicts);
|
|
43
|
+
// Check for lock contention (both need exclusive locks on same table)
|
|
44
|
+
const lockA = stmtsA.some((s) => EXCLUSIVE_LOCK_TYPES.has(s.type));
|
|
45
|
+
const lockB = stmtsB.some((s) => EXCLUSIVE_LOCK_TYPES.has(s.type));
|
|
46
|
+
if (lockA && lockB && columnConflicts.length === 0) {
|
|
47
|
+
// Only add table-level conflict if no column-level conflict was found
|
|
48
|
+
conflicts.push({
|
|
49
|
+
severity: "WARNING",
|
|
50
|
+
type: "LOCK_CONTENTION",
|
|
51
|
+
table,
|
|
52
|
+
message: `Both migrations require exclusive locks on table "${table}". Running concurrently may cause lock wait timeouts.`,
|
|
53
|
+
recommendation: `Merge these changes into a single migration or ensure they run sequentially with sufficient lock_timeout.`,
|
|
54
|
+
statementA: truncate(stmtsA[0].raw),
|
|
55
|
+
statementB: truncate(stmtsB[0].raw),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Check for drop dependency: one drops a table the other modifies
|
|
59
|
+
const dropsA = stmtsA.filter((s) => s.type === "DROP_TABLE");
|
|
60
|
+
const modifiesB = stmtsB.filter((s) => s.type !== "DROP_TABLE" && s.type !== "CREATE_TABLE");
|
|
61
|
+
if (dropsA.length > 0 && modifiesB.length > 0) {
|
|
62
|
+
conflicts.push({
|
|
63
|
+
severity: "CRITICAL",
|
|
64
|
+
type: "DROP_DEPENDENCY",
|
|
65
|
+
table,
|
|
66
|
+
message: `Migration "${migrationA.filename}" drops table "${table}" while "${migrationB.filename}" modifies it. Order-dependent failure.`,
|
|
67
|
+
recommendation: `Ensure the DROP TABLE migration runs AFTER the other, or consolidate both changes.`,
|
|
68
|
+
statementA: truncate(dropsA[0].raw),
|
|
69
|
+
statementB: truncate(modifiesB[0].raw),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const dropsB = stmtsB.filter((s) => s.type === "DROP_TABLE");
|
|
73
|
+
const modifiesA = stmtsA.filter((s) => s.type !== "DROP_TABLE" && s.type !== "CREATE_TABLE");
|
|
74
|
+
if (dropsB.length > 0 && modifiesA.length > 0) {
|
|
75
|
+
conflicts.push({
|
|
76
|
+
severity: "CRITICAL",
|
|
77
|
+
type: "DROP_DEPENDENCY",
|
|
78
|
+
table,
|
|
79
|
+
message: `Migration "${migrationB.filename}" drops table "${table}" while "${migrationA.filename}" modifies it. Order-dependent failure.`,
|
|
80
|
+
recommendation: `Ensure the DROP TABLE migration runs AFTER the other, or consolidate both changes.`,
|
|
81
|
+
statementA: truncate(modifiesA[0].raw),
|
|
82
|
+
statementB: truncate(dropsB[0].raw),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const canRunConcurrently = conflicts.every((c) => c.severity !== "CRITICAL" && c.type !== "LOCK_CONTENTION");
|
|
87
|
+
return {
|
|
88
|
+
migrationA: migrationA.filename,
|
|
89
|
+
migrationB: migrationB.filename,
|
|
90
|
+
conflicts,
|
|
91
|
+
canRunConcurrently,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function detectColumnConflicts(table, stmtsA, stmtsB) {
|
|
95
|
+
const conflicts = [];
|
|
96
|
+
const colStmtsA = stmtsA.filter((s) => COLUMN_MODIFYING_TYPES.has(s.type) && s.columnName);
|
|
97
|
+
const colStmtsB = stmtsB.filter((s) => COLUMN_MODIFYING_TYPES.has(s.type) && s.columnName);
|
|
98
|
+
for (const a of colStmtsA) {
|
|
99
|
+
for (const b of colStmtsB) {
|
|
100
|
+
if (a.columnName &&
|
|
101
|
+
b.columnName &&
|
|
102
|
+
a.columnName.toLowerCase() === b.columnName.toLowerCase()) {
|
|
103
|
+
conflicts.push({
|
|
104
|
+
severity: "CRITICAL",
|
|
105
|
+
type: "SAME_COLUMN",
|
|
106
|
+
table,
|
|
107
|
+
column: a.columnName,
|
|
108
|
+
message: `Both migrations modify column "${a.columnName}" on table "${table}". This will cause conflicts regardless of execution order.`,
|
|
109
|
+
recommendation: `Merge these column changes into a single migration to avoid conflicts.`,
|
|
110
|
+
statementA: truncate(a.raw),
|
|
111
|
+
statementB: truncate(b.raw),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return conflicts;
|
|
117
|
+
}
|
|
118
|
+
function groupByTable(statements) {
|
|
119
|
+
const map = new Map();
|
|
120
|
+
for (const stmt of statements) {
|
|
121
|
+
if (!stmt.tableName)
|
|
122
|
+
continue;
|
|
123
|
+
const table = stmt.tableName.toLowerCase();
|
|
124
|
+
const existing = map.get(table) || [];
|
|
125
|
+
existing.push(stmt);
|
|
126
|
+
map.set(table, existing);
|
|
127
|
+
}
|
|
128
|
+
return map;
|
|
129
|
+
}
|
|
130
|
+
function truncate(s, max = 80) {
|
|
131
|
+
const clean = s.replace(/\s+/g, " ").trim();
|
|
132
|
+
return clean.length > max ? clean.slice(0, max - 3) + "..." : clean;
|
|
133
|
+
}
|
|
134
|
+
export function formatConflictReport(report) {
|
|
135
|
+
const sections = [];
|
|
136
|
+
sections.push("## Migration Conflict Analysis");
|
|
137
|
+
sections.push(`\n- **Migration A**: ${report.migrationA}`);
|
|
138
|
+
sections.push(`- **Migration B**: ${report.migrationB}`);
|
|
139
|
+
sections.push(`- **Conflicts found**: ${report.conflicts.length}`);
|
|
140
|
+
sections.push(`- **Can run concurrently**: ${report.canRunConcurrently ? "Yes" : "**No**"}`);
|
|
141
|
+
if (report.conflicts.length === 0) {
|
|
142
|
+
sections.push("\n### No conflicts detected\n\nThese migrations can safely be applied in either order.");
|
|
143
|
+
return sections.join("\n");
|
|
144
|
+
}
|
|
145
|
+
const critical = report.conflicts.filter((c) => c.severity === "CRITICAL");
|
|
146
|
+
const warnings = report.conflicts.filter((c) => c.severity === "WARNING");
|
|
147
|
+
if (critical.length > 0) {
|
|
148
|
+
sections.push(`\n### Critical Conflicts (${critical.length})\n`);
|
|
149
|
+
for (const c of critical) {
|
|
150
|
+
sections.push(`**[${c.type}]** Table: \`${c.table}\`${c.column ? `, Column: \`${c.column}\`` : ""}`);
|
|
151
|
+
sections.push(c.message);
|
|
152
|
+
sections.push(`- A: \`${c.statementA}\``);
|
|
153
|
+
sections.push(`- B: \`${c.statementB}\``);
|
|
154
|
+
sections.push(`*Fix*: ${c.recommendation}\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (warnings.length > 0) {
|
|
158
|
+
sections.push(`\n### Warnings (${warnings.length})\n`);
|
|
159
|
+
for (const c of warnings) {
|
|
160
|
+
sections.push(`**[${c.type}]** Table: \`${c.table}\``);
|
|
161
|
+
sections.push(c.message);
|
|
162
|
+
sections.push(`*Fix*: ${c.recommendation}\n`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return sections.join("\n");
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=conflicts.js.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data loss detector.
|
|
3
|
+
*
|
|
4
|
+
* Detects migration operations that can cause irreversible data loss:
|
|
5
|
+
* - Column drops
|
|
6
|
+
* - Type changes that truncate data
|
|
7
|
+
* - NOT NULL on existing columns without default
|
|
8
|
+
* - Table drops
|
|
9
|
+
* - CASCADE operations
|
|
10
|
+
*/
|
|
11
|
+
// Types that lose precision when converted
|
|
12
|
+
const NARROWING_CONVERSIONS = {
|
|
13
|
+
"BIGINT": ["INTEGER", "SMALLINT", "TINYINT"],
|
|
14
|
+
"INTEGER": ["SMALLINT", "TINYINT"],
|
|
15
|
+
"DOUBLE PRECISION": ["REAL", "FLOAT4", "NUMERIC"],
|
|
16
|
+
"TEXT": ["VARCHAR", "CHAR"],
|
|
17
|
+
"VARCHAR": ["CHAR"],
|
|
18
|
+
"TIMESTAMP": ["DATE", "TIME"],
|
|
19
|
+
"TIMESTAMPTZ": ["DATE", "TIME", "TIMESTAMP"],
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Analyze a migration for data loss risks.
|
|
23
|
+
*/
|
|
24
|
+
export function analyzeDataLoss(migration) {
|
|
25
|
+
const issues = [];
|
|
26
|
+
for (const stmt of migration.statements) {
|
|
27
|
+
const upper = stmt.raw.toUpperCase();
|
|
28
|
+
switch (stmt.type) {
|
|
29
|
+
case "DROP_COLUMN": {
|
|
30
|
+
issues.push({
|
|
31
|
+
risk: "CERTAIN",
|
|
32
|
+
statement: truncate(stmt.raw),
|
|
33
|
+
tableName: stmt.tableName,
|
|
34
|
+
description: `Dropping column '${stmt.columnName}' permanently deletes all data in that column.`,
|
|
35
|
+
mitigation: "Back up the column data before dropping. Consider renaming the column first and dropping in a later migration after verifying no rollback is needed.",
|
|
36
|
+
});
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case "DROP_TABLE": {
|
|
40
|
+
const cascade = upper.includes("CASCADE");
|
|
41
|
+
issues.push({
|
|
42
|
+
risk: "CERTAIN",
|
|
43
|
+
statement: truncate(stmt.raw),
|
|
44
|
+
tableName: stmt.tableName,
|
|
45
|
+
description: `Dropping table '${stmt.tableName}' permanently deletes all data.${cascade ? " CASCADE will also drop dependent objects." : ""}`,
|
|
46
|
+
mitigation: "Ensure a backup exists. Consider renaming the table first and dropping in a later migration.",
|
|
47
|
+
});
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "MODIFY_COLUMN": {
|
|
51
|
+
if (stmt.details.typeChange === "true") {
|
|
52
|
+
// Try to detect narrowing type changes
|
|
53
|
+
const typeChangeMatch = stmt.raw.match(/(?:TYPE|SET DATA TYPE)\s+(\w+(?:\(\d+(?:,\s*\d+)?\))?)/i);
|
|
54
|
+
const newType = typeChangeMatch?.[1]?.toUpperCase() || "UNKNOWN";
|
|
55
|
+
issues.push({
|
|
56
|
+
risk: "LIKELY",
|
|
57
|
+
statement: truncate(stmt.raw),
|
|
58
|
+
tableName: stmt.tableName,
|
|
59
|
+
description: `Changing type of '${stmt.columnName}' to ${newType} may truncate or lose data if existing values don't fit the new type.`,
|
|
60
|
+
mitigation: "Run a SELECT to verify all existing values are compatible with the new type before migrating. Use a USING clause for explicit conversion.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (stmt.details.setNotNull === "true") {
|
|
64
|
+
issues.push({
|
|
65
|
+
risk: "POSSIBLE",
|
|
66
|
+
statement: truncate(stmt.raw),
|
|
67
|
+
tableName: stmt.tableName,
|
|
68
|
+
description: `SET NOT NULL on '${stmt.columnName}' will fail if any NULL values exist, blocking the migration.`,
|
|
69
|
+
mitigation: "Run UPDATE ... SET column = default_value WHERE column IS NULL before adding the NOT NULL constraint.",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "ADD_COLUMN": {
|
|
75
|
+
if (stmt.details.notNull === "true" && stmt.details.hasDefault !== "true") {
|
|
76
|
+
issues.push({
|
|
77
|
+
risk: "POSSIBLE",
|
|
78
|
+
statement: truncate(stmt.raw),
|
|
79
|
+
tableName: stmt.tableName,
|
|
80
|
+
description: `Adding NOT NULL column '${stmt.columnName}' without DEFAULT will fail on tables with existing rows.`,
|
|
81
|
+
mitigation: "Add a DEFAULT value, or add the column as nullable first, backfill, then set NOT NULL.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "OTHER": {
|
|
87
|
+
// Detect TRUNCATE
|
|
88
|
+
if (upper.includes("TRUNCATE")) {
|
|
89
|
+
const tableMatch = stmt.raw.match(/TRUNCATE\s+(?:TABLE\s+)?(?:`|"|)?(\w+)/i);
|
|
90
|
+
issues.push({
|
|
91
|
+
risk: "CERTAIN",
|
|
92
|
+
statement: truncate(stmt.raw),
|
|
93
|
+
tableName: tableMatch?.[1] || null,
|
|
94
|
+
description: "TRUNCATE permanently deletes all rows from the table.",
|
|
95
|
+
mitigation: "Ensure this is intentional. Back up the table data first.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// Detect DELETE without WHERE
|
|
99
|
+
if (upper.match(/DELETE\s+FROM/) && !upper.includes("WHERE")) {
|
|
100
|
+
const tableMatch = stmt.raw.match(/DELETE\s+FROM\s+(?:`|"|)?(\w+)/i);
|
|
101
|
+
issues.push({
|
|
102
|
+
risk: "CERTAIN",
|
|
103
|
+
statement: truncate(stmt.raw),
|
|
104
|
+
tableName: tableMatch?.[1] || null,
|
|
105
|
+
description: "DELETE without WHERE clause deletes all rows from the table.",
|
|
106
|
+
mitigation: "Add a WHERE clause to limit the delete, or use TRUNCATE if intentional.",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// Detect UPDATE without WHERE
|
|
110
|
+
if (upper.match(/^UPDATE\b/) && !upper.includes("WHERE")) {
|
|
111
|
+
const tableMatch = stmt.raw.match(/UPDATE\s+(?:`|"|)?(\w+)/i);
|
|
112
|
+
issues.push({
|
|
113
|
+
risk: "LIKELY",
|
|
114
|
+
statement: truncate(stmt.raw),
|
|
115
|
+
tableName: tableMatch?.[1] || null,
|
|
116
|
+
description: "UPDATE without WHERE clause modifies all rows. Previous values are lost.",
|
|
117
|
+
mitigation: "Add a WHERE clause to limit the update scope. Consider adding a backup column or logging old values.",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return issues;
|
|
127
|
+
}
|
|
128
|
+
function truncate(s, max = 120) {
|
|
129
|
+
return s.length > max ? s.substring(0, max) + "..." : s;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=data-loss.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock risk analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Detects DDL operations that acquire heavy table locks in PostgreSQL and MySQL.
|
|
5
|
+
* Each risky pattern has a severity and recommended safe alternative.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Analyze a migration for lock-related risks.
|
|
9
|
+
*/
|
|
10
|
+
export function analyzeLockRisks(migration) {
|
|
11
|
+
const risks = [];
|
|
12
|
+
for (const stmt of migration.statements) {
|
|
13
|
+
const stmtRisks = analyzeStatement(stmt);
|
|
14
|
+
risks.push(...stmtRisks);
|
|
15
|
+
}
|
|
16
|
+
return risks;
|
|
17
|
+
}
|
|
18
|
+
function analyzeStatement(stmt) {
|
|
19
|
+
const risks = [];
|
|
20
|
+
const upper = stmt.raw.toUpperCase();
|
|
21
|
+
switch (stmt.type) {
|
|
22
|
+
case "ADD_COLUMN": {
|
|
23
|
+
// NOT NULL without DEFAULT requires full table rewrite (PG <11, all MySQL)
|
|
24
|
+
if (stmt.details.notNull === "true" && stmt.details.hasDefault !== "true") {
|
|
25
|
+
risks.push({
|
|
26
|
+
severity: "CRITICAL",
|
|
27
|
+
statement: truncate(stmt.raw),
|
|
28
|
+
tableName: stmt.tableName,
|
|
29
|
+
risk: `Adding NOT NULL column '${stmt.columnName}' without DEFAULT requires a full table rewrite. This acquires an ACCESS EXCLUSIVE lock for the duration of the rewrite.`,
|
|
30
|
+
recommendation: "Add the column with a DEFAULT value first, then backfill, then set NOT NULL. Or use ALTER TABLE ... ADD COLUMN ... DEFAULT ... NOT NULL (PG 11+ is fast for this).",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case "DROP_COLUMN": {
|
|
36
|
+
risks.push({
|
|
37
|
+
severity: "HIGH",
|
|
38
|
+
statement: truncate(stmt.raw),
|
|
39
|
+
tableName: stmt.tableName,
|
|
40
|
+
risk: `Dropping column '${stmt.columnName}' acquires ACCESS EXCLUSIVE lock. In PostgreSQL this is fast (metadata only), but in MySQL it rewrites the table.`,
|
|
41
|
+
recommendation: "For PostgreSQL: generally safe but verify no views/functions depend on this column. For MySQL: use pt-online-schema-change or gh-ost for large tables.",
|
|
42
|
+
});
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "MODIFY_COLUMN": {
|
|
46
|
+
if (stmt.details.typeChange === "true") {
|
|
47
|
+
risks.push({
|
|
48
|
+
severity: "CRITICAL",
|
|
49
|
+
statement: truncate(stmt.raw),
|
|
50
|
+
tableName: stmt.tableName,
|
|
51
|
+
risk: `Changing column type for '${stmt.columnName}' may require a full table rewrite with ACCESS EXCLUSIVE lock. Duration depends on table size.`,
|
|
52
|
+
recommendation: "Use expand-contract pattern: add new column, backfill with trigger, switch reads, drop old column. Avoids long lock.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (stmt.details.setNotNull === "true") {
|
|
56
|
+
risks.push({
|
|
57
|
+
severity: "HIGH",
|
|
58
|
+
statement: truncate(stmt.raw),
|
|
59
|
+
tableName: stmt.tableName,
|
|
60
|
+
risk: `SET NOT NULL on '${stmt.columnName}' requires a full table scan to verify no nulls exist. Acquires ACCESS EXCLUSIVE lock during scan (PG <12).`,
|
|
61
|
+
recommendation: "In PG 12+, add a CHECK constraint first (NOT VALID), then validate separately. In older PG/MySQL, test on a replica first.",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "CREATE_INDEX": {
|
|
67
|
+
if (stmt.details.concurrently !== "true") {
|
|
68
|
+
risks.push({
|
|
69
|
+
severity: "HIGH",
|
|
70
|
+
statement: truncate(stmt.raw),
|
|
71
|
+
tableName: stmt.tableName,
|
|
72
|
+
risk: "CREATE INDEX without CONCURRENTLY acquires a SHARE lock on the table, blocking all writes for the duration of index creation.",
|
|
73
|
+
recommendation: "Use CREATE INDEX CONCURRENTLY to allow concurrent writes. Note: CONCURRENTLY cannot run inside a transaction block.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
risks.push({
|
|
78
|
+
severity: "INFO",
|
|
79
|
+
statement: truncate(stmt.raw),
|
|
80
|
+
tableName: stmt.tableName,
|
|
81
|
+
risk: "CREATE INDEX CONCURRENTLY is used — good practice. Note: it takes longer and cannot run in a transaction.",
|
|
82
|
+
recommendation: "Ensure the migration tool runs this outside a transaction (Flyway: use non-transactional migration).",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "DROP_TABLE": {
|
|
88
|
+
risks.push({
|
|
89
|
+
severity: "HIGH",
|
|
90
|
+
statement: truncate(stmt.raw),
|
|
91
|
+
tableName: stmt.tableName,
|
|
92
|
+
risk: `DROP TABLE '${stmt.tableName}' is irreversible and acquires ACCESS EXCLUSIVE lock.`,
|
|
93
|
+
recommendation: "Ensure no foreign keys reference this table. Consider renaming first (expand-contract) to allow rollback.",
|
|
94
|
+
});
|
|
95
|
+
if (stmt.details.cascade === "true") {
|
|
96
|
+
risks.push({
|
|
97
|
+
severity: "CRITICAL",
|
|
98
|
+
statement: truncate(stmt.raw),
|
|
99
|
+
tableName: stmt.tableName,
|
|
100
|
+
risk: "CASCADE will drop all dependent objects (views, foreign keys, functions). This can have unexpected blast radius.",
|
|
101
|
+
recommendation: "List all dependencies first with pg_depend/information_schema. Drop them explicitly instead of using CASCADE.",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "ADD_CONSTRAINT": {
|
|
107
|
+
if (stmt.details.constraintType === "FOREIGN_KEY") {
|
|
108
|
+
risks.push({
|
|
109
|
+
severity: "MEDIUM",
|
|
110
|
+
statement: truncate(stmt.raw),
|
|
111
|
+
tableName: stmt.tableName,
|
|
112
|
+
risk: "Adding a FOREIGN KEY constraint acquires SHARE ROW EXCLUSIVE lock on both tables and validates all existing rows.",
|
|
113
|
+
recommendation: "In PostgreSQL, add the constraint as NOT VALID first, then VALIDATE separately. This splits the lock into two shorter windows.",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (upper.includes("NOT VALID")) {
|
|
117
|
+
risks.push({
|
|
118
|
+
severity: "INFO",
|
|
119
|
+
statement: truncate(stmt.raw),
|
|
120
|
+
tableName: stmt.tableName,
|
|
121
|
+
risk: "NOT VALID constraint added — good practice. Constraint is not validated against existing rows.",
|
|
122
|
+
recommendation: "Remember to run ALTER TABLE ... VALIDATE CONSTRAINT in a follow-up migration.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case "RENAME": {
|
|
128
|
+
risks.push({
|
|
129
|
+
severity: "MEDIUM",
|
|
130
|
+
statement: truncate(stmt.raw),
|
|
131
|
+
tableName: stmt.tableName,
|
|
132
|
+
risk: "RENAME acquires ACCESS EXCLUSIVE lock (brief). Application code referencing the old name will break immediately.",
|
|
133
|
+
recommendation: "Coordinate with application deployment. Consider using views as aliases during transition.",
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
// Generic checks across all statements
|
|
141
|
+
if (upper.includes("LOCK TABLE") || upper.includes("LOCK TABLES")) {
|
|
142
|
+
risks.push({
|
|
143
|
+
severity: "CRITICAL",
|
|
144
|
+
statement: truncate(stmt.raw),
|
|
145
|
+
tableName: stmt.tableName,
|
|
146
|
+
risk: "Explicit table lock detected. This blocks all concurrent access.",
|
|
147
|
+
recommendation: "Avoid explicit locks in migrations. Use row-level locking or redesign the migration.",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return risks;
|
|
151
|
+
}
|
|
152
|
+
function truncate(s, max = 120) {
|
|
153
|
+
return s.length > max ? s.substring(0, max) + "..." : s;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Calculate overall risk score (0-100) for a migration.
|
|
157
|
+
*/
|
|
158
|
+
export function calculateRiskScore(risks) {
|
|
159
|
+
if (risks.length === 0)
|
|
160
|
+
return 0;
|
|
161
|
+
const weights = {
|
|
162
|
+
CRITICAL: 30,
|
|
163
|
+
HIGH: 20,
|
|
164
|
+
MEDIUM: 10,
|
|
165
|
+
LOW: 5,
|
|
166
|
+
INFO: 0,
|
|
167
|
+
};
|
|
168
|
+
let score = 0;
|
|
169
|
+
for (const risk of risks) {
|
|
170
|
+
score += weights[risk.severity];
|
|
171
|
+
}
|
|
172
|
+
return Math.min(100, score);
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=lock-risk.js.map
|