managarten/scripts/validate-migrations.mjs
Wuesteon 9e771c9ae2 🔧 chore(auth): improve migration safety and docker setup
- Add safe-db-push.mjs script for safer database migrations
- Update docker-entrypoint.sh with db:push fallback when migrations fail
- Add validate-migrations.mjs script for CI migration validation
- Update CI workflow to use migration validation
- Update drizzle.config.ts with improved configuration
2025-12-19 02:18:31 +01:00

204 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Migration Validation Script
*
* Scans migration files for destructive SQL patterns and fails CI if found.
* This prevents accidental data loss in production deployments.
*
* Usage:
* node scripts/validate-migrations.mjs [--allow-destructive]
*
* Destructive patterns detected:
* - DROP TABLE
* - DROP COLUMN
* - DROP INDEX (without CONCURRENTLY)
* - DROP SCHEMA
* - TRUNCATE
* - DELETE FROM (without WHERE)
*
* Safe patterns (allowed):
* - DROP ... IF EXISTS (only creates if not exists)
* - CREATE ... IF NOT EXISTS
* - ALTER TABLE ... ADD COLUMN
* - CREATE INDEX CONCURRENTLY
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
import { join } from 'path';
// Configuration
const MIGRATION_DIRS = [
'services/mana-core-auth/src/db/migrations',
// Add other services as needed
];
// Destructive patterns to detect
const DESTRUCTIVE_PATTERNS = [
{
pattern: /DROP\s+TABLE(?!\s+IF\s+EXISTS)/gi,
message: 'DROP TABLE without IF EXISTS - will fail if table does not exist and is destructive',
severity: 'error',
},
{
pattern: /DROP\s+TABLE\s+IF\s+EXISTS\s+(?!.*CASCADE\s*;)/gi,
message: 'DROP TABLE IF EXISTS - THIS WILL DELETE DATA',
severity: 'error',
},
{
pattern: /DROP\s+TABLE.*CASCADE/gi,
message: 'DROP TABLE CASCADE - THIS WILL DELETE DATA AND DEPENDENT OBJECTS',
severity: 'error',
},
{
pattern: /ALTER\s+TABLE\s+\S+\s+DROP\s+COLUMN/gi,
message: 'DROP COLUMN - THIS WILL DELETE DATA',
severity: 'error',
},
{
pattern: /DROP\s+SCHEMA(?!\s+IF\s+EXISTS)/gi,
message: 'DROP SCHEMA without IF EXISTS',
severity: 'error',
},
{
pattern: /DROP\s+SCHEMA\s+IF\s+EXISTS.*CASCADE/gi,
message: 'DROP SCHEMA CASCADE - THIS WILL DELETE ALL TABLES IN SCHEMA',
severity: 'error',
},
{
pattern: /TRUNCATE\s+(?:TABLE\s+)?/gi,
message: 'TRUNCATE - THIS WILL DELETE ALL DATA IN TABLE',
severity: 'error',
},
{
pattern: /DELETE\s+FROM\s+\S+\s*(?:;|$)/gim,
message: 'DELETE FROM without WHERE clause - THIS WILL DELETE ALL DATA',
severity: 'error',
},
{
pattern: /DROP\s+INDEX(?!\s+CONCURRENTLY)/gi,
message: 'DROP INDEX without CONCURRENTLY - may cause table locks',
severity: 'warning',
},
];
// Safe patterns that override destructive checks
const SAFE_PATTERNS = [
// These patterns indicate safe, idempotent operations
/CREATE\s+(?:TABLE|INDEX|SCHEMA|TYPE)\s+IF\s+NOT\s+EXISTS/gi,
/DO\s+\$\$.*EXCEPTION.*WHEN\s+duplicate_object/gis, // Safe enum creation pattern
];
function findMigrationFiles(dir) {
const files = [];
if (!existsSync(dir)) {
return files;
}
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory() && entry !== 'meta') {
// Check subdirectories for .sql files
files.push(...findMigrationFiles(fullPath));
} else if (entry.endsWith('.sql')) {
files.push(fullPath);
}
}
return files;
}
function validateMigration(filePath) {
const content = readFileSync(filePath, 'utf-8');
const issues = [];
// Check if file uses safe patterns throughout (reserved for future use)
// const isSafeFile = SAFE_PATTERNS.some((pattern) => pattern.test(content));
void SAFE_PATTERNS; // Silence unused warning - patterns reserved for future enhancements
for (const { pattern, message, severity } of DESTRUCTIVE_PATTERNS) {
// Reset regex lastIndex
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(content)) !== null) {
// Find line number
const beforeMatch = content.substring(0, match.index);
const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
issues.push({
file: filePath,
line: lineNumber,
message,
severity,
match: match[0].trim(),
});
}
}
return issues;
}
function main() {
const args = process.argv.slice(2);
const allowDestructive = args.includes('--allow-destructive');
console.log('🔍 Validating migration files for destructive patterns...\n');
const allIssues = [];
let filesChecked = 0;
for (const dir of MIGRATION_DIRS) {
const files = findMigrationFiles(dir);
filesChecked += files.length;
for (const file of files) {
const issues = validateMigration(file);
allIssues.push(...issues);
}
}
// Separate errors and warnings
const errors = allIssues.filter((i) => i.severity === 'error');
const warnings = allIssues.filter((i) => i.severity === 'warning');
console.log(`📁 Checked ${filesChecked} migration files\n`);
if (warnings.length > 0) {
console.log('⚠️ WARNINGS:\n');
for (const issue of warnings) {
console.log(` ${issue.file}:${issue.line}`);
console.log(` ${issue.message}`);
console.log(` Found: ${issue.match}\n`);
}
}
if (errors.length > 0) {
console.log('❌ DESTRUCTIVE PATTERNS DETECTED:\n');
for (const issue of errors) {
console.log(` ${issue.file}:${issue.line}`);
console.log(` ${issue.message}`);
console.log(` Found: ${issue.match}\n`);
}
if (allowDestructive) {
console.log('⚠️ --allow-destructive flag set, continuing despite errors\n');
console.log('🟡 Migration validation passed with warnings');
process.exit(0);
} else {
console.log('💡 To proceed with destructive migrations, use --allow-destructive flag');
console.log(' Or review and update the migration to use safe patterns.\n');
console.log('❌ Migration validation FAILED');
process.exit(1);
}
}
console.log('✅ Migration validation passed - no destructive patterns found');
process.exit(0);
}
main();