migraguard 0.3.1 → 0.4.1
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/COMMANDS.md +21 -1
- package/README.md +28 -10
- package/dist/cli.js +317 -44
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +21 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/COMMANDS.md
CHANGED
|
@@ -64,7 +64,27 @@ migraguard check
|
|
|
64
64
|
|
|
65
65
|
### `migraguard lint`
|
|
66
66
|
|
|
67
|
-
Run
|
|
67
|
+
Run built-in safety rules on all migration files. Rules use libpg-query AST analysis — no external tools required.
|
|
68
|
+
|
|
69
|
+
Rules (all enabled by default):
|
|
70
|
+
- `require-if-not-exists` — CREATE/DROP must use IF NOT EXISTS / IF EXISTS
|
|
71
|
+
- `require-concurrent-index` — CREATE INDEX must use CONCURRENTLY (skipped for tables created in the same file)
|
|
72
|
+
- `require-lock-timeout` — SET lock_timeout must appear before DDL statements
|
|
73
|
+
- `ban-concurrent-index-in-transaction` — CONCURRENTLY cannot be inside BEGIN...COMMIT
|
|
74
|
+
- `adding-not-nullable-field` — NOT NULL column must have a DEFAULT value
|
|
75
|
+
- `constraint-missing-not-valid` — ADD CONSTRAINT must use NOT VALID
|
|
76
|
+
|
|
77
|
+
Disable specific rules or add custom rules via config:
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"lint": {
|
|
81
|
+
"rules": { "require-lock-timeout": false },
|
|
82
|
+
"customRulesDir": "lint-rules"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Custom rule files (`.js` / `.mjs`) in the specified directory are loaded automatically. Each file must default-export a `LintRule` object. See README for an example.
|
|
68
88
|
|
|
69
89
|
```bash
|
|
70
90
|
migraguard lint
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# migraguard
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/migraguard) [](https://opensource.org/licenses/MIT)
|
|
4
|
+
|
|
3
5
|
An incident-prevention migration tool for PostgreSQL. Enforces safe operational policies via CI gates and DB state tracking, so that common migration accidents are structurally impossible.
|
|
4
6
|
|
|
5
7
|
**Prevented accidents:**
|
|
@@ -48,11 +50,7 @@ npx migraguard dump
|
|
|
48
50
|
- **One release = one file**: Migration files are squashed into a single file before release, simplifying error recovery. In DAG mode, independent DDL can be released individually
|
|
49
51
|
- **Parallel releases via dependency tree**: DDL dependencies are analyzed to build a DAG, enabling parallel releases for independent changes
|
|
50
52
|
- **Shift verification left**: Linting, checksum-based tamper detection, and schema dump diffs run at the PR stage
|
|
51
|
-
- **Minimal footprint**:
|
|
52
|
-
- `psql` — executes migration SQL
|
|
53
|
-
- `pg_dump` — produces schema dumps for drift detection
|
|
54
|
-
- [Squawk](https://squawkhq.com/) — lints SQL for safety (optional)
|
|
55
|
-
- [libpg-query](https://github.com/pganalyze/libpg-query) — parses DDL for dependency analysis (DAG model)
|
|
53
|
+
- **Minimal footprint**: Two CLI tools (`psql`, `pg_dump`) and one npm library ([libpg-query](https://github.com/pganalyze/libpg-query)). No external linter required — lint rules are built in via AST analysis
|
|
56
54
|
|
|
57
55
|
## Core Concepts
|
|
58
56
|
|
|
@@ -157,7 +155,7 @@ See [docs/state-model.md](docs/state-model.md) for detailed apply, check, resolv
|
|
|
157
155
|
| `status` | Display migration status per file |
|
|
158
156
|
| `editable` | List currently editable files (tail / leaf) |
|
|
159
157
|
| `check` | Verify file integrity via metadata.json (no DB required) |
|
|
160
|
-
| `lint` |
|
|
158
|
+
| `lint` | Run built-in safety rules (AST-based) |
|
|
161
159
|
| `verify` / `verify --all` | Prove idempotency on shadow DB |
|
|
162
160
|
| `dump` | Save normalized schema dump |
|
|
163
161
|
| `diff` | Show schema diff (DB vs saved dump) |
|
|
@@ -242,7 +240,14 @@ jobs:
|
|
|
242
240
|
"excludePrivileges": true
|
|
243
241
|
},
|
|
244
242
|
"lint": {
|
|
245
|
-
"
|
|
243
|
+
"rules": {
|
|
244
|
+
"require-concurrent-index": true,
|
|
245
|
+
"require-if-not-exists": true,
|
|
246
|
+
"require-lock-timeout": true,
|
|
247
|
+
"ban-concurrent-index-in-transaction": true,
|
|
248
|
+
"adding-not-nullable-field": true,
|
|
249
|
+
"constraint-missing-not-valid": true
|
|
250
|
+
}
|
|
246
251
|
}
|
|
247
252
|
}
|
|
248
253
|
```
|
|
@@ -309,7 +314,20 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
|
|
|
309
314
|
UPDATE users SET status = 'active' WHERE status IS NULL;
|
|
310
315
|
```
|
|
311
316
|
|
|
312
|
-
|
|
317
|
+
`migraguard lint` enforces these patterns with built-in rules (no external tools required):
|
|
318
|
+
|
|
319
|
+
| Rule | Detects |
|
|
320
|
+
|------|---------|
|
|
321
|
+
| `require-if-not-exists` | CREATE/DROP without IF NOT EXISTS / IF EXISTS |
|
|
322
|
+
| `require-concurrent-index` | CREATE INDEX without CONCURRENTLY on existing tables |
|
|
323
|
+
| `require-lock-timeout` | DDL without prior SET lock_timeout |
|
|
324
|
+
| `ban-concurrent-index-in-transaction` | CONCURRENTLY inside BEGIN...COMMIT |
|
|
325
|
+
| `adding-not-nullable-field` | NOT NULL column without DEFAULT |
|
|
326
|
+
| `constraint-missing-not-valid` | ADD CONSTRAINT without NOT VALID |
|
|
327
|
+
|
|
328
|
+
Rules are enabled by default and can be disabled per-rule in `migraguard.config.json` under `lint.rules`.
|
|
329
|
+
|
|
330
|
+
Project-specific rules can be added via `lint.customRulesDir`. See [docs/safe-ddl.md](docs/safe-ddl.md) for built-in rule details and custom rule examples.
|
|
313
331
|
|
|
314
332
|
## Directory Structure
|
|
315
333
|
|
|
@@ -422,8 +440,8 @@ No. `verify` creates a temporary shadow DB, applies migrations twice, then drops
|
|
|
422
440
|
| Language | TypeScript (Node.js) |
|
|
423
441
|
| DB execution | `psql` CLI |
|
|
424
442
|
| Schema dump | `pg_dump --schema-only` |
|
|
425
|
-
| SQL lint | [
|
|
426
|
-
| SQL parser | [libpg-query](https://github.com/pganalyze/libpg-query) (PostgreSQL real parser WASM build
|
|
443
|
+
| SQL lint | Built-in rules via [libpg-query](https://github.com/pganalyze/libpg-query) AST analysis |
|
|
444
|
+
| SQL parser | [libpg-query](https://github.com/pganalyze/libpg-query) (PostgreSQL real parser WASM build) |
|
|
427
445
|
| Package manager | npm |
|
|
428
446
|
|
|
429
447
|
## Detailed Documentation
|
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk6 from 'chalk';
|
|
3
|
+
import { createRequire } from 'module';
|
|
3
4
|
import { readFile, mkdir, writeFile, unlink, readdir } from 'fs/promises';
|
|
4
5
|
import { dirname, resolve, join } from 'path';
|
|
5
6
|
import { existsSync } from 'fs';
|
|
6
7
|
import { randomBytes, createHash } from 'crypto';
|
|
7
8
|
import libpg from 'libpg-query';
|
|
9
|
+
import { pathToFileURL } from 'url';
|
|
10
|
+
import pg from 'pg';
|
|
8
11
|
import { execFile } from 'child_process';
|
|
9
12
|
import { promisify } from 'util';
|
|
10
|
-
import pg from 'pg';
|
|
11
13
|
import { tmpdir } from 'os';
|
|
12
14
|
|
|
13
15
|
// src/cli/index.ts
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
var VERSION =
|
|
16
|
+
var require2 = createRequire(import.meta.url);
|
|
17
|
+
var pkg = require2("../package.json");
|
|
18
|
+
var VERSION = pkg.version;
|
|
17
19
|
var CONFIG_FILE_NAME = "migraguard.config.json";
|
|
18
20
|
var DEFAULT_NAMING = {
|
|
19
21
|
pattern: "{timestamp}__{description}.sql",
|
|
@@ -33,7 +35,14 @@ var DEFAULT_DUMP = {
|
|
|
33
35
|
excludePrivileges: true
|
|
34
36
|
};
|
|
35
37
|
var DEFAULT_LINT = {
|
|
36
|
-
|
|
38
|
+
rules: {
|
|
39
|
+
"require-concurrent-index": true,
|
|
40
|
+
"require-if-not-exists": true,
|
|
41
|
+
"require-lock-timeout": true,
|
|
42
|
+
"ban-concurrent-index-in-transaction": true,
|
|
43
|
+
"adding-not-nullable-field": true,
|
|
44
|
+
"constraint-missing-not-valid": true
|
|
45
|
+
}
|
|
37
46
|
};
|
|
38
47
|
function applyEnvOverrides(connection) {
|
|
39
48
|
return {
|
|
@@ -81,7 +90,11 @@ function buildConfig(raw, configDir) {
|
|
|
81
90
|
naming: { ...DEFAULT_NAMING, ...raw.naming },
|
|
82
91
|
connection: applyEnvOverrides(connection),
|
|
83
92
|
dump: { ...DEFAULT_DUMP, ...raw.dump },
|
|
84
|
-
lint: {
|
|
93
|
+
lint: {
|
|
94
|
+
...DEFAULT_LINT,
|
|
95
|
+
...raw.lint,
|
|
96
|
+
rules: { ...DEFAULT_LINT.rules, ...raw.lint?.rules }
|
|
97
|
+
}
|
|
85
98
|
};
|
|
86
99
|
}
|
|
87
100
|
async function loadConfig(startDir) {
|
|
@@ -1007,51 +1020,311 @@ function findConnectedComponents(newFiles, graph) {
|
|
|
1007
1020
|
}
|
|
1008
1021
|
return components;
|
|
1009
1022
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1023
|
+
async function runRules(sql, rules) {
|
|
1024
|
+
const violations = [];
|
|
1025
|
+
let stmts;
|
|
1012
1026
|
try {
|
|
1013
|
-
await
|
|
1014
|
-
|
|
1027
|
+
const ast = await libpg.parse(sql);
|
|
1028
|
+
stmts = ast.stmts;
|
|
1015
1029
|
} catch {
|
|
1016
|
-
return
|
|
1030
|
+
return violations;
|
|
1017
1031
|
}
|
|
1032
|
+
const visitors = [];
|
|
1033
|
+
for (const rule of rules) {
|
|
1034
|
+
visitors.push({ ruleId: rule.id, handlers: rule.create() });
|
|
1035
|
+
}
|
|
1036
|
+
const createdTables = /* @__PURE__ */ new Set();
|
|
1037
|
+
let lockTimeoutSet = false;
|
|
1038
|
+
let inTransaction = false;
|
|
1039
|
+
for (const { stmt } of stmts) {
|
|
1040
|
+
const s = stmt;
|
|
1041
|
+
if ("VariableSetStmt" in s) {
|
|
1042
|
+
const name = s.VariableSetStmt.name;
|
|
1043
|
+
if (name === "lock_timeout") lockTimeoutSet = true;
|
|
1044
|
+
}
|
|
1045
|
+
if ("TransactionStmt" in s) {
|
|
1046
|
+
const kind = s.TransactionStmt.kind;
|
|
1047
|
+
if (kind === "TRANS_STMT_BEGIN") inTransaction = true;
|
|
1048
|
+
else if (kind === "TRANS_STMT_COMMIT" || kind === "TRANS_STMT_ROLLBACK") inTransaction = false;
|
|
1049
|
+
}
|
|
1050
|
+
if ("CreateStmt" in s) {
|
|
1051
|
+
const rel = s.CreateStmt.relation;
|
|
1052
|
+
if (rel?.relname) createdTables.add(rel.relname);
|
|
1053
|
+
}
|
|
1054
|
+
const ctx = {
|
|
1055
|
+
report: null,
|
|
1056
|
+
createdTables,
|
|
1057
|
+
lockTimeoutSet,
|
|
1058
|
+
inTransaction
|
|
1059
|
+
};
|
|
1060
|
+
for (const key of Object.keys(s)) {
|
|
1061
|
+
const node = s[key];
|
|
1062
|
+
for (const { ruleId, handlers } of visitors) {
|
|
1063
|
+
const handler = handlers[key];
|
|
1064
|
+
if (!handler) continue;
|
|
1065
|
+
ctx.report = (v) => violations.push({ rule: ruleId, ...v });
|
|
1066
|
+
handler(node, ctx);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return violations;
|
|
1018
1071
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1072
|
+
|
|
1073
|
+
// src/rules/require-concurrent-index.ts
|
|
1074
|
+
var requireConcurrentIndex = {
|
|
1075
|
+
id: "require-concurrent-index",
|
|
1076
|
+
description: "CREATE INDEX must use CONCURRENTLY on existing tables",
|
|
1077
|
+
create() {
|
|
1078
|
+
return {
|
|
1079
|
+
IndexStmt(node, ctx) {
|
|
1080
|
+
const rel = node.relation;
|
|
1081
|
+
const tableName = rel?.relname ?? "(unknown)";
|
|
1082
|
+
const isNewTable = ctx.createdTables.has(tableName);
|
|
1083
|
+
if (!node.concurrent && !isNewTable) {
|
|
1084
|
+
ctx.report({
|
|
1085
|
+
message: `CREATE INDEX on "${tableName}" without CONCURRENTLY`,
|
|
1086
|
+
hint: "Use CREATE INDEX CONCURRENTLY to avoid blocking writes"
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1023
1091
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// src/rules/require-if-not-exists.ts
|
|
1095
|
+
var requireIfNotExists = {
|
|
1096
|
+
id: "require-if-not-exists",
|
|
1097
|
+
description: "CREATE must use IF NOT EXISTS, DROP must use IF EXISTS",
|
|
1098
|
+
create() {
|
|
1099
|
+
return {
|
|
1100
|
+
CreateStmt(node, ctx) {
|
|
1101
|
+
if (!node.if_not_exists) {
|
|
1102
|
+
const rel = node.relation;
|
|
1103
|
+
ctx.report({
|
|
1104
|
+
message: `CREATE TABLE ${rel?.relname ?? "(unknown)"} without IF NOT EXISTS`,
|
|
1105
|
+
hint: "Use CREATE TABLE IF NOT EXISTS for idempotent migrations"
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
IndexStmt(node, ctx) {
|
|
1110
|
+
if (!node.if_not_exists) {
|
|
1111
|
+
ctx.report({
|
|
1112
|
+
message: "CREATE INDEX without IF NOT EXISTS",
|
|
1113
|
+
hint: "Use CREATE INDEX ... IF NOT EXISTS for idempotent migrations"
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
},
|
|
1117
|
+
DropStmt(node, ctx) {
|
|
1118
|
+
if (!node.missing_ok) {
|
|
1119
|
+
ctx.report({
|
|
1120
|
+
message: "DROP without IF EXISTS",
|
|
1121
|
+
hint: "Use DROP ... IF EXISTS for idempotent migrations"
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
// src/rules/require-lock-timeout.ts
|
|
1130
|
+
var requireLockTimeout = {
|
|
1131
|
+
id: "require-lock-timeout",
|
|
1132
|
+
description: "SET lock_timeout must appear before DDL statements",
|
|
1133
|
+
create() {
|
|
1134
|
+
let flagged = false;
|
|
1135
|
+
function checkTimeout(ctx) {
|
|
1136
|
+
if (!ctx.lockTimeoutSet && !flagged) {
|
|
1137
|
+
flagged = true;
|
|
1138
|
+
ctx.report({
|
|
1139
|
+
message: "DDL appears before SET lock_timeout",
|
|
1140
|
+
hint: "Add SET lock_timeout = '5s'; before DDL statements"
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return {
|
|
1145
|
+
CreateStmt(_node, ctx) {
|
|
1146
|
+
checkTimeout(ctx);
|
|
1147
|
+
},
|
|
1148
|
+
IndexStmt(node, ctx) {
|
|
1149
|
+
if (!node.concurrent) checkTimeout(ctx);
|
|
1150
|
+
},
|
|
1151
|
+
AlterTableStmt(_node, ctx) {
|
|
1152
|
+
checkTimeout(ctx);
|
|
1153
|
+
},
|
|
1154
|
+
DropStmt(_node, ctx) {
|
|
1155
|
+
checkTimeout(ctx);
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1029
1158
|
}
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
// src/rules/ban-concurrent-index-in-transaction.ts
|
|
1162
|
+
var banConcurrentIndexInTransaction = {
|
|
1163
|
+
id: "ban-concurrent-index-in-transaction",
|
|
1164
|
+
description: "CREATE INDEX CONCURRENTLY cannot run inside a transaction",
|
|
1165
|
+
create() {
|
|
1166
|
+
return {
|
|
1167
|
+
IndexStmt(node, ctx) {
|
|
1168
|
+
if (node.concurrent && ctx.inTransaction) {
|
|
1169
|
+
ctx.report({
|
|
1170
|
+
message: "CREATE INDEX CONCURRENTLY inside a transaction",
|
|
1171
|
+
hint: "Remove BEGIN/COMMIT \u2014 CONCURRENTLY cannot run inside a transaction block"
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
// src/rules/adding-not-nullable-field.ts
|
|
1180
|
+
var addingNotNullableField = {
|
|
1181
|
+
id: "adding-not-nullable-field",
|
|
1182
|
+
description: "Adding a NOT NULL column requires a DEFAULT value",
|
|
1183
|
+
create() {
|
|
1184
|
+
return {
|
|
1185
|
+
AlterTableStmt(node, ctx) {
|
|
1186
|
+
const cmds = node.cmds;
|
|
1187
|
+
if (!cmds) return;
|
|
1188
|
+
for (const cmd of cmds) {
|
|
1189
|
+
const alterCmd = cmd.AlterTableCmd;
|
|
1190
|
+
if (!alterCmd) continue;
|
|
1191
|
+
const subtype = alterCmd.subtype;
|
|
1192
|
+
const isAddColumn = subtype === "AT_AddColumn" || subtype === 0;
|
|
1193
|
+
if (!isAddColumn) continue;
|
|
1194
|
+
const colDef = alterCmd.def;
|
|
1195
|
+
if (!colDef?.ColumnDef) continue;
|
|
1196
|
+
const col = colDef.ColumnDef;
|
|
1197
|
+
const constraints = col.constraints;
|
|
1198
|
+
if (!constraints) continue;
|
|
1199
|
+
let hasNotNull = false;
|
|
1200
|
+
let hasDefault = false;
|
|
1201
|
+
for (const c of constraints) {
|
|
1202
|
+
const constr = c.Constraint;
|
|
1203
|
+
if (!constr) continue;
|
|
1204
|
+
if (constr.contype === "CONSTR_NOTNULL") hasNotNull = true;
|
|
1205
|
+
if (constr.contype === "CONSTR_DEFAULT") hasDefault = true;
|
|
1206
|
+
}
|
|
1207
|
+
if (hasNotNull && !hasDefault) {
|
|
1208
|
+
const colname = col.colname;
|
|
1209
|
+
ctx.report({
|
|
1210
|
+
message: `Adding NOT NULL column "${colname ?? "(unknown)"}" without DEFAULT`,
|
|
1211
|
+
hint: "Add a DEFAULT value or add the column as nullable first, then backfill, then set NOT NULL"
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// src/rules/constraint-missing-not-valid.ts
|
|
1221
|
+
var constraintMissingNotValid = {
|
|
1222
|
+
id: "constraint-missing-not-valid",
|
|
1223
|
+
description: "ADD CONSTRAINT should use NOT VALID to avoid full table scan",
|
|
1224
|
+
create() {
|
|
1225
|
+
return {
|
|
1226
|
+
AlterTableStmt(node, ctx) {
|
|
1227
|
+
const cmds = node.cmds;
|
|
1228
|
+
if (!cmds) return;
|
|
1229
|
+
for (const cmd of cmds) {
|
|
1230
|
+
const alterCmd = cmd.AlterTableCmd;
|
|
1231
|
+
if (!alterCmd) continue;
|
|
1232
|
+
const subtype = alterCmd.subtype;
|
|
1233
|
+
const isAddConstraint = subtype === "AT_AddConstraint" || subtype === 14;
|
|
1234
|
+
if (!isAddConstraint) continue;
|
|
1235
|
+
const def = alterCmd.def;
|
|
1236
|
+
if (!def?.Constraint) continue;
|
|
1237
|
+
const constr = def.Constraint;
|
|
1238
|
+
const contype = constr.contype;
|
|
1239
|
+
const needsNotValid = contype === "CONSTR_FOREIGN" || contype === "CONSTR_CHECK";
|
|
1240
|
+
if (!needsNotValid) continue;
|
|
1241
|
+
if (!constr.skip_validation) {
|
|
1242
|
+
const conname = constr.conname;
|
|
1243
|
+
ctx.report({
|
|
1244
|
+
message: `ADD CONSTRAINT ${conname ? `"${conname}" ` : ""}without NOT VALID`,
|
|
1245
|
+
hint: "Use NOT VALID to avoid a full table scan that blocks writes, then VALIDATE CONSTRAINT separately"
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
// src/rules/index.ts
|
|
1255
|
+
var ALL_RULES = [
|
|
1256
|
+
requireConcurrentIndex,
|
|
1257
|
+
requireIfNotExists,
|
|
1258
|
+
requireLockTimeout,
|
|
1259
|
+
banConcurrentIndexInTransaction,
|
|
1260
|
+
addingNotNullableField,
|
|
1261
|
+
constraintMissingNotValid
|
|
1262
|
+
];
|
|
1263
|
+
|
|
1264
|
+
// src/commands/lint.ts
|
|
1265
|
+
async function loadCustomRules(config) {
|
|
1266
|
+
const dir = config.lint.customRulesDir;
|
|
1267
|
+
if (!dir) return [];
|
|
1268
|
+
const absDir = resolveFromConfig(config, dir);
|
|
1269
|
+
let entries;
|
|
1270
|
+
try {
|
|
1271
|
+
entries = await readdir(absDir);
|
|
1272
|
+
} catch {
|
|
1273
|
+
return [];
|
|
1274
|
+
}
|
|
1275
|
+
const rules = [];
|
|
1276
|
+
for (const entry of entries) {
|
|
1277
|
+
if (!entry.endsWith(".js") && !entry.endsWith(".mjs")) continue;
|
|
1278
|
+
const filePath = resolve(absDir, entry);
|
|
1279
|
+
try {
|
|
1280
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
1281
|
+
const rule = mod.default ?? mod;
|
|
1282
|
+
if (rule && typeof rule.id === "string" && typeof rule.create === "function") {
|
|
1283
|
+
rules.push(rule);
|
|
1284
|
+
}
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
console.error(chalk6.yellow(`Warning: failed to load custom rule from ${entry}: ${err.message}`));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return rules;
|
|
1290
|
+
}
|
|
1291
|
+
async function commandLint(config) {
|
|
1030
1292
|
const files = await scanMigrations(config);
|
|
1031
1293
|
if (files.length === 0) {
|
|
1032
1294
|
console.log(chalk6.yellow("No migration files to lint."));
|
|
1033
|
-
return { ok: true, filesLinted: 0 };
|
|
1295
|
+
return { ok: true, filesLinted: 0, violations: 0 };
|
|
1296
|
+
}
|
|
1297
|
+
const customRules = await loadCustomRules(config);
|
|
1298
|
+
const allRules = [...ALL_RULES, ...customRules];
|
|
1299
|
+
const enabledRules = allRules.filter((r) => config.lint.rules[r.id] !== false);
|
|
1300
|
+
if (enabledRules.length === 0) {
|
|
1301
|
+
console.log(chalk6.yellow("All lint rules are disabled."));
|
|
1302
|
+
return { ok: true, filesLinted: files.length, violations: 0 };
|
|
1034
1303
|
}
|
|
1035
|
-
let
|
|
1304
|
+
let totalViolations = 0;
|
|
1036
1305
|
for (const f of files) {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
console.error(chalk6.red(`
|
|
1043
|
-
\u2717 ${f.fileName}:`));
|
|
1044
|
-
if (execErr.stdout) console.error(execErr.stdout);
|
|
1045
|
-
if (execErr.stderr) console.error(execErr.stderr);
|
|
1306
|
+
const sql = await readFile(f.filePath, "utf-8");
|
|
1307
|
+
const violations = await runRules(sql, enabledRules);
|
|
1308
|
+
if (violations.length > 0) {
|
|
1309
|
+
totalViolations += violations.length;
|
|
1310
|
+
printViolations(f.fileName, violations);
|
|
1046
1311
|
}
|
|
1047
1312
|
}
|
|
1048
|
-
if (
|
|
1313
|
+
if (totalViolations > 0) {
|
|
1049
1314
|
console.error(chalk6.red(`
|
|
1050
|
-
Lint failed.`));
|
|
1315
|
+
Lint failed: ${totalViolations} violation(s).`));
|
|
1051
1316
|
} else {
|
|
1052
1317
|
console.log(chalk6.green(`\u2713 ${files.length} file(s) passed lint.`));
|
|
1053
1318
|
}
|
|
1054
|
-
return { ok:
|
|
1319
|
+
return { ok: totalViolations === 0, filesLinted: files.length, violations: totalViolations };
|
|
1320
|
+
}
|
|
1321
|
+
function printViolations(fileName, violations) {
|
|
1322
|
+
console.error(chalk6.red(`
|
|
1323
|
+
\u2717 ${fileName}:`));
|
|
1324
|
+
for (const v of violations) {
|
|
1325
|
+
console.error(chalk6.red(` [${v.rule}] ${v.message}`));
|
|
1326
|
+
console.error(chalk6.gray(` hint: ${v.hint}`));
|
|
1327
|
+
}
|
|
1055
1328
|
}
|
|
1056
1329
|
var { Client } = pg;
|
|
1057
1330
|
var ADVISORY_LOCK_KEY = "migraguard-apply";
|
|
@@ -1228,7 +1501,7 @@ async function commandEditable(config) {
|
|
|
1228
1501
|
}
|
|
1229
1502
|
return { editableFiles, entries };
|
|
1230
1503
|
}
|
|
1231
|
-
var
|
|
1504
|
+
var execFileAsync = promisify(execFile);
|
|
1232
1505
|
function buildPsqlEnv(config) {
|
|
1233
1506
|
const env = { ...process.env };
|
|
1234
1507
|
env["PGHOST"] = config.connection.host;
|
|
@@ -1243,7 +1516,7 @@ function buildPsqlEnv(config) {
|
|
|
1243
1516
|
async function executePsqlFile(config, filePath) {
|
|
1244
1517
|
const env = buildPsqlEnv(config);
|
|
1245
1518
|
try {
|
|
1246
|
-
const { stdout, stderr } = await
|
|
1519
|
+
const { stdout, stderr } = await execFileAsync(
|
|
1247
1520
|
"psql",
|
|
1248
1521
|
["-v", "ON_ERROR_STOP=1", "-f", filePath],
|
|
1249
1522
|
{ env }
|
|
@@ -1258,7 +1531,7 @@ async function executePsqlFile(config, filePath) {
|
|
|
1258
1531
|
};
|
|
1259
1532
|
}
|
|
1260
1533
|
}
|
|
1261
|
-
var
|
|
1534
|
+
var execFileAsync2 = promisify(execFile);
|
|
1262
1535
|
function buildPgDumpEnv(config) {
|
|
1263
1536
|
const env = { ...process.env };
|
|
1264
1537
|
env["PGHOST"] = config.connection.host;
|
|
@@ -1278,11 +1551,11 @@ async function dumpSchema(config) {
|
|
|
1278
1551
|
let stdout;
|
|
1279
1552
|
if (pgDumpCmd && pgDumpCmd.length > 0) {
|
|
1280
1553
|
const [cmd, ...baseArgs] = pgDumpCmd;
|
|
1281
|
-
const { stdout: out } = await
|
|
1554
|
+
const { stdout: out } = await execFileAsync2(cmd, [...baseArgs, ...dumpArgs]);
|
|
1282
1555
|
stdout = out;
|
|
1283
1556
|
} else {
|
|
1284
1557
|
const env = buildPgDumpEnv(config);
|
|
1285
|
-
const { stdout: out } = await
|
|
1558
|
+
const { stdout: out } = await execFileAsync2("pg_dump", dumpArgs, { env });
|
|
1286
1559
|
stdout = out;
|
|
1287
1560
|
}
|
|
1288
1561
|
if (config.dump.normalize) {
|
|
@@ -1682,7 +1955,7 @@ async function commandDiff(config) {
|
|
|
1682
1955
|
return { identical: false, diff };
|
|
1683
1956
|
}
|
|
1684
1957
|
var { Client: Client2 } = pg;
|
|
1685
|
-
var
|
|
1958
|
+
var execFileAsync3 = promisify(execFile);
|
|
1686
1959
|
function shadowDbName() {
|
|
1687
1960
|
const suffix = randomBytes(4).toString("hex");
|
|
1688
1961
|
return `migraguard_shadow_${suffix}`;
|
|
@@ -1732,11 +2005,11 @@ async function dumpSourceToShadow(config, shadowName) {
|
|
|
1732
2005
|
let dumpOutput;
|
|
1733
2006
|
if (pgDumpCmd && pgDumpCmd.length > 0) {
|
|
1734
2007
|
const [cmd, ...baseArgs] = pgDumpCmd;
|
|
1735
|
-
const { stdout } = await
|
|
2008
|
+
const { stdout } = await execFileAsync3(cmd, [...baseArgs, "--no-owner", "--no-privileges"]);
|
|
1736
2009
|
dumpOutput = stdout;
|
|
1737
2010
|
} else {
|
|
1738
2011
|
env["PGDATABASE"] = conn.database;
|
|
1739
|
-
const { stdout } = await
|
|
2012
|
+
const { stdout } = await execFileAsync3("pg_dump", ["--no-owner", "--no-privileges"], { env });
|
|
1740
2013
|
dumpOutput = stdout;
|
|
1741
2014
|
}
|
|
1742
2015
|
const tmpFile = join(tmpdir(), `migraguard-dump-${randomBytes(4).toString("hex")}.sql`);
|
|
@@ -1744,7 +2017,7 @@ async function dumpSourceToShadow(config, shadowName) {
|
|
|
1744
2017
|
try {
|
|
1745
2018
|
const restoreEnv = buildEnv(conn);
|
|
1746
2019
|
restoreEnv["PGDATABASE"] = shadowName;
|
|
1747
|
-
await
|
|
2020
|
+
await execFileAsync3("psql", ["-v", "ON_ERROR_STOP=1", "-f", tmpFile], {
|
|
1748
2021
|
env: restoreEnv,
|
|
1749
2022
|
maxBuffer: 50 * 1024 * 1024
|
|
1750
2023
|
});
|
|
@@ -2202,7 +2475,7 @@ program.command("squash").description("Squash multiple new migration files into
|
|
|
2202
2475
|
const config = await loadConfig();
|
|
2203
2476
|
await commandSquash(config);
|
|
2204
2477
|
}));
|
|
2205
|
-
program.command("lint").description("Run
|
|
2478
|
+
program.command("lint").description("Run built-in safety rules on migration files").action(() => run(async () => {
|
|
2206
2479
|
const config = await loadConfig();
|
|
2207
2480
|
const result = await commandLint(config);
|
|
2208
2481
|
if (!result.ok) process.exit(1);
|