@200systems/mf-db-mysql
Version:
MySQL database client with connection pooling, migrations, and health monitoring
244 lines • 10.8 kB
JavaScript
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