@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
583 lines (577 loc) • 21.2 kB
JavaScript
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
export class MigrationManager {
config;
history = null;
constructor(config) {
this.config = config;
}
async createMigration(name, type, template) {
const version = this.generateVersion();
const fileName = `${version}_${name.toLowerCase().replace(/\s+/g, '_')}.js`;
const filePath = path.join(this.config.migrationsPath, fileName);
// Create migration content
let content;
if (template) {
content = this.applyTemplate(template);
}
else {
content = this.getDefaultTemplate(type);
}
// Create migration object
const migration = {
id: crypto.randomUUID(),
version,
name,
type,
status: 'pending',
createdAt: new Date().toISOString(),
checksum: this.calculateChecksum(content),
up: {
type: 'javascript',
content,
transaction: true,
},
down: {
type: 'javascript',
content: '// Rollback logic here',
transaction: true,
},
};
// Write migration file
await fs.mkdir(this.config.migrationsPath, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
return migration;
}
async runMigrations(options = {}) {
await this.loadHistory();
const pending = await this.getPendingMigrations();
if (pending.length === 0) {
return [];
}
// Filter to target version if specified
let toRun = pending;
if (options.target) {
const targetIndex = pending.findIndex(m => m.version === options.target);
if (targetIndex >= 0) {
toRun = pending.slice(0, targetIndex + 1);
}
}
// Create execution plan
const plan = await this.createMigrationPlan(toRun);
// Validate plan
if (!options.force) {
const validation = await this.validateMigration(plan.migrations[0]);
if (!validation.isValid) {
throw new Error(`Migration validation failed: ${validation.errors.join(', ')}`);
}
}
// Execute migrations
const records = [];
for (const migration of plan.migrations) {
if (options.dryRun) {
this.log('info', `[DRY RUN] Would execute migration: ${migration.version} - ${migration.name}`);
continue;
}
const record = await this.executeMigration(migration);
records.push(record);
if (record.status === 'failed') {
throw new Error(`Migration ${migration.version} failed: ${record.error}`);
}
}
return records;
}
async rollback(options = {}) {
await this.loadHistory();
if (!this.history || this.history.migrations.length === 0) {
throw new Error('No migrations to rollback');
}
// Determine which migrations to rollback
let toRollback = [];
if (options.target) {
// Rollback to specific version
const targetIndex = this.history.migrations.findIndex(m => m.version === options.target);
if (targetIndex >= 0) {
toRollback = this.history.migrations.slice(targetIndex + 1).reverse();
}
}
else if (options.steps) {
// Rollback specific number of steps
toRollback = this.history.migrations.slice(-options.steps).reverse();
}
else {
// Rollback last migration
toRollback = [this.history.migrations[this.history.migrations.length - 1]];
}
const records = [];
for (const migrationRecord of toRollback) {
const migration = await this.loadMigration(migrationRecord.version);
if (!migration.down) {
if (!options.force) {
throw new Error(`Migration ${migration.version} cannot be rolled back (no down script)`);
}
continue;
}
const record = await this.executeRollback(migration);
records.push(record);
if (record.status === 'failed') {
throw new Error(`Rollback of ${migration.version} failed: ${record.error}`);
}
}
return records;
}
async getMigrationStatus() {
await this.loadHistory();
const pending = await this.getPendingMigrations();
const executed = this.history?.migrations.filter(m => m.status === 'completed').length || 0;
const failed = this.history?.migrations.filter(m => m.status === 'failed').length || 0;
return {
current: this.history?.currentVersion || 'none',
pending: pending.length,
executed,
failed,
};
}
async validateMigration(migration) {
const validation = {
isValid: true,
errors: [],
warnings: [],
suggestions: [],
};
// Check syntax
if (this.config.validation?.syntaxCheck) {
try {
await this.validateSyntax(migration);
}
catch (error) {
validation.errors.push(`Syntax error: ${error.message}`);
validation.isValid = false;
}
}
// Check dependencies
if (migration.dependencies && migration.dependencies.length > 0) {
for (const dep of migration.dependencies) {
const exists = await this.migrationExists(dep);
if (!exists) {
validation.errors.push(`Missing dependency: ${dep}`);
validation.isValid = false;
}
}
}
// Check rollback script
if (this.config.validation?.requireRollback && !migration.down) {
validation.errors.push('Rollback script is required but not provided');
validation.isValid = false;
}
// Warnings
if (migration.type === 'schema' && !migration.down) {
validation.warnings.push('Schema migration without rollback script - this may cause issues');
}
if (!migration.description) {
validation.warnings.push('Migration lacks description - consider adding one for clarity');
}
// Suggestions
if (migration.up.transaction === false && migration.type === 'data') {
validation.suggestions.push('Consider using transactions for data migrations');
}
return validation;
}
async createMigrationPlan(migrations) {
// Build dependency graph
const graph = this.buildDependencyGraph(migrations);
// Topological sort for execution order
const sorted = this.topologicalSort(graph, migrations);
// Assess risks
const risks = this.assessMigrationRisks(sorted);
// Estimate duration
const estimatedDuration = sorted.reduce((total, m) => {
return total + this.estimateMigrationDuration(m);
}, 0);
// Check if all migrations have rollback scripts
const rollbackable = sorted.every(m => !!m.down);
return {
migrations: sorted,
estimatedDuration,
risks,
rollbackable,
dependencies: graph,
};
}
buildDependencyGraph(migrations) {
const nodes = migrations.map(m => m.version);
const edges = [];
migrations.forEach(migration => {
if (migration.dependencies) {
migration.dependencies.forEach(dep => {
edges.push([dep, migration.version]);
});
}
});
return { nodes, edges };
}
topologicalSort(graph, migrations) {
const visited = new Set();
const result = [];
const visit = (version) => {
if (visited.has(version))
return;
visited.add(version);
// Visit dependencies first
const deps = graph.edges.filter(([from, to]) => to === version).map(([from]) => from);
deps.forEach(dep => visit(dep));
const migration = migrations.find(m => m.version === version);
if (migration)
result.push(migration);
};
graph.nodes.forEach(node => visit(node));
return result;
}
assessMigrationRisks(migrations) {
const risks = [];
migrations.forEach(migration => {
// Schema changes are high risk
if (migration.type === 'schema') {
risks.push({
migration: migration.version,
description: 'Schema changes may cause downtime',
severity: 'high',
mitigation: 'Ensure backup is available and test in staging',
});
}
// Large data migrations
if (migration.type === 'data' && migration.up.content.includes('UPDATE') && !migration.up.content.includes('LIMIT')) {
risks.push({
migration: migration.version,
description: 'Unbounded data update may affect performance',
severity: 'medium',
mitigation: 'Consider batching updates',
});
}
// No rollback script
if (!migration.down) {
risks.push({
migration: migration.version,
description: 'No rollback script available',
severity: 'medium',
mitigation: 'Manual intervention may be required for rollback',
});
}
});
return risks;
}
estimateMigrationDuration(migration) {
// Base estimates by type (in milliseconds)
const baseEstimates = {
schema: 5000,
data: 30000,
seed: 10000,
index: 60000,
procedure: 2000,
config: 1000,
};
return baseEstimates[migration.type] || 5000;
}
async executeMigration(migration) {
const record = {
migrationId: migration.id,
version: migration.version,
name: migration.name,
status: 'running',
executedAt: new Date().toISOString(),
duration: 0,
logs: [],
};
const startTime = Date.now();
try {
// Run pre-migration hooks
if (this.config.hooks?.beforeEach) {
await this.runHook(this.config.hooks.beforeEach, migration);
}
// Execute migration
this.log('info', `Executing migration: ${migration.version} - ${migration.name}`);
await this.runMigrationScript(migration.up, migration);
// Run post-migration hooks
if (this.config.hooks?.afterEach) {
await this.runHook(this.config.hooks.afterEach, migration);
}
record.status = 'completed';
record.duration = Date.now() - startTime;
this.log('info', `Migration completed in ${record.duration}ms`);
// Update history
await this.updateHistory(record);
}
catch (error) {
record.status = 'failed';
record.error = error.message;
record.duration = Date.now() - startTime;
this.log('error', `Migration failed: ${error.message}`);
// Run error hooks
if (this.config.hooks?.onError) {
await this.runHook(this.config.hooks.onError, migration);
}
}
return record;
}
async executeRollback(migration) {
if (!migration.down) {
throw new Error('No rollback script available');
}
const record = {
migrationId: migration.id,
version: migration.version,
name: migration.name,
status: 'running',
executedAt: new Date().toISOString(),
duration: 0,
logs: [],
};
const startTime = Date.now();
try {
this.log('info', `Rolling back migration: ${migration.version} - ${migration.name}`);
await this.runMigrationScript(migration.down, migration);
record.status = 'rolled_back';
record.duration = Date.now() - startTime;
this.log('info', `Rollback completed in ${record.duration}ms`);
// Remove from history
await this.removeFromHistory(migration.version);
}
catch (error) {
record.status = 'failed';
record.error = error.message;
record.duration = Date.now() - startTime;
this.log('error', `Rollback failed: ${error.message}`);
}
return record;
}
async runMigrationScript(script, migration) {
switch (script.type) {
case 'javascript':
case 'typescript':
// Dynamic import and execute
const migrationPath = path.join(this.config.migrationsPath, `${migration.version}_*.js`);
const module = await import(migrationPath);
if (typeof module.up === 'function') {
await module.up();
}
break;
case 'sql':
// Execute SQL based on database config
if (this.config.database) {
// Would execute SQL here based on database type
this.log('info', 'Executing SQL migration');
}
break;
case 'shell':
// Execute shell command
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
await execAsync(script.content);
break;
}
}
async getPendingMigrations() {
const allMigrations = await this.loadAllMigrations();
const executedVersions = new Set(this.history?.migrations.map(m => m.version) || []);
return allMigrations.filter(m => !executedVersions.has(m.version));
}
async loadAllMigrations() {
const files = await fs.readdir(this.config.migrationsPath);
const migrationFiles = files.filter(f => f.match(/^\d{14}_.*\.(js|ts|sql)$/));
const migrations = [];
for (const file of migrationFiles) {
const version = file.substring(0, 14);
const name = file.substring(15).replace(/\.(js|ts|sql)$/, '').replace(/_/g, ' ');
const content = await fs.readFile(path.join(this.config.migrationsPath, file), 'utf-8');
migrations.push({
id: crypto.randomUUID(),
version,
name,
type: this.inferMigrationType(content),
status: 'pending',
createdAt: new Date().toISOString(),
checksum: this.calculateChecksum(content),
up: {
type: file.endsWith('.sql') ? 'sql' : 'javascript',
content,
transaction: true,
},
});
}
return migrations.sort((a, b) => a.version.localeCompare(b.version));
}
async loadMigration(version) {
const migrations = await this.loadAllMigrations();
const migration = migrations.find(m => m.version === version);
if (!migration) {
throw new Error(`Migration ${version} not found`);
}
return migration;
}
async loadHistory() {
// Load from storage - simplified for example
this.history = {
projectId: 'default',
migrations: [],
currentVersion: 'none',
};
}
async updateHistory(record) {
if (!this.history) {
await this.loadHistory();
}
this.history.migrations.push(record);
this.history.currentVersion = record.version;
this.history.lastMigration = record;
// Save to storage
}
async removeFromHistory(version) {
if (!this.history)
return;
this.history.migrations = this.history.migrations.filter(m => m.version !== version);
if (this.history.migrations.length > 0) {
const last = this.history.migrations[this.history.migrations.length - 1];
this.history.currentVersion = last.version;
this.history.lastMigration = last;
}
else {
this.history.currentVersion = 'none';
this.history.lastMigration = undefined;
}
// Save to storage
}
generateVersion() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
const second = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}${hour}${minute}${second}`;
}
calculateChecksum(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
inferMigrationType(content) {
if (content.includes('CREATE TABLE') || content.includes('ALTER TABLE')) {
return 'schema';
}
if (content.includes('INSERT INTO') && content.includes('seed')) {
return 'seed';
}
if (content.includes('CREATE INDEX')) {
return 'index';
}
if (content.includes('CREATE PROCEDURE') || content.includes('CREATE FUNCTION')) {
return 'procedure';
}
if (content.includes('UPDATE') || content.includes('INSERT')) {
return 'data';
}
return 'config';
}
async validateSyntax(migration) {
// Basic syntax validation based on type
if (migration.up.type === 'sql') {
// Check for common SQL syntax issues
const sql = migration.up.content.toUpperCase();
if (sql.includes('DROP TABLE') && !sql.includes('IF EXISTS')) {
throw new Error('DROP TABLE should use IF EXISTS clause');
}
}
}
async migrationExists(version) {
const migrations = await this.loadAllMigrations();
return migrations.some(m => m.version === version);
}
async runHook(hook, migration) {
// Execute hook command
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
await execAsync(hook, {
env: {
...process.env,
MIGRATION_VERSION: migration.version,
MIGRATION_NAME: migration.name,
MIGRATION_TYPE: migration.type,
},
});
}
getDefaultTemplate(type) {
const templates = {
schema: `
// Schema migration
exports.up = async function(db) {
// Create tables, alter columns, etc.
};
exports.down = async function(db) {
// Revert schema changes
};
`,
data: `
// Data migration
exports.up = async function(db) {
// Transform or migrate data
};
exports.down = async function(db) {
// Revert data changes
};
`,
seed: `
// Seed data
exports.up = async function(db) {
// Insert seed data
};
exports.down = async function(db) {
// Remove seed data
};
`,
index: `
// Index migration
exports.up = async function(db) {
// Create indexes
};
exports.down = async function(db) {
// Drop indexes
};
`,
procedure: `
// Stored procedure migration
exports.up = async function(db) {
// Create procedures/functions
};
exports.down = async function(db) {
// Drop procedures/functions
};
`,
config: `
// Configuration migration
exports.up = async function(db) {
// Apply configuration changes
};
exports.down = async function(db) {
// Revert configuration changes
};
`,
};
return templates[type];
}
applyTemplate(template) {
let content = template.template;
if (template.variables) {
Object.entries(template.variables).forEach(([key, value]) => {
content = content.replace(new RegExp(`{{${key}}}`, 'g'), value);
});
}
return content;
}
log(level, message, details) {
// Would normally log to file or send to logging service
console.log(`[${level.toUpperCase()}] ${message}`, details || '');
}
}
//# sourceMappingURL=migration-manager.js.map