UNPKG

@200systems/mf-db-mysql

Version:

MySQL database client with connection pooling, migrations, and health monitoring

244 lines 10.8 kB
import path from 'path'; import fs from 'fs/promises'; import { createHash } from 'crypto'; import { MigrationError, } from '@200systems/mf-db-core'; /** * MySQL Migration Manager implementation */ export class MySQLMigrator { client; migrationsDir; logger; migrationsTable = 'mf_migrations'; constructor(client, migrationsDir, logger) { this.client = client; this.migrationsDir = migrationsDir; this.logger = logger; } async migrate() { try { this.logger.info('Starting MySQL migrations'); await this.ensureMigrationsTable(); const pending = await this.getPending(); if (pending.length === 0) { this.logger.info('No pending migrations'); return; } this.logger.info(`Found ${pending.length} pending migrations`); for (const migration of pending) { await this.applyMigration(migration); } this.logger.info('All migrations completed successfully'); } catch (error) { this.logger.error('Migration failed', error, { error }); throw new MigrationError(`Migration failed: ${error.message}`, undefined, error); } } async rollback() { try { this.logger.info('Starting rollback of last migration'); const applied = await this.getStatus(); if (applied.length === 0) { this.logger.info('No migrations to rollback'); return; } const lastMigration = applied[applied.length - 1]; await this.rollbackMigration(lastMigration.id); this.logger.info('Rollback completed successfully'); } catch (error) { this.logger.error('Rollback failed', error, { error }); throw new MigrationError(`Rollback failed: ${error.message}`, undefined, error); } } async rollbackTo(migrationId) { try { this.logger.info('Starting rollback to migration', { migrationId }); const applied = await this.getStatus(); const targetIndex = applied.findIndex(m => m.id === migrationId); if (targetIndex === -1) { throw new MigrationError(`Migration ${migrationId} not found in applied migrations`); } // Rollback migrations from most recent down to the target (exclusive) const migrationsToRollback = applied.slice(targetIndex + 1).reverse(); for (const migration of migrationsToRollback) { await this.rollbackMigration(migration.id); } this.logger.info(`Rollback to ${migrationId} completed successfully`); } catch (error) { this.logger.error('Rollback to migration failed', error, { error, migrationId }); throw new MigrationError(`Rollback to ${migrationId} failed: ${error.message}`, migrationId, error); } } async getStatus() { try { await this.ensureMigrationsTable(); const result = await this.client.query(`SELECT id, description, applied_at, checksum FROM ${this.migrationsTable} ORDER BY applied_at ASC`); return result.rows.map(row => ({ id: row.id, description: row.description, applied_at: new Date(row.applied_at), checksum: row.checksum, })); } catch (error) { this.logger.error('Failed to get migration status', error, { error }); throw new MigrationError(`Failed to get migration status: ${error.message}`, undefined, error); } } async getPending() { try { const [allMigrations, appliedMigrations] = await Promise.all([ this.loadMigrationsFromDisk(), this.getStatus(), ]); const appliedIds = new Set(appliedMigrations.map(m => m.id)); const pending = allMigrations.filter(m => !appliedIds.has(m.id)); // Validate checksums for applied migrations for (const applied of appliedMigrations) { const migration = allMigrations.find(m => m.id === applied.id); if (migration && migration.checksum !== applied.checksum) { throw new MigrationError(`Migration ${applied.id} has been modified since it was applied. ` + `Expected checksum: ${applied.checksum}, actual: ${migration.checksum}`); } } return pending.sort((a, b) => a.id.localeCompare(b.id)); } catch (error) { this.logger.error('Failed to get pending migrations', error, { error }); throw new MigrationError(`Failed to get pending migrations: ${error.message}`, undefined, error); } } async reset() { try { this.logger.warn('Resetting all migrations - THIS WILL DROP ALL DATA'); const applied = await this.getStatus(); // Rollback all migrations in reverse order for (const migration of applied.reverse()) { await this.rollbackMigration(migration.id, false); // Don't remove from tracking table yet } // Drop the migrations table await this.client.query(`DROP TABLE IF EXISTS ${this.migrationsTable}`); this.logger.warn('All migrations reset successfully'); } catch (error) { this.logger.error('Failed to reset migrations', error, { error }); throw new MigrationError(`Failed to reset migrations: ${error.message}`, undefined, error); } } async ensureMigrationsTable() { const createTableSQL = ` CREATE TABLE IF NOT EXISTS ${this.migrationsTable} ( id VARCHAR(255) PRIMARY KEY, description TEXT NOT NULL, checksum VARCHAR(64) NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_applied_at (applied_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `; await this.client.query(createTableSQL); } async loadMigrationsFromDisk() { try { const files = await fs.readdir(this.migrationsDir); const migrationFiles = files .filter(file => file.endsWith('.sql')) .sort(); const migrations = []; for (const file of migrationFiles) { const filePath = path.join(this.migrationsDir, file); const content = await fs.readFile(filePath, 'utf-8'); const migration = this.parseMigrationFile(file, content); migrations.push(migration); } return migrations; } catch (error) { this.logger.error('Failed to load migrations from disk', error, { error, migrationsDir: this.migrationsDir }); throw new MigrationError(`Failed to load migrations: ${error.message}`, undefined, error); } } parseMigrationFile(filename, content) { // Extract migration ID from filename (e.g., "001_create_users_table.sql" -> "001_create_users_table") const id = filename.replace('.sql', ''); // Split content into UP and DOWN sections const sections = content.split(/^--\s*(UP|DOWN)\s*$/m); if (sections.length < 3) { throw new MigrationError(`Invalid migration file format: ${filename}. Must contain -- UP and -- DOWN sections.`); } let up = ''; let down = ''; let description = ''; // Extract description from the first comment line const firstLine = content.split('\n')[0]; if (firstLine.startsWith('--')) { description = firstLine.substring(2).trim(); } // Parse sections for (let i = 1; i < sections.length; i += 2) { const sectionType = sections[i]; const sectionContent = sections[i + 1] || ''; if (sectionType === 'UP') { up = sectionContent.trim(); } else if (sectionType === 'DOWN') { down = sectionContent.trim(); } } if (!up) { throw new MigrationError(`Migration ${filename} is missing UP section`); } if (!down) { throw new MigrationError(`Migration ${filename} is missing DOWN section`); } // Generate checksum const checksum = createHash('sha256').update(content).digest('hex'); return { id, description: description || `Migration ${id}`, up, down, checksum, }; } async applyMigration(migration) { try { await this.client.transaction(async (trx) => { this.logger.info('Applying migration', { id: migration.id, description: migration.description }); // Execute the UP migration await trx.query(migration.up); // Record the migration await trx.query(`INSERT INTO ${this.migrationsTable} (id, description, checksum) VALUES (?, ?, ?)`, [migration.id, migration.description, migration.checksum]); this.logger.info('Migration applied successfully', { id: migration.id }); }); } catch (error) { this.logger.error('Failed to apply migration', error, { error, migrationId: migration.id }); throw new MigrationError(`Failed to apply migration ${migration.id}: ${error.message}`, migration.id, error); } finally { // Ensure the client is released after the transaction await this.client.close(); } } async rollbackMigration(migrationId, removeFromTable = true) { const migrations = await this.loadMigrationsFromDisk(); const migration = migrations.find(m => m.id === migrationId); if (!migration) { throw new MigrationError(`Migration file for ${migrationId} not found`); } await this.client.transaction(async (trx) => { this.logger.info('Rolling back migration', { id: migrationId, description: migration.description }); // Execute the DOWN migration await trx.query(migration.down); // Remove from migrations table if requested if (removeFromTable) { await trx.query(`DELETE FROM ${this.migrationsTable} WHERE id = ?`, [migrationId]); } this.logger.info('Migration rolled back successfully', { id: migrationId }); }); } } //# sourceMappingURL=migrator.js.map