-
Notifications
You must be signed in to change notification settings - Fork 269
Expand file tree
/
Copy pathvalidate-migrations.js
More file actions
147 lines (132 loc) · 5.05 KB
/
Copy pathvalidate-migrations.js
File metadata and controls
147 lines (132 loc) · 5.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
* CI check: migrations must not open their own connections (issue #1211).
*
* TypeORM's default `migrationsTransactionMode` is `all` — every migration in
* a run shares a single transaction. A migration that opens its own pooled
* connection via `createQueryRunner()` (or reaches the connection through
* `queryRunner.connection`) cannot see tables/rows created by earlier
* migrations in the same run, and its changes escape the shared transaction
* (they are NOT rolled back if a later migration fails).
*
* This was the root cause of the `fix-invoice-number-sequence` failure
* resolved in #1195: a fresh connection queried the `invoices` table before
* the migration that creates it had committed, so the run crashed from scratch
* on every fresh database.
*
* Rule: a migration must only use the `QueryRunner` passed to its `up()` /
* `down()` method.
*
* Usage: node scripts/validate-migrations.js [path ...]
* Exit code 0 = all pass, 1 = violations.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const SRC_DIR = path.join(ROOT, 'src');
// Directories whose `.ts` files are TypeORM migration classes. Mirrors the
// `migrations` glob in src/config/datasource.ts (`src/migrations/[0-9]*`) plus
// the migration dirs of feature modules that follow the same convention.
const MIGRATION_DIRS = [
path.join(SRC_DIR, 'migrations'),
path.join(SRC_DIR, 'achievements', 'migrations'),
path.join(SRC_DIR, 'notifications', 'migrations'),
];
// Whether a file inside a migration dir is a migration class.
// - src/migrations: basename starts with a digit (TypeORM's timestamp
// convention), matching the `migrations` glob in src/config/datasource.ts.
// Non-migration helpers that live there too (services, entities) are
// excluded — runtime service code legitimately opens query runners.
// - feature-module migration dirs (achievements, notifications): every .ts
// file is a migration, regardless of naming.
const DIGIT_PREFIX = /^[0-9].*\.(ts|js)$/;
const ANY_MIGRATION = /\.(ts|js)$/;
function isMigrationFile(absolutePath, dir) {
const regex = dir === path.join(SRC_DIR, 'migrations') ? DIGIT_PREFIX : ANY_MIGRATION;
return regex.test(path.basename(absolutePath));
}
function findMigrationFiles(dir) {
let results = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (entry.isFile()) {
const fullPath = path.join(dir, entry.name);
if (isMigrationFile(fullPath, dir)) {
results.push(fullPath);
}
}
}
return results;
}
// Patterns that open a separate pooled connection or reach outside the
// transaction the migration runner gave us.
const FORBIDDEN_PATTERNS = [
{
name: 'createQueryRunner()',
// queryRunner.connection.createQueryRunner(), dataSource.createQueryRunner(), ...
regex: /\bcreateQueryRunner\s*\(/g,
},
{
name: 'queryRunner.connection',
// queryRunner.connection / queryRunner.manager.connection — a direct
// escape hatch off the shared transaction.
regex: /\.connection\b/g,
},
];
function violationsFor(file) {
const content = fs.readFileSync(file, 'utf-8');
const lines = content.split('\n');
const violations = [];
for (const pattern of FORBIDDEN_PATTERNS) {
let match;
while ((match = pattern.regex.exec(content)) !== null) {
const lineNumber = content.slice(0, match.index).split('\n').length;
const line = lines[lineNumber - 1].trim();
violations.push({ pattern: pattern.name, lineNumber, line });
// Guard against zero-length matches causing an infinite loop
if (match.index === pattern.regex.lastIndex) {
pattern.regex.lastIndex += 1;
}
}
}
return violations;
}
let exitCode = 0;
// Allow explicit paths (useful for manual checks); default to the migration dirs.
const cliTargets = process.argv.slice(2);
const targets = cliTargets.length > 0 ? cliTargets : MIGRATION_DIRS;
const files = [];
for (const target of targets) {
const absolute = path.isAbsolute(target) ? target : path.resolve(ROOT, target);
if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
files.push(...findMigrationFiles(absolute));
} else if (fs.existsSync(absolute) && fs.statSync(absolute).isFile()) {
files.push(absolute);
}
}
if (files.length === 0) {
console.error('ERROR: No migration files found!');
process.exit(1);
}
for (const file of files) {
const relativePath = path.relative(ROOT, file);
const violations = violationsFor(file);
for (const violation of violations) {
console.error(
`FAIL: ${relativePath}:${violation.lineNumber} — opens a separate connection via '${violation.pattern}':`,
);
console.error(` ${violation.line}`);
console.error(
` Migrations run in one shared transaction; use the QueryRunner passed to up()/down() instead.`,
);
exitCode = 1;
}
}
if (exitCode === 0) {
console.log(`PASS: All ${files.length} migration files use only the passed queryRunner`);
}
process.exit(exitCode);