mgcheck 0.1.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 +416 -0
- package/dist/activity-log-ETNHCZ7B.js +13 -0
- package/dist/activity-log-ETNHCZ7B.js.map +1 -0
- package/dist/applier-TLGJ6SZ2.js +9 -0
- package/dist/applier-TLGJ6SZ2.js.map +1 -0
- package/dist/chunk-3G3R2NM3.js +73 -0
- package/dist/chunk-3G3R2NM3.js.map +1 -0
- package/dist/chunk-4YTQB62X.js +261 -0
- package/dist/chunk-4YTQB62X.js.map +1 -0
- package/dist/chunk-7ENQ5WVM.js +97 -0
- package/dist/chunk-7ENQ5WVM.js.map +1 -0
- package/dist/chunk-7JBFSZBD.js +1212 -0
- package/dist/chunk-7JBFSZBD.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +99 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/installer-KGWDJ6OR.js +323 -0
- package/dist/installer-KGWDJ6OR.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +158 -0
- package/dist/mcp/server.js.map +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1212 @@
|
|
|
1
|
+
// src/core/config.ts
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
var DEFAULT_CONFIG = {
|
|
5
|
+
shadowDb: {
|
|
6
|
+
provider: "docker",
|
|
7
|
+
dockerImage: "postgres:16-alpine"
|
|
8
|
+
},
|
|
9
|
+
rules: {},
|
|
10
|
+
output: {
|
|
11
|
+
format: "terminal",
|
|
12
|
+
verbose: false
|
|
13
|
+
},
|
|
14
|
+
confirmDestructive: false
|
|
15
|
+
};
|
|
16
|
+
var CONFIG_FILES = [
|
|
17
|
+
".mgcheckrc.json",
|
|
18
|
+
".mgcheckrc.yml",
|
|
19
|
+
".mgcheckrc",
|
|
20
|
+
"mgcheck.config.json"
|
|
21
|
+
];
|
|
22
|
+
function loadConfig(overrides = {}, configPath) {
|
|
23
|
+
let config = structuredClone(DEFAULT_CONFIG);
|
|
24
|
+
const fileConfig = loadConfigFile(configPath);
|
|
25
|
+
if (fileConfig) {
|
|
26
|
+
config = mergeConfig(config, fileConfig);
|
|
27
|
+
}
|
|
28
|
+
config = applyEnvVars(config);
|
|
29
|
+
config = mergeConfig(config, overrides);
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
function loadConfigFile(explicitPath) {
|
|
33
|
+
if (explicitPath) {
|
|
34
|
+
const fullPath = resolve(explicitPath);
|
|
35
|
+
if (!existsSync(fullPath)) {
|
|
36
|
+
throw new Error(`Config file not found: ${fullPath}`);
|
|
37
|
+
}
|
|
38
|
+
return parseConfigFile(fullPath);
|
|
39
|
+
}
|
|
40
|
+
for (const name of CONFIG_FILES) {
|
|
41
|
+
const fullPath = resolve(process.cwd(), name);
|
|
42
|
+
if (existsSync(fullPath)) {
|
|
43
|
+
return parseConfigFile(fullPath);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function parseConfigFile(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(filePath, "utf-8");
|
|
51
|
+
return JSON.parse(content);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`Failed to parse config file ${filePath}: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function applyEnvVars(config) {
|
|
57
|
+
const result = structuredClone(config);
|
|
58
|
+
if (process.env.MGCHECK_DB_URL) {
|
|
59
|
+
result.shadowDb.dbUrl = process.env.MGCHECK_DB_URL;
|
|
60
|
+
}
|
|
61
|
+
if (process.env.MGCHECK_DOCKER_IMAGE) {
|
|
62
|
+
result.shadowDb.dockerImage = process.env.MGCHECK_DOCKER_IMAGE;
|
|
63
|
+
}
|
|
64
|
+
if (process.env.MGCHECK_PROVIDER) {
|
|
65
|
+
const provider = process.env.MGCHECK_PROVIDER;
|
|
66
|
+
if (provider === "docker" || provider === "pglite") {
|
|
67
|
+
result.shadowDb.provider = provider;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (process.env.MGCHECK_LLM_PROVIDER || process.env.MGCHECK_LLM_API_KEY) {
|
|
71
|
+
result.llm = {
|
|
72
|
+
provider: process.env.MGCHECK_LLM_PROVIDER || "openai",
|
|
73
|
+
model: process.env.MGCHECK_LLM_MODEL || "gpt-4o",
|
|
74
|
+
apiKey: process.env.MGCHECK_LLM_API_KEY || "",
|
|
75
|
+
maxFixAttempts: 1
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
function mergeConfig(a, b) {
|
|
81
|
+
const result = structuredClone(a);
|
|
82
|
+
if (b.shadowDb) {
|
|
83
|
+
Object.assign(result.shadowDb, b.shadowDb);
|
|
84
|
+
}
|
|
85
|
+
if (b.rules) {
|
|
86
|
+
Object.assign(result.rules, b.rules);
|
|
87
|
+
}
|
|
88
|
+
if (b.output) {
|
|
89
|
+
Object.assign(result.output, b.output);
|
|
90
|
+
}
|
|
91
|
+
if (b.confirmDestructive !== void 0) {
|
|
92
|
+
result.confirmDestructive = b.confirmDestructive;
|
|
93
|
+
}
|
|
94
|
+
if (b.llm) {
|
|
95
|
+
result.llm = { ...result.llm, ...b.llm };
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function getRuleSeverity(ruleId, defaultSeverity, config) {
|
|
100
|
+
const override = config.rules[ruleId];
|
|
101
|
+
if (override === "off") return null;
|
|
102
|
+
if (override === "error" || override === "warning") return override;
|
|
103
|
+
return defaultSeverity;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/detector/index.ts
|
|
107
|
+
import { statSync as statSync2, existsSync as existsSync4, readdirSync as readdirSync3 } from "fs";
|
|
108
|
+
import { resolve as resolve2, join as join3 } from "path";
|
|
109
|
+
|
|
110
|
+
// src/detector/raw-sql.ts
|
|
111
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
112
|
+
import { basename } from "path";
|
|
113
|
+
function detectRawSql(filePath) {
|
|
114
|
+
const sql = readFileSync2(filePath, "utf-8");
|
|
115
|
+
const file = {
|
|
116
|
+
path: filePath,
|
|
117
|
+
sql,
|
|
118
|
+
order: 0
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
format: "raw-sql",
|
|
122
|
+
files: [file],
|
|
123
|
+
hasDownMigration: false,
|
|
124
|
+
basePath: filePath
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function detectRawSqlDirectory(files) {
|
|
128
|
+
const sqlFiles = files.filter((f) => f.endsWith(".sql")).sort().map((filePath, index) => ({
|
|
129
|
+
path: filePath,
|
|
130
|
+
sql: readFileSync2(filePath, "utf-8"),
|
|
131
|
+
order: index
|
|
132
|
+
}));
|
|
133
|
+
const hasDown = sqlFiles.some(
|
|
134
|
+
(f) => basename(f.path).toLowerCase().includes("down") || basename(f.path).toLowerCase().includes("rollback")
|
|
135
|
+
);
|
|
136
|
+
return {
|
|
137
|
+
format: "raw-sql",
|
|
138
|
+
files: sqlFiles,
|
|
139
|
+
hasDownMigration: hasDown,
|
|
140
|
+
basePath: sqlFiles[0]?.path || ""
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/detector/prisma.ts
|
|
145
|
+
import { readFileSync as readFileSync3, existsSync as existsSync2, readdirSync, statSync } from "fs";
|
|
146
|
+
import { join } from "path";
|
|
147
|
+
function detectPrisma(dirPath) {
|
|
148
|
+
const migrationSqlPath = join(dirPath, "migration.sql");
|
|
149
|
+
if (existsSync2(migrationSqlPath)) {
|
|
150
|
+
const sql = readFileSync3(migrationSqlPath, "utf-8");
|
|
151
|
+
const hasDown2 = existsSync2(join(dirPath, "down.sql"));
|
|
152
|
+
return {
|
|
153
|
+
format: "prisma",
|
|
154
|
+
files: [{ path: migrationSqlPath, sql, order: 0 }],
|
|
155
|
+
hasDownMigration: hasDown2,
|
|
156
|
+
basePath: dirPath
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const files = [];
|
|
160
|
+
const entries = readdirSync(dirPath).filter((entry) => {
|
|
161
|
+
const entryPath = join(dirPath, entry);
|
|
162
|
+
return statSync(entryPath).isDirectory();
|
|
163
|
+
}).sort();
|
|
164
|
+
let hasDown = false;
|
|
165
|
+
for (let i = 0; i < entries.length; i++) {
|
|
166
|
+
const entryDir = join(dirPath, entries[i]);
|
|
167
|
+
const sqlFile = join(entryDir, "migration.sql");
|
|
168
|
+
if (existsSync2(sqlFile)) {
|
|
169
|
+
files.push({
|
|
170
|
+
path: sqlFile,
|
|
171
|
+
sql: readFileSync3(sqlFile, "utf-8"),
|
|
172
|
+
order: i
|
|
173
|
+
});
|
|
174
|
+
if (existsSync2(join(entryDir, "down.sql"))) {
|
|
175
|
+
hasDown = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
format: "prisma",
|
|
181
|
+
files,
|
|
182
|
+
hasDownMigration: hasDown,
|
|
183
|
+
basePath: dirPath
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function isPrismaDirectory(dirPath) {
|
|
187
|
+
if (existsSync2(join(dirPath, "migration.sql"))) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const entries = readdirSync(dirPath);
|
|
192
|
+
return entries.some((entry) => {
|
|
193
|
+
const entryPath = join(dirPath, entry);
|
|
194
|
+
return statSync(entryPath).isDirectory() && existsSync2(join(entryPath, "migration.sql"));
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/detector/drizzle.ts
|
|
202
|
+
import { readFileSync as readFileSync4, existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
|
|
203
|
+
import { join as join2 } from "path";
|
|
204
|
+
function detectDrizzle(dirPath) {
|
|
205
|
+
const journalPath = join2(dirPath, "meta", "_journal.json");
|
|
206
|
+
const files = [];
|
|
207
|
+
if (existsSync3(journalPath)) {
|
|
208
|
+
const journal = JSON.parse(
|
|
209
|
+
readFileSync4(journalPath, "utf-8")
|
|
210
|
+
);
|
|
211
|
+
for (const entry of journal.entries) {
|
|
212
|
+
const sqlFile = join2(dirPath, `${entry.tag}.sql`);
|
|
213
|
+
if (existsSync3(sqlFile)) {
|
|
214
|
+
files.push({
|
|
215
|
+
path: sqlFile,
|
|
216
|
+
sql: readFileSync4(sqlFile, "utf-8"),
|
|
217
|
+
order: entry.idx
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
const sqlFiles = readdirSync2(dirPath).filter((f) => f.endsWith(".sql")).sort();
|
|
223
|
+
for (let i = 0; i < sqlFiles.length; i++) {
|
|
224
|
+
const filePath = join2(dirPath, sqlFiles[i]);
|
|
225
|
+
files.push({
|
|
226
|
+
path: filePath,
|
|
227
|
+
sql: readFileSync4(filePath, "utf-8"),
|
|
228
|
+
order: i
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
format: "drizzle",
|
|
234
|
+
files,
|
|
235
|
+
hasDownMigration: false,
|
|
236
|
+
// Drizzle doesn't natively support down migrations
|
|
237
|
+
basePath: dirPath
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function isDrizzleDirectory(dirPath) {
|
|
241
|
+
return existsSync3(join2(dirPath, "meta", "_journal.json"));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/detector/index.ts
|
|
245
|
+
function detectMigration(inputPath) {
|
|
246
|
+
const fullPath = resolve2(inputPath);
|
|
247
|
+
if (!existsSync4(fullPath)) {
|
|
248
|
+
throw new Error(`Path does not exist: ${fullPath}`);
|
|
249
|
+
}
|
|
250
|
+
const stat = statSync2(fullPath);
|
|
251
|
+
if (stat.isFile()) {
|
|
252
|
+
if (!fullPath.endsWith(".sql")) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Unsupported file type: ${fullPath}. Expected a .sql file.`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return detectRawSql(fullPath);
|
|
258
|
+
}
|
|
259
|
+
if (stat.isDirectory()) {
|
|
260
|
+
if (isPrismaDirectory(fullPath)) {
|
|
261
|
+
return detectPrisma(fullPath);
|
|
262
|
+
}
|
|
263
|
+
if (isDrizzleDirectory(fullPath)) {
|
|
264
|
+
return detectDrizzle(fullPath);
|
|
265
|
+
}
|
|
266
|
+
const entries = readdirSync3(fullPath);
|
|
267
|
+
const sqlFiles = entries.filter((f) => f.endsWith(".sql")).map((f) => join3(fullPath, f));
|
|
268
|
+
if (sqlFiles.length > 0) {
|
|
269
|
+
return detectRawSqlDirectory(sqlFiles);
|
|
270
|
+
}
|
|
271
|
+
throw new Error(
|
|
272
|
+
`No migration files found in ${fullPath}. Expected .sql files, a Prisma migration directory, or a Drizzle migration directory.`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
throw new Error(`Unsupported path type: ${fullPath}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/analyzer/rules/types.ts
|
|
279
|
+
function getStmtType(ast) {
|
|
280
|
+
if (!ast) return null;
|
|
281
|
+
const stmt = ast.RawStmt?.stmt || ast.stmt || ast;
|
|
282
|
+
const keys = Object.keys(stmt);
|
|
283
|
+
return keys.length > 0 ? keys[0] : null;
|
|
284
|
+
}
|
|
285
|
+
function getStmtNode(ast) {
|
|
286
|
+
if (!ast) return null;
|
|
287
|
+
const stmt = ast.RawStmt?.stmt || ast.stmt || ast;
|
|
288
|
+
const type = getStmtType(ast);
|
|
289
|
+
return type ? stmt[type] : null;
|
|
290
|
+
}
|
|
291
|
+
function sqlMatches(raw, pattern) {
|
|
292
|
+
return pattern.test(raw);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/analyzer/rules/create-index-not-concurrent.ts
|
|
296
|
+
var createIndexNotConcurrent = {
|
|
297
|
+
id: "MG001",
|
|
298
|
+
name: "create-index-not-concurrent",
|
|
299
|
+
severity: "error",
|
|
300
|
+
description: "CREATE INDEX without CONCURRENTLY locks the table for writes during index build",
|
|
301
|
+
check(statements, _context) {
|
|
302
|
+
const violations = [];
|
|
303
|
+
for (const stmt of statements) {
|
|
304
|
+
let isViolation = false;
|
|
305
|
+
if (stmt.ast) {
|
|
306
|
+
const type = getStmtType(stmt.ast);
|
|
307
|
+
if (type === "IndexStmt") {
|
|
308
|
+
const node = getStmtNode(stmt.ast);
|
|
309
|
+
if (node && !node.concurrent) {
|
|
310
|
+
isViolation = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
const pattern = /CREATE\s+INDEX\b(?!\s+CONCURRENTLY)/i;
|
|
315
|
+
const uniquePattern = /CREATE\s+UNIQUE\s+INDEX\b(?!\s+CONCURRENTLY)/i;
|
|
316
|
+
if ((sqlMatches(stmt.raw, pattern) || sqlMatches(stmt.raw, uniquePattern)) && !sqlMatches(stmt.raw, /CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY/i)) {
|
|
317
|
+
isViolation = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (isViolation) {
|
|
321
|
+
violations.push({
|
|
322
|
+
ruleId: "MG001",
|
|
323
|
+
ruleName: "create-index-not-concurrent",
|
|
324
|
+
severity: "error",
|
|
325
|
+
message: "CREATE INDEX without CONCURRENTLY acquires a SHARE lock on the table, blocking all INSERT, UPDATE, and DELETE operations for the entire duration of the index build. On a table with millions of rows, this can block writes for minutes or hours.",
|
|
326
|
+
line: stmt.line,
|
|
327
|
+
sql: stmt.raw,
|
|
328
|
+
suggestion: "Use CREATE INDEX CONCURRENTLY instead.\nNote: CONCURRENTLY cannot run inside a transaction block, so each index must be created in its own migration or with explicit transaction control."
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return violations;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// src/analyzer/rules/alter-column-type.ts
|
|
337
|
+
var alterColumnType = {
|
|
338
|
+
id: "MG002",
|
|
339
|
+
name: "alter-column-type",
|
|
340
|
+
severity: "error",
|
|
341
|
+
description: "ALTER COLUMN TYPE causes a full table rewrite with ACCESS EXCLUSIVE lock",
|
|
342
|
+
check(statements, _context) {
|
|
343
|
+
const violations = [];
|
|
344
|
+
for (const stmt of statements) {
|
|
345
|
+
let isViolation = false;
|
|
346
|
+
if (stmt.ast) {
|
|
347
|
+
const type = getStmtType(stmt.ast);
|
|
348
|
+
if (type === "AlterTableStmt") {
|
|
349
|
+
const node = getStmtNode(stmt.ast);
|
|
350
|
+
if (node?.cmds) {
|
|
351
|
+
for (const cmd of node.cmds) {
|
|
352
|
+
const alterCmd = cmd.AlterTableCmd;
|
|
353
|
+
if (alterCmd && (alterCmd.subtype === "AT_AlterColumnType" || alterCmd.subtype === 25)) {
|
|
354
|
+
isViolation = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
if (sqlMatches(stmt.raw, /ALTER\s+TABLE\b.*\bALTER\s+COLUMN\b.*\bTYPE\b/i)) {
|
|
361
|
+
isViolation = true;
|
|
362
|
+
}
|
|
363
|
+
if (sqlMatches(stmt.raw, /ALTER\s+TABLE\b.*\bSET\s+DATA\s+TYPE\b/i)) {
|
|
364
|
+
isViolation = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (isViolation) {
|
|
368
|
+
violations.push({
|
|
369
|
+
ruleId: "MG002",
|
|
370
|
+
ruleName: "alter-column-type",
|
|
371
|
+
severity: "error",
|
|
372
|
+
message: "ALTER COLUMN TYPE triggers a full table rewrite, acquiring an ACCESS EXCLUSIVE lock that blocks ALL operations (including SELECT) for the entire duration. On a table with millions of rows, this can cause minutes of complete downtime.",
|
|
373
|
+
line: stmt.line,
|
|
374
|
+
sql: stmt.raw,
|
|
375
|
+
suggestion: "Use the expand/contract pattern instead:\n1. Add a new column with the desired type\n2. Backfill data from the old column to the new one (in batches)\n3. Update application code to use the new column\n4. Drop the old column in a subsequent migration"
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return violations;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// src/analyzer/rules/add-column-not-null-no-default.ts
|
|
384
|
+
var addColumnNotNullNoDefault = {
|
|
385
|
+
id: "MG003",
|
|
386
|
+
name: "add-column-not-null-no-default",
|
|
387
|
+
severity: "error",
|
|
388
|
+
description: "ADD COLUMN NOT NULL without DEFAULT causes table rewrite or failure on non-empty tables",
|
|
389
|
+
check(statements, _context) {
|
|
390
|
+
const violations = [];
|
|
391
|
+
for (const stmt of statements) {
|
|
392
|
+
let isViolation = false;
|
|
393
|
+
if (stmt.ast) {
|
|
394
|
+
const type = getStmtType(stmt.ast);
|
|
395
|
+
if (type === "AlterTableStmt") {
|
|
396
|
+
const node = getStmtNode(stmt.ast);
|
|
397
|
+
if (node?.cmds) {
|
|
398
|
+
for (const cmd of node.cmds) {
|
|
399
|
+
const alterCmd = cmd.AlterTableCmd;
|
|
400
|
+
if (alterCmd && (alterCmd.subtype === "AT_AddColumn" || alterCmd.subtype === 0)) {
|
|
401
|
+
const colDef = alterCmd.def?.ColumnDef || alterCmd.def;
|
|
402
|
+
if (colDef) {
|
|
403
|
+
const hasNotNull = colDef.constraints?.some(
|
|
404
|
+
(c) => c.Constraint?.contype === "CONSTR_NOTNULL" || c.Constraint?.contype === 1
|
|
405
|
+
);
|
|
406
|
+
const hasDefault = colDef.constraints?.some(
|
|
407
|
+
(c) => c.Constraint?.contype === "CONSTR_DEFAULT" || c.Constraint?.contype === 2
|
|
408
|
+
);
|
|
409
|
+
if (hasNotNull && !hasDefault) {
|
|
410
|
+
isViolation = true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
if (sqlMatches(stmt.raw, /ADD\s+COLUMN\b.*\bNOT\s+NULL\b/i) && !sqlMatches(stmt.raw, /\bDEFAULT\b/i)) {
|
|
419
|
+
isViolation = true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (isViolation) {
|
|
423
|
+
violations.push({
|
|
424
|
+
ruleId: "MG003",
|
|
425
|
+
ruleName: "add-column-not-null-no-default",
|
|
426
|
+
severity: "error",
|
|
427
|
+
message: "Adding a NOT NULL column without a DEFAULT value will fail on any non-empty table (existing rows cannot satisfy the NOT NULL constraint). On PostgreSQL < 11, even with a DEFAULT, this triggers a full table rewrite. On PG 11+, constant DEFAULT values are metadata-only and safe.",
|
|
428
|
+
line: stmt.line,
|
|
429
|
+
sql: stmt.raw,
|
|
430
|
+
suggestion: "Safe approach (works on all PG versions):\n1. ADD COLUMN name type (nullable, no constraint)\n2. UPDATE table SET name = default_value WHERE name IS NULL (backfill in batches)\n3. ALTER COLUMN name SET NOT NULL\n\nOr on PG 11+:\n ADD COLUMN name type NOT NULL DEFAULT 'value'"
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return violations;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// src/analyzer/rules/add-constraint-not-valid.ts
|
|
439
|
+
var addConstraintNotValid = {
|
|
440
|
+
id: "MG004",
|
|
441
|
+
name: "add-constraint-not-valid",
|
|
442
|
+
severity: "warning",
|
|
443
|
+
description: "ADD CONSTRAINT without NOT VALID causes full table scan under heavy lock",
|
|
444
|
+
check(statements, _context) {
|
|
445
|
+
const violations = [];
|
|
446
|
+
for (const stmt of statements) {
|
|
447
|
+
const hasAddConstraint = sqlMatches(stmt.raw, /ADD\s+CONSTRAINT\b/i);
|
|
448
|
+
const hasCheckOrFK = sqlMatches(stmt.raw, /\bCHECK\s*\(/i) || sqlMatches(stmt.raw, /\bFOREIGN\s+KEY\b/i);
|
|
449
|
+
const hasNotValid = sqlMatches(stmt.raw, /\bNOT\s+VALID\b/i);
|
|
450
|
+
if (hasAddConstraint && hasCheckOrFK && !hasNotValid) {
|
|
451
|
+
violations.push({
|
|
452
|
+
ruleId: "MG004",
|
|
453
|
+
ruleName: "add-constraint-not-valid",
|
|
454
|
+
severity: "warning",
|
|
455
|
+
message: "Adding a CHECK or FOREIGN KEY constraint without NOT VALID causes PostgreSQL to scan and validate every existing row while holding an ACCESS EXCLUSIVE lock. On large tables, this blocks all access for the duration of the scan.",
|
|
456
|
+
line: stmt.line,
|
|
457
|
+
sql: stmt.raw,
|
|
458
|
+
suggestion: "Split into two steps:\n1. ALTER TABLE t ADD CONSTRAINT c CHECK (expr) NOT VALID;\n (instant, only applies to new/updated rows)\n2. ALTER TABLE t VALIDATE CONSTRAINT c;\n (validates existing rows with a weaker SHARE UPDATE EXCLUSIVE lock)"
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return violations;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// src/analyzer/rules/drop-column.ts
|
|
467
|
+
var dropColumn = {
|
|
468
|
+
id: "MG005",
|
|
469
|
+
name: "drop-column",
|
|
470
|
+
severity: "error",
|
|
471
|
+
description: "DROP COLUMN is destructive and irreversible \u2014 requires --confirm-destructive",
|
|
472
|
+
check(statements, _context) {
|
|
473
|
+
const violations = [];
|
|
474
|
+
for (const stmt of statements) {
|
|
475
|
+
let isViolation = false;
|
|
476
|
+
if (stmt.ast) {
|
|
477
|
+
const type = getStmtType(stmt.ast);
|
|
478
|
+
if (type === "AlterTableStmt") {
|
|
479
|
+
const node = getStmtNode(stmt.ast);
|
|
480
|
+
if (node?.cmds) {
|
|
481
|
+
for (const cmd of node.cmds) {
|
|
482
|
+
const alterCmd = cmd.AlterTableCmd;
|
|
483
|
+
if (alterCmd && (alterCmd.subtype === "AT_DropColumn" || alterCmd.subtype === 12)) {
|
|
484
|
+
isViolation = true;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
if (sqlMatches(stmt.raw, /ALTER\s+TABLE\b.*\bDROP\s+COLUMN\b/i)) {
|
|
491
|
+
isViolation = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (isViolation) {
|
|
495
|
+
violations.push({
|
|
496
|
+
ruleId: "MG005",
|
|
497
|
+
ruleName: "drop-column",
|
|
498
|
+
severity: "error",
|
|
499
|
+
message: "\u26A0\uFE0F DESTRUCTIVE: DROP COLUMN permanently removes the column and all its data. This operation is irreversible. Any application code still referencing this column will break immediately.",
|
|
500
|
+
line: stmt.line,
|
|
501
|
+
sql: stmt.raw,
|
|
502
|
+
suggestion: "Before dropping a column in production:\n1. Remove all application code references to the column first\n2. Deploy the code change\n3. Only then drop the column in a separate migration\n4. Use --confirm-destructive to proceed with this migration"
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return violations;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// src/analyzer/rules/drop-table.ts
|
|
511
|
+
var dropTable = {
|
|
512
|
+
id: "MG006",
|
|
513
|
+
name: "drop-table",
|
|
514
|
+
severity: "error",
|
|
515
|
+
description: "DROP TABLE is destructive and irreversible \u2014 requires --confirm-destructive",
|
|
516
|
+
check(statements, _context) {
|
|
517
|
+
const violations = [];
|
|
518
|
+
for (const stmt of statements) {
|
|
519
|
+
let isViolation = false;
|
|
520
|
+
if (stmt.ast) {
|
|
521
|
+
const type = getStmtType(stmt.ast);
|
|
522
|
+
if (type === "DropStmt") {
|
|
523
|
+
const node = getStmtNode(stmt.ast);
|
|
524
|
+
if (node && (node.removeType === "OBJECT_TABLE" || node.removeType === 38)) {
|
|
525
|
+
isViolation = true;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
if (sqlMatches(stmt.raw, /DROP\s+TABLE\b/i)) {
|
|
530
|
+
isViolation = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (isViolation) {
|
|
534
|
+
violations.push({
|
|
535
|
+
ruleId: "MG006",
|
|
536
|
+
ruleName: "drop-table",
|
|
537
|
+
severity: "error",
|
|
538
|
+
message: "\u26A0\uFE0F DESTRUCTIVE: DROP TABLE permanently deletes the table and ALL its data. This operation is irreversible. All foreign keys, indexes, triggers, and policies associated with this table will also be removed.",
|
|
539
|
+
line: stmt.line,
|
|
540
|
+
sql: stmt.raw,
|
|
541
|
+
suggestion: "Before dropping a table in production:\n1. Ensure no application code references this table\n2. Consider renaming the table first (e.g., _deprecated_tablename)\n3. Wait a safe period to confirm nothing breaks\n4. Back up the data if it might be needed later\n5. Use --confirm-destructive to proceed"
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return violations;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/analyzer/rules/rename-column.ts
|
|
550
|
+
var renameColumn = {
|
|
551
|
+
id: "MG007",
|
|
552
|
+
name: "rename-column",
|
|
553
|
+
severity: "warning",
|
|
554
|
+
description: "RENAME COLUMN is a breaking change for zero-downtime deployments",
|
|
555
|
+
check(statements, _context) {
|
|
556
|
+
const violations = [];
|
|
557
|
+
for (const stmt of statements) {
|
|
558
|
+
let isViolation = false;
|
|
559
|
+
if (stmt.ast) {
|
|
560
|
+
const type = getStmtType(stmt.ast);
|
|
561
|
+
if (type === "RenameStmt") {
|
|
562
|
+
const node = getStmtNode(stmt.ast);
|
|
563
|
+
if (node && (node.renameType === "OBJECT_COLUMN" || node.renameType === 3)) {
|
|
564
|
+
isViolation = true;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
if (sqlMatches(stmt.raw, /ALTER\s+TABLE\b.*\bRENAME\s+COLUMN\b/i)) {
|
|
569
|
+
isViolation = true;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (isViolation) {
|
|
573
|
+
violations.push({
|
|
574
|
+
ruleId: "MG007",
|
|
575
|
+
ruleName: "rename-column",
|
|
576
|
+
severity: "warning",
|
|
577
|
+
message: "Renaming a column is a breaking change for zero-downtime deployments. Any running application code, ORM queries, or reports referencing the old column name will fail immediately after the rename is applied.",
|
|
578
|
+
line: stmt.line,
|
|
579
|
+
sql: stmt.raw,
|
|
580
|
+
suggestion: "For zero-downtime rename, use the expand/contract pattern:\n1. Add a new column with the desired name\n2. Write to both old and new columns in application code\n3. Backfill existing data to the new column\n4. Switch reads to the new column\n5. Stop writing to the old column\n6. Drop the old column in a subsequent migration"
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return violations;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// src/analyzer/rules/rename-table.ts
|
|
589
|
+
var renameTable = {
|
|
590
|
+
id: "MG008",
|
|
591
|
+
name: "rename-table",
|
|
592
|
+
severity: "warning",
|
|
593
|
+
description: "RENAME TABLE is a breaking change for zero-downtime deployments",
|
|
594
|
+
check(statements, _context) {
|
|
595
|
+
const violations = [];
|
|
596
|
+
for (const stmt of statements) {
|
|
597
|
+
let isViolation = false;
|
|
598
|
+
if (stmt.ast) {
|
|
599
|
+
const type = getStmtType(stmt.ast);
|
|
600
|
+
if (type === "RenameStmt") {
|
|
601
|
+
const node = getStmtNode(stmt.ast);
|
|
602
|
+
if (node && (node.renameType === "OBJECT_TABLE" || node.renameType === 38)) {
|
|
603
|
+
isViolation = true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
} else {
|
|
607
|
+
if (sqlMatches(stmt.raw, /ALTER\s+TABLE\b.*\bRENAME\s+TO\b/i)) {
|
|
608
|
+
isViolation = true;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (isViolation) {
|
|
612
|
+
violations.push({
|
|
613
|
+
ruleId: "MG008",
|
|
614
|
+
ruleName: "rename-table",
|
|
615
|
+
severity: "warning",
|
|
616
|
+
message: "Renaming a table is a breaking change for zero-downtime deployments. All application code, views, foreign keys, and queries referencing the old table name will fail immediately after the rename.",
|
|
617
|
+
line: stmt.line,
|
|
618
|
+
sql: stmt.raw,
|
|
619
|
+
suggestion: "For zero-downtime rename:\n1. Create a new table with the desired name\n2. Create a VIEW with the old name pointing to the new table\n3. Migrate application code to use the new table name\n4. Drop the view and old table in a subsequent migration"
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return violations;
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// src/analyzer/rules/missing-lock-timeout.ts
|
|
628
|
+
var missingLockTimeout = {
|
|
629
|
+
id: "MG009",
|
|
630
|
+
name: "missing-lock-timeout",
|
|
631
|
+
severity: "warning",
|
|
632
|
+
description: "No lock_timeout or statement_timeout set before risky operations",
|
|
633
|
+
check(statements, _context) {
|
|
634
|
+
const violations = [];
|
|
635
|
+
const hasLockTimeout = statements.some(
|
|
636
|
+
(s) => sqlMatches(s.raw, /SET\s+(LOCAL\s+)?lock_timeout/i)
|
|
637
|
+
);
|
|
638
|
+
const hasStatementTimeout = statements.some(
|
|
639
|
+
(s) => sqlMatches(s.raw, /SET\s+(LOCAL\s+)?statement_timeout/i)
|
|
640
|
+
);
|
|
641
|
+
if (hasLockTimeout || hasStatementTimeout) {
|
|
642
|
+
return violations;
|
|
643
|
+
}
|
|
644
|
+
const riskyPatterns = [
|
|
645
|
+
/CREATE\s+INDEX\b/i,
|
|
646
|
+
/ALTER\s+TABLE\b/i,
|
|
647
|
+
/DROP\s+INDEX\b/i
|
|
648
|
+
];
|
|
649
|
+
const hasRiskyOp = statements.some(
|
|
650
|
+
(s) => riskyPatterns.some((p) => sqlMatches(s.raw, p))
|
|
651
|
+
);
|
|
652
|
+
if (hasRiskyOp) {
|
|
653
|
+
violations.push({
|
|
654
|
+
ruleId: "MG009",
|
|
655
|
+
ruleName: "missing-lock-timeout",
|
|
656
|
+
severity: "warning",
|
|
657
|
+
message: "This migration contains DDL operations that acquire locks but does not set a lock_timeout. Without a lock_timeout, if another transaction holds a conflicting lock, your migration will wait indefinitely \u2014 and all subsequent queries will queue behind it, potentially causing a cascading outage (lock queue death spiral).",
|
|
658
|
+
line: 1,
|
|
659
|
+
sql: _context.fullSql.substring(0, 200) + "...",
|
|
660
|
+
suggestion: "Add a lock timeout at the beginning of your migration:\n SET lock_timeout = '5s';\n\nThis causes the migration to fail fast if it cannot acquire the lock within 5 seconds, rather than waiting indefinitely and blocking other queries. You can retry the migration during a quieter period."
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return violations;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// src/analyzer/rules/missing-down-migration.ts
|
|
668
|
+
var missingDownMigration = {
|
|
669
|
+
id: "MG010",
|
|
670
|
+
name: "missing-down-migration",
|
|
671
|
+
severity: "warning",
|
|
672
|
+
description: "No rollback/down migration detected",
|
|
673
|
+
check(_statements, context) {
|
|
674
|
+
const violations = [];
|
|
675
|
+
if (context.format === "drizzle") {
|
|
676
|
+
return violations;
|
|
677
|
+
}
|
|
678
|
+
if (!context.hasDownMigration) {
|
|
679
|
+
violations.push({
|
|
680
|
+
ruleId: "MG010",
|
|
681
|
+
ruleName: "missing-down-migration",
|
|
682
|
+
severity: "warning",
|
|
683
|
+
message: "No rollback/down migration was detected. Without a rollback migration, reverting this change in production requires manual intervention. This is especially risky for schema changes that are difficult to reverse (e.g., column type changes).",
|
|
684
|
+
line: 1,
|
|
685
|
+
sql: "(no down migration file found)",
|
|
686
|
+
suggestion: "Create a corresponding rollback migration:\n\u2022 For Prisma: Add a down.sql file in the same migration directory\n\u2022 For raw SQL: Create a corresponding *_down.sql or *_rollback.sql file\n\nThe rollback should reverse the changes made by this migration. For example:\n Up: ALTER TABLE users ADD COLUMN status TEXT;\n Down: ALTER TABLE users DROP COLUMN status;"
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
return violations;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// src/analyzer/rules/index.ts
|
|
694
|
+
var ALL_RULES = [
|
|
695
|
+
createIndexNotConcurrent,
|
|
696
|
+
// MG001
|
|
697
|
+
alterColumnType,
|
|
698
|
+
// MG002
|
|
699
|
+
addColumnNotNullNoDefault,
|
|
700
|
+
// MG003
|
|
701
|
+
addConstraintNotValid,
|
|
702
|
+
// MG004
|
|
703
|
+
dropColumn,
|
|
704
|
+
// MG005
|
|
705
|
+
dropTable,
|
|
706
|
+
// MG006
|
|
707
|
+
renameColumn,
|
|
708
|
+
// MG007
|
|
709
|
+
renameTable,
|
|
710
|
+
// MG008
|
|
711
|
+
missingLockTimeout,
|
|
712
|
+
// MG009
|
|
713
|
+
missingDownMigration
|
|
714
|
+
// MG010
|
|
715
|
+
];
|
|
716
|
+
var DESTRUCTIVE_RULE_IDS = /* @__PURE__ */ new Set(["MG005", "MG006"]);
|
|
717
|
+
|
|
718
|
+
// src/analyzer/parser.ts
|
|
719
|
+
async function parseSql(rawSql) {
|
|
720
|
+
const statements = [];
|
|
721
|
+
try {
|
|
722
|
+
const { parse } = await import("pgsql-parser");
|
|
723
|
+
const result = await parse(rawSql);
|
|
724
|
+
const stmtList = Array.isArray(result) ? result : result?.stmts || [];
|
|
725
|
+
if (stmtList.length > 0) {
|
|
726
|
+
for (let i = 0; i < stmtList.length; i++) {
|
|
727
|
+
const stmtObj = stmtList[i];
|
|
728
|
+
const loc = stmtObj.stmt_location || 0;
|
|
729
|
+
const len = stmtObj.stmt_len && stmtObj.stmt_len > 0 ? stmtObj.stmt_len : rawSql.length - loc;
|
|
730
|
+
const raw = rawSql.substring(loc, loc + len).trim();
|
|
731
|
+
const line = rawSql.substring(0, loc).split("\n").length;
|
|
732
|
+
statements.push({
|
|
733
|
+
raw,
|
|
734
|
+
line,
|
|
735
|
+
ast: stmtObj
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return statements;
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
741
|
+
const rawStatements = splitStatements(rawSql);
|
|
742
|
+
for (let i = 0; i < rawStatements.length; i++) {
|
|
743
|
+
const raw = rawStatements[i].trim();
|
|
744
|
+
if (!raw) continue;
|
|
745
|
+
statements.push({
|
|
746
|
+
raw,
|
|
747
|
+
line: findLineNumber(rawSql, rawStatements[i]),
|
|
748
|
+
ast: null
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return statements;
|
|
752
|
+
}
|
|
753
|
+
const fallbackStatements = splitStatements(rawSql);
|
|
754
|
+
for (const rawStmt of fallbackStatements) {
|
|
755
|
+
const raw = rawStmt.trim();
|
|
756
|
+
if (!raw) continue;
|
|
757
|
+
statements.push({
|
|
758
|
+
raw,
|
|
759
|
+
line: findLineNumber(rawSql, rawStmt),
|
|
760
|
+
ast: null
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
return statements;
|
|
764
|
+
}
|
|
765
|
+
function splitStatements(sql) {
|
|
766
|
+
const statements = [];
|
|
767
|
+
let current = "";
|
|
768
|
+
let inSingleQuote = false;
|
|
769
|
+
let inDoubleQuote = false;
|
|
770
|
+
let inDollarQuote = false;
|
|
771
|
+
let dollarTag = "";
|
|
772
|
+
let inLineComment = false;
|
|
773
|
+
let inBlockComment = false;
|
|
774
|
+
for (let i = 0; i < sql.length; i++) {
|
|
775
|
+
const char = sql[i];
|
|
776
|
+
const next = sql[i + 1] || "";
|
|
777
|
+
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && !inBlockComment) {
|
|
778
|
+
if (char === "-" && next === "-") {
|
|
779
|
+
inLineComment = true;
|
|
780
|
+
current += char;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (inLineComment) {
|
|
785
|
+
current += char;
|
|
786
|
+
if (char === "\n") {
|
|
787
|
+
inLineComment = false;
|
|
788
|
+
}
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote) {
|
|
792
|
+
if (char === "/" && next === "*") {
|
|
793
|
+
inBlockComment = true;
|
|
794
|
+
current += char;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (inBlockComment) {
|
|
799
|
+
current += char;
|
|
800
|
+
if (char === "*" && next === "/") {
|
|
801
|
+
current += next;
|
|
802
|
+
i++;
|
|
803
|
+
inBlockComment = false;
|
|
804
|
+
}
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (!inSingleQuote && !inDoubleQuote && char === "$") {
|
|
808
|
+
if (inDollarQuote) {
|
|
809
|
+
const remaining = sql.substring(i);
|
|
810
|
+
if (remaining.startsWith(dollarTag)) {
|
|
811
|
+
current += dollarTag;
|
|
812
|
+
i += dollarTag.length - 1;
|
|
813
|
+
inDollarQuote = false;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
} else {
|
|
817
|
+
const match = sql.substring(i).match(/^(\$[^$]*\$)/);
|
|
818
|
+
if (match) {
|
|
819
|
+
dollarTag = match[1];
|
|
820
|
+
current += dollarTag;
|
|
821
|
+
i += dollarTag.length - 1;
|
|
822
|
+
inDollarQuote = true;
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (inDollarQuote) {
|
|
828
|
+
current += char;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (char === "'" && !inDoubleQuote) {
|
|
832
|
+
inSingleQuote = !inSingleQuote;
|
|
833
|
+
current += char;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (inSingleQuote) {
|
|
837
|
+
current += char;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (char === '"' && !inSingleQuote) {
|
|
841
|
+
inDoubleQuote = !inDoubleQuote;
|
|
842
|
+
current += char;
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (inDoubleQuote) {
|
|
846
|
+
current += char;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (char === ";") {
|
|
850
|
+
current += char;
|
|
851
|
+
if (current.trim()) {
|
|
852
|
+
statements.push(current);
|
|
853
|
+
}
|
|
854
|
+
current = "";
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
current += char;
|
|
858
|
+
}
|
|
859
|
+
if (current.trim()) {
|
|
860
|
+
statements.push(current);
|
|
861
|
+
}
|
|
862
|
+
return statements;
|
|
863
|
+
}
|
|
864
|
+
function findLineNumber(fullSql, substring) {
|
|
865
|
+
const trimmed = substring.trim();
|
|
866
|
+
const index = fullSql.indexOf(trimmed);
|
|
867
|
+
if (index === -1) return 1;
|
|
868
|
+
const before = fullSql.substring(0, index);
|
|
869
|
+
return before.split("\n").length;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/analyzer/index.ts
|
|
873
|
+
async function analyze(migration, config) {
|
|
874
|
+
const fullSql = migration.files.map((f) => f.sql).join("\n");
|
|
875
|
+
const statements = await parseSql(fullSql);
|
|
876
|
+
const context = {
|
|
877
|
+
hasDownMigration: migration.hasDownMigration,
|
|
878
|
+
format: migration.format,
|
|
879
|
+
fullSql
|
|
880
|
+
};
|
|
881
|
+
const violations = [];
|
|
882
|
+
let hasDestructive = false;
|
|
883
|
+
for (const rule of ALL_RULES) {
|
|
884
|
+
const effectiveSeverity = getRuleSeverity(rule.id, rule.severity, config);
|
|
885
|
+
if (effectiveSeverity === null) {
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const ruleViolations = rule.check(statements, context);
|
|
889
|
+
for (const violation of ruleViolations) {
|
|
890
|
+
violation.severity = effectiveSeverity;
|
|
891
|
+
violations.push(violation);
|
|
892
|
+
if (DESTRUCTIVE_RULE_IDS.has(violation.ruleId)) {
|
|
893
|
+
hasDestructive = true;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
violations,
|
|
899
|
+
hasDestructive,
|
|
900
|
+
statements: statements.map((s) => ({ line: s.line, raw: s.raw }))
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/shadow/docker-postgres.ts
|
|
905
|
+
import Dockerode from "dockerode";
|
|
906
|
+
import pg from "pg";
|
|
907
|
+
var { Client } = pg;
|
|
908
|
+
var DockerPostgresShadow = class {
|
|
909
|
+
docker;
|
|
910
|
+
container = null;
|
|
911
|
+
connectionConfig = null;
|
|
912
|
+
externalDbUrl = null;
|
|
913
|
+
imageName;
|
|
914
|
+
constructor(imageName = "postgres:16-alpine", externalDbUrl) {
|
|
915
|
+
this.docker = new Dockerode();
|
|
916
|
+
this.imageName = imageName;
|
|
917
|
+
this.externalDbUrl = externalDbUrl || null;
|
|
918
|
+
}
|
|
919
|
+
async create() {
|
|
920
|
+
if (this.externalDbUrl) {
|
|
921
|
+
const url = new URL(this.externalDbUrl);
|
|
922
|
+
this.connectionConfig = {
|
|
923
|
+
host: url.hostname,
|
|
924
|
+
port: parseInt(url.port || "5432", 10),
|
|
925
|
+
database: url.pathname.slice(1) || "postgres",
|
|
926
|
+
user: url.username || "postgres",
|
|
927
|
+
password: url.password || "postgres"
|
|
928
|
+
};
|
|
929
|
+
return this.connectionConfig;
|
|
930
|
+
}
|
|
931
|
+
try {
|
|
932
|
+
await this.docker.getImage(this.imageName).inspect();
|
|
933
|
+
} catch {
|
|
934
|
+
console.error(`Pulling ${this.imageName}...`);
|
|
935
|
+
await new Promise((resolve4, reject) => {
|
|
936
|
+
this.docker.pull(this.imageName, (err, stream) => {
|
|
937
|
+
if (err) return reject(err);
|
|
938
|
+
this.docker.modem.followProgress(stream, (err2) => {
|
|
939
|
+
if (err2) return reject(err2);
|
|
940
|
+
resolve4();
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
const password = "mgcheck_shadow_" + Math.random().toString(36).substring(7);
|
|
946
|
+
const containerName = `mgcheck-shadow-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
947
|
+
this.container = await this.docker.createContainer({
|
|
948
|
+
Image: this.imageName,
|
|
949
|
+
name: containerName,
|
|
950
|
+
Env: [
|
|
951
|
+
`POSTGRES_PASSWORD=${password}`,
|
|
952
|
+
"POSTGRES_DB=mgcheck_shadow",
|
|
953
|
+
"POSTGRES_USER=mgcheck"
|
|
954
|
+
],
|
|
955
|
+
HostConfig: {
|
|
956
|
+
AutoRemove: true,
|
|
957
|
+
PortBindings: {
|
|
958
|
+
"5432/tcp": [{ HostPort: "0" }]
|
|
959
|
+
// Random port
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
ExposedPorts: {
|
|
963
|
+
"5432/tcp": {}
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
await this.container.start();
|
|
967
|
+
const info = await this.container.inspect();
|
|
968
|
+
const hostPort = info.NetworkSettings.Ports["5432/tcp"]?.[0]?.HostPort;
|
|
969
|
+
if (!hostPort) {
|
|
970
|
+
throw new Error("Failed to get assigned port for shadow database container");
|
|
971
|
+
}
|
|
972
|
+
this.connectionConfig = {
|
|
973
|
+
host: "127.0.0.1",
|
|
974
|
+
port: parseInt(hostPort, 10),
|
|
975
|
+
database: "mgcheck_shadow",
|
|
976
|
+
user: "mgcheck",
|
|
977
|
+
password
|
|
978
|
+
};
|
|
979
|
+
await this.waitForReady();
|
|
980
|
+
return this.connectionConfig;
|
|
981
|
+
}
|
|
982
|
+
async seed(sql) {
|
|
983
|
+
if (!this.connectionConfig) {
|
|
984
|
+
throw new Error("Shadow database not created yet. Call create() first.");
|
|
985
|
+
}
|
|
986
|
+
const client = new Client(this.connectionConfig);
|
|
987
|
+
try {
|
|
988
|
+
await client.connect();
|
|
989
|
+
await client.query(sql);
|
|
990
|
+
} finally {
|
|
991
|
+
await client.end();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async execute(sql) {
|
|
995
|
+
if (!this.connectionConfig) {
|
|
996
|
+
throw new Error("Shadow database not created yet. Call create() first.");
|
|
997
|
+
}
|
|
998
|
+
const client = new Client(this.connectionConfig);
|
|
999
|
+
const startTime = Date.now();
|
|
1000
|
+
let statementsExecuted = 0;
|
|
1001
|
+
try {
|
|
1002
|
+
await client.connect();
|
|
1003
|
+
const result = await client.query(sql);
|
|
1004
|
+
statementsExecuted = Array.isArray(result) ? result.length : 1;
|
|
1005
|
+
return {
|
|
1006
|
+
success: true,
|
|
1007
|
+
executionTimeMs: Date.now() - startTime,
|
|
1008
|
+
statementsExecuted
|
|
1009
|
+
};
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
return {
|
|
1012
|
+
success: false,
|
|
1013
|
+
error: err.message,
|
|
1014
|
+
executionTimeMs: Date.now() - startTime,
|
|
1015
|
+
statementsExecuted
|
|
1016
|
+
};
|
|
1017
|
+
} finally {
|
|
1018
|
+
await client.end();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
async teardown() {
|
|
1022
|
+
if (this.container) {
|
|
1023
|
+
try {
|
|
1024
|
+
await this.container.stop({ t: 2 });
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
await this.container.remove({ force: true });
|
|
1029
|
+
} catch {
|
|
1030
|
+
}
|
|
1031
|
+
this.container = null;
|
|
1032
|
+
}
|
|
1033
|
+
this.connectionConfig = null;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Wait for Postgres to accept connections with exponential backoff.
|
|
1037
|
+
* Timeout: 30 seconds.
|
|
1038
|
+
*/
|
|
1039
|
+
async waitForReady(timeoutMs = 3e4) {
|
|
1040
|
+
if (!this.connectionConfig) throw new Error("No connection config");
|
|
1041
|
+
const startTime = Date.now();
|
|
1042
|
+
let delay = 200;
|
|
1043
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
1044
|
+
const client = new Client(this.connectionConfig);
|
|
1045
|
+
try {
|
|
1046
|
+
await client.connect();
|
|
1047
|
+
await client.query("SELECT 1");
|
|
1048
|
+
await client.end();
|
|
1049
|
+
return;
|
|
1050
|
+
} catch {
|
|
1051
|
+
await client.end().catch(() => {
|
|
1052
|
+
});
|
|
1053
|
+
await new Promise((resolve4) => setTimeout(resolve4, delay));
|
|
1054
|
+
delay = Math.min(delay * 1.5, 2e3);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
`Shadow database failed to become ready within ${timeoutMs / 1e3}s. Ensure Docker is running and the Postgres image is accessible.`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
// src/shadow/pglite.ts
|
|
1064
|
+
var PGliteShadow = class {
|
|
1065
|
+
db = null;
|
|
1066
|
+
async create() {
|
|
1067
|
+
const { PGlite } = await import("@electric-sql/pglite");
|
|
1068
|
+
this.db = new PGlite();
|
|
1069
|
+
return {
|
|
1070
|
+
host: "pglite-in-process",
|
|
1071
|
+
port: 0,
|
|
1072
|
+
database: "pglite",
|
|
1073
|
+
user: "pglite",
|
|
1074
|
+
password: ""
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
async seed(sql) {
|
|
1078
|
+
if (!this.db) {
|
|
1079
|
+
throw new Error("PGlite database not created yet. Call create() first.");
|
|
1080
|
+
}
|
|
1081
|
+
await this.db.exec(sql);
|
|
1082
|
+
}
|
|
1083
|
+
async execute(sql) {
|
|
1084
|
+
if (!this.db) {
|
|
1085
|
+
throw new Error("PGlite database not created yet. Call create() first.");
|
|
1086
|
+
}
|
|
1087
|
+
const startTime = Date.now();
|
|
1088
|
+
try {
|
|
1089
|
+
const results = await this.db.exec(sql);
|
|
1090
|
+
const statementsExecuted = Array.isArray(results) ? results.length : 1;
|
|
1091
|
+
return {
|
|
1092
|
+
success: true,
|
|
1093
|
+
executionTimeMs: Date.now() - startTime,
|
|
1094
|
+
statementsExecuted
|
|
1095
|
+
};
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
const errorMessage = err.message;
|
|
1098
|
+
if (errorMessage.includes("CONCURRENTLY")) {
|
|
1099
|
+
return {
|
|
1100
|
+
success: false,
|
|
1101
|
+
error: errorMessage + "\n\nNote: PGlite does not support CONCURRENTLY operations. Use --provider docker for full Postgres fidelity.",
|
|
1102
|
+
executionTimeMs: Date.now() - startTime,
|
|
1103
|
+
statementsExecuted: 0
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
success: false,
|
|
1108
|
+
error: errorMessage,
|
|
1109
|
+
executionTimeMs: Date.now() - startTime,
|
|
1110
|
+
statementsExecuted: 0
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async teardown() {
|
|
1115
|
+
if (this.db) {
|
|
1116
|
+
try {
|
|
1117
|
+
await this.db.close();
|
|
1118
|
+
} catch {
|
|
1119
|
+
}
|
|
1120
|
+
this.db = null;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
// src/shadow/index.ts
|
|
1126
|
+
function createShadowDatabase(config) {
|
|
1127
|
+
if (config.shadowDb.dbUrl) {
|
|
1128
|
+
return new DockerPostgresShadow(config.shadowDb.dockerImage, config.shadowDb.dbUrl);
|
|
1129
|
+
}
|
|
1130
|
+
switch (config.shadowDb.provider) {
|
|
1131
|
+
case "docker":
|
|
1132
|
+
return new DockerPostgresShadow(config.shadowDb.dockerImage);
|
|
1133
|
+
case "pglite":
|
|
1134
|
+
return new PGliteShadow();
|
|
1135
|
+
default:
|
|
1136
|
+
throw new Error(`Unknown shadow DB provider: ${config.shadowDb.provider}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/core/engine.ts
|
|
1141
|
+
import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
|
|
1142
|
+
import { resolve as resolve3 } from "path";
|
|
1143
|
+
async function runCheck(inputPath, config, mode = "run", onProgress) {
|
|
1144
|
+
const log = onProgress || (() => {
|
|
1145
|
+
});
|
|
1146
|
+
log("Detecting migration format...");
|
|
1147
|
+
const migration = detectMigration(inputPath);
|
|
1148
|
+
log(`Detected: ${migration.format} (${migration.files.length} file(s))`);
|
|
1149
|
+
log("Running rule analysis...");
|
|
1150
|
+
const analysis = await analyze(migration, config);
|
|
1151
|
+
log(`Parsed ${analysis.statements.length} SQL statement(s):`);
|
|
1152
|
+
for (const s of analysis.statements) {
|
|
1153
|
+
const singleLine = s.raw.replace(/\s+/g, " ").trim();
|
|
1154
|
+
const truncated = singleLine.length > 70 ? singleLine.substring(0, 67) + "..." : singleLine;
|
|
1155
|
+
log(` \u2022 [Line ${s.line}] ${truncated}`);
|
|
1156
|
+
}
|
|
1157
|
+
log(`Rule analysis completed: ${analysis.violations.length} violation(s) found`);
|
|
1158
|
+
let execution = null;
|
|
1159
|
+
if (mode === "run") {
|
|
1160
|
+
const shadow = createShadowDatabase(config);
|
|
1161
|
+
try {
|
|
1162
|
+
log(`Starting shadow database (${config.shadowDb.provider})...`);
|
|
1163
|
+
await shadow.create();
|
|
1164
|
+
log("Shadow database ready");
|
|
1165
|
+
if (config.shadowDb.seedFile) {
|
|
1166
|
+
const seedPath = resolve3(config.shadowDb.seedFile);
|
|
1167
|
+
if (!existsSync5(seedPath)) {
|
|
1168
|
+
throw new Error(`Seed file not found: ${seedPath}`);
|
|
1169
|
+
}
|
|
1170
|
+
const seedSql = readFileSync5(seedPath, "utf-8");
|
|
1171
|
+
log("Applying seed schema...");
|
|
1172
|
+
await shadow.seed(seedSql);
|
|
1173
|
+
log("Seed schema applied");
|
|
1174
|
+
}
|
|
1175
|
+
const fullSql = migration.files.map((f) => f.sql).join("\n");
|
|
1176
|
+
log(`Executing migration against shadow database (${analysis.statements.length} statement(s))...`);
|
|
1177
|
+
execution = await shadow.execute(fullSql);
|
|
1178
|
+
log(execution.success ? "Migration executed successfully" : `Migration execution failed: ${execution.error}`);
|
|
1179
|
+
} finally {
|
|
1180
|
+
log("Tearing down shadow database...");
|
|
1181
|
+
await shadow.teardown();
|
|
1182
|
+
log("Shadow database cleaned up");
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
const hasBlockingViolations = analysis.violations.some((v) => v.severity === "error");
|
|
1186
|
+
const executionFailed = execution !== null && !execution.success;
|
|
1187
|
+
const hasUnconfirmedDestructive = analysis.hasDestructive && !config.confirmDestructive;
|
|
1188
|
+
const passed = !executionFailed && !hasBlockingViolations && !hasUnconfirmedDestructive;
|
|
1189
|
+
const report = {
|
|
1190
|
+
migration,
|
|
1191
|
+
execution,
|
|
1192
|
+
violations: analysis.violations,
|
|
1193
|
+
statements: analysis.statements,
|
|
1194
|
+
passed,
|
|
1195
|
+
hasDestructive: analysis.hasDestructive,
|
|
1196
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1197
|
+
mode
|
|
1198
|
+
};
|
|
1199
|
+
return report;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export {
|
|
1203
|
+
loadConfig,
|
|
1204
|
+
getRuleSeverity,
|
|
1205
|
+
detectMigration,
|
|
1206
|
+
ALL_RULES,
|
|
1207
|
+
DESTRUCTIVE_RULE_IDS,
|
|
1208
|
+
analyze,
|
|
1209
|
+
createShadowDatabase,
|
|
1210
|
+
runCheck
|
|
1211
|
+
};
|
|
1212
|
+
//# sourceMappingURL=chunk-7JBFSZBD.js.map
|