Migration Hooks

Extend migration behavior with lifecycle hooks

Table of contents

  1. Overview
    1. Common Use Cases
  2. Quick Start
    1. Basic Hook Implementation
  3. Available Lifecycle Hooks
  4. Hook Examples
    1. Slack Notifications
    2. Metrics Collection
    3. Transaction Metrics (v0.5.0+)
    4. Custom File Logger
    5. Dry-Run Mode
    6. Migration Validation
    7. Backup and Restore Monitoring
  5. Combining Multiple Hooks
    1. Dynamic Hook Management
  6. Advanced Patterns
    1. Error Recovery Hook
    2. Conditional Hooks
    3. Nested Composites
  7. Best Practices
    1. 1. Keep Hooks Focused
    2. 2. Handle Errors Gracefully
    3. 3. Make Hooks Configurable
    4. 4. Use TypeScript for Type Safety
    5. 5. Test Your Hooks
  8. Troubleshooting
    1. Hook Not Called
    2. Hooks Causing Migration to Fail
    3. Hooks Not Executing in Order
  9. API Reference
  10. Example: Production-Ready Hooks

Overview

Migration hooks provide extension points throughout the migration lifecycle, enabling you to add custom behavior without modifying the core migration code. Hooks follow the Observer Pattern, allowing multiple implementations to respond to migration events.

Common Use Cases

  • Notifications - Send Slack, email, or SMS alerts on migration events
  • Metrics - Collect timing, success/failure metrics for monitoring
  • Logging - Custom logging to files, databases, or external services
  • Validation - Enforce naming conventions or migration policies
  • Dry-run Mode - Preview migrations without executing them
  • Integration - Trigger CI/CD pipelines, update status pages

Quick Start

Basic Hook Implementation

import { IMigrationHooks, MigrationScript, IMigrationResult } from 'migration-script-runner';

class NotificationHooks implements IMigrationHooks {
    async onStart(total: number, pending: number): Promise<void> {
        console.log(`Starting migration: ${pending}/${total} scripts`);
    }

    async onComplete(result: IMigrationResult): Promise<void> {
        console.log(`✅ Completed: ${result.executed.length} migrations`);
    }

    async onError(error: Error): Promise<void> {
        console.error(`❌ Failed: ${error.message}`);
    }
}

// Use the hooks
const executor = new MigrationScriptExecutor({ handler, 
    hooks: new NotificationHooks()
});

await executor.migrate();

Available Lifecycle Hooks

The IMigrationHooks interface provides 10 lifecycle hooks:

Hook Timing Use Case
onStart After scripts loaded, before backup Initialization, notifications
onBeforeBackup Before creating backup Pre-backup checks, disk space validation
onAfterBackup After backup created Upload backup to S3, verify backup
onBeforeMigrate Before each migration runs Validation, dry-run mode, logging
onAfterMigrate After each migration succeeds Metrics, notifications per script
onMigrationError When a migration fails Error logging, alerting
onBeforeRestore Before restoring backup Pre-restore notifications
onAfterRestore After backup restored Post-restore validation
onComplete After all migrations succeed Success notifications, cleanup
onError When migration process fails Failure notifications, error tracking

Hook Examples

Slack Notifications

class SlackHooks implements IMigrationHooks {
    constructor(private webhookUrl: string) {}

    async onStart(total: number, pending: number): Promise<void> {
        await this.sendMessage({
            text: `🚀 Starting migration`,
            attachments: [{
                color: 'good',
                fields: [
                    { title: 'Total Scripts', value: String(total), short: true },
                    { title: 'To Execute', value: String(pending), short: true }
                ]
            }]
        });
    }

    async onComplete(result: IMigrationResult): Promise<void> {
        await this.sendMessage({
            text: `✅ Migration completed successfully`,
            attachments: [{
                color: 'good',
                fields: [
                    { title: 'Executed', value: String(result.executed.length), short: true },
                    { title: 'Scripts', value: result.executed.map(s => s.name).join(', ') }
                ]
            }]
        });
    }

    async onError(error: Error): Promise<void> {
        await this.sendMessage({
            text: `❌ Migration failed`,
            attachments: [{
                color: 'danger',
                fields: [
                    { title: 'Error', value: error.message },
                    { title: 'Stack', value: error.stack || 'N/A' }
                ]
            }]
        });
    }

    private async sendMessage(payload: any): Promise<void> {
        await fetch(this.webhookUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
        });
    }
}

// Usage
const hooks = new SlackHooks(process.env.SLACK_WEBHOOK_URL!);
const executor = new MigrationScriptExecutor({ handler,  hooks });

Metrics Collection

import { metrics } from './metrics-client'; // Your metrics library

class MetricsHooks implements IMigrationHooks {
    async onStart(total: number, pending: number): Promise<void> {
        metrics.gauge('migration.total_scripts', total);
        metrics.gauge('migration.pending_scripts', pending);
        metrics.increment('migration.started');
    }

    async onAfterMigrate(script: MigrationScript, result: string): Promise<void> {
        const duration = (script.finishedAt! - script.startedAt!) / 1000;

        metrics.timing('migration.duration', duration, {
            script: script.name,
            timestamp: script.timestamp
        });

        metrics.increment('migration.script.success', {
            script: script.name
        });
    }

    async onMigrationError(script: MigrationScript, error: Error): Promise<void> {
        metrics.increment('migration.script.error', {
            script: script.name,
            error: error.message
        });
    }

    async onComplete(result: IMigrationResult): Promise<void> {
        metrics.increment('migration.completed');
        metrics.gauge('migration.total_executed', result.executed.length);
    }

    async onError(error: Error): Promise<void> {
        metrics.increment('migration.failed', {
            error: error.message
        });
    }
}

Transaction Metrics (v0.5.0+)

Track transaction-specific metrics when using transaction modes:

import { metrics } from './metrics-client'; // Your metrics library

class TransactionMetricsHooks implements IMigrationHooks {
    private transactionStartTime?: number;

    async onStart(total: number, pending: number): Promise<void> {
        metrics.gauge('migration.transaction.pending_count', pending);
    }

    async onBeforeMigrate(script: MigrationScript): Promise<void> {
        // Track when transaction begins
        this.transactionStartTime = Date.now();

        // Track transaction mode from script metadata
        const txMode = (script as any).transactionMode;
        if (txMode) {
            metrics.increment('migration.transaction.mode', {
                mode: txMode
            });
        }
    }

    async onAfterMigrate(script: MigrationScript, result: string): Promise<void> {
        // Calculate transaction duration
        if (this.transactionStartTime) {
            const duration = Date.now() - this.transactionStartTime;
            metrics.timing('migration.transaction.duration', duration, {
                script: script.name,
                mode: (script as any).transactionMode || 'NONE'
            });
        }

        // Track successful transaction commits
        metrics.increment('migration.transaction.commit.success', {
            script: script.name
        });

        // Track if transaction used retry mechanism
        const retryCount = (script as any).retryCount || 0;
        if (retryCount > 0) {
            metrics.increment('migration.transaction.retries', {
                script: script.name,
                retry_count: retryCount
            });
        }
    }

    async onMigrationError(script: MigrationScript, error: Error): Promise<void> {
        // Track transaction rollbacks
        metrics.increment('migration.transaction.rollback', {
            script: script.name,
            error_type: error.name
        });

        // Track specific transaction errors
        if (error.message.includes('deadlock')) {
            metrics.increment('migration.transaction.error.deadlock');
        } else if (error.message.includes('timeout')) {
            metrics.increment('migration.transaction.error.timeout');
        } else if (error.message.includes('conflict')) {
            metrics.increment('migration.transaction.error.conflict');
        }
    }
}

// Usage with transaction configuration
const config = new Config();
config.transaction.mode = TransactionMode.PER_MIGRATION;
config.transaction.isolation = IsolationLevel.READ_COMMITTED;
config.transaction.retries = 3;

const hooks = new TransactionMetricsHooks();
const executor = new MigrationScriptExecutor({ handler,  hooks });

await executor.migrate();

Key Metrics Tracked:

  • Transaction duration - How long each transaction takes
  • Commit success rate - Percentage of successful commits
  • Rollback frequency - How often transactions are rolled back
  • Retry count - Number of transaction retries due to conflicts/deadlocks
  • Error types - Categorized transaction errors (deadlock, timeout, conflict)
  • Transaction mode usage - Which transaction modes are being used

Integration with Popular Monitoring Tools:

// Datadog
class DatadogTransactionMetrics extends TransactionMetricsHooks {
    async onAfterMigrate(script: MigrationScript, result: string): Promise<void> {
        await super.onAfterMigrate(script, result);

        // Send custom metric to Datadog
        const statsd = require('hot-shots');
        const client = new statsd();

        client.timing('migration.transaction.duration',
            Date.now() - this.transactionStartTime!,
            ['script:' + script.name]
        );
    }
}

// Prometheus
class PrometheusTransactionMetrics extends TransactionMetricsHooks {
    private commitCounter = new promClient.Counter({
        name: 'migration_transaction_commits_total',
        help: 'Total number of transaction commits',
        labelNames: ['script', 'status']
    });

    async onAfterMigrate(script: MigrationScript, result: string): Promise<void> {
        this.commitCounter.inc({ script: script.name, status: 'success' });
    }

    async onMigrationError(script: MigrationScript, error: Error): Promise<void> {
        this.commitCounter.inc({ script: script.name, status: 'rollback' });
    }
}

Custom File Logger

import fs from 'fs';
import path from 'path';

class DetailedFileLoggerHooks implements IMigrationHooks {
    private logPath: string;

    constructor(logPath: string) {
        this.logPath = logPath;
        // Ensure log directory exists
        fs.mkdirSync(path.dirname(logPath), { recursive: true });
    }

    async onStart(total: number, pending: number): Promise<void> {
        this.log(`=== Migration Started ===`);
        this.log(`Total Scripts: ${total}`);
        this.log(`Pending: ${pending}`);
        this.log(`Timestamp: ${new Date().toISOString()}`);
        this.log('');
    }

    async onBeforeMigrate(script: MigrationScript): Promise<void> {
        this.log(`Executing: ${script.name}`);
        this.log(`  Path: ${script.filepath}`);
        this.log(`  Timestamp: ${script.timestamp}`);
    }

    async onAfterMigrate(script: MigrationScript, result: string): Promise<void> {
        const duration = script.finishedAt! - script.startedAt!;
        this.log(`  ✅ Success: ${script.name}`);
        this.log(`  Duration: ${duration}ms`);
        this.log(`  Result: ${result}`);
        this.log('');
    }

    async onMigrationError(script: MigrationScript, error: Error): Promise<void> {
        this.log(`  ❌ Failed: ${script.name}`);
        this.log(`  Error: ${error.message}`);
        this.log(`  Stack: ${error.stack}`);
        this.log('');
    }

    async onComplete(result: IMigrationResult): Promise<void> {
        this.log(`=== Migration Completed ===`);
        this.log(`Executed: ${result.executed.length} scripts`);
        result.executed.forEach(s => {
            this.log(`  - ${s.name}`);
        });
        this.log('');
    }

    async onError(error: Error): Promise<void> {
        this.log(`=== Migration Failed ===`);
        this.log(`Error: ${error.message}`);
        this.log(`Stack: ${error.stack}`);
        this.log('');
    }

    private log(message: string): void {
        fs.appendFileSync(this.logPath, message + '\n');
    }
}

Dry-Run Mode

class DryRunHooks implements IMigrationHooks {
    async onBeforeMigrate(script: MigrationScript): Promise<void> {
        console.log(`[DRY RUN] Would execute: ${script.name}`);
        console.log(`  Timestamp: ${script.timestamp}`);
        console.log(`  Path: ${script.filepath}`);

        // Throw error to skip actual execution
        throw new Error('DRY_RUN_MODE');
    }

    async onMigrationError(script: MigrationScript, error: Error): Promise<void> {
        // Suppress error for dry-run mode
        if (error.message === 'DRY_RUN_MODE') {
            console.log(`  [DRY RUN] Skipped execution\n`);
            return;
        }

        // Real error - log it
        console.error(`  ❌ Error: ${error.message}\n`);
    }
}

// Usage
const hooks = new DryRunHooks();
const executor = new MigrationScriptExecutor({ handler,  hooks });

try {
    await executor.migrate();
} catch (error) {
    if (error.message !== 'DRY_RUN_MODE') {
        throw error;
    }
    console.log('Dry run completed');
}

Migration Validation

class ValidationHooks implements IMigrationHooks {
    async onBeforeMigrate(script: MigrationScript): Promise<void> {
        // Enforce naming convention
        const pattern = /^V\d{12}_[a-z_]+\.ts$/;
        if (!script.name.match(pattern)) {
            throw new Error(
                `Invalid script name: ${script.name}. ` +
                `Must match pattern: V{timestamp}_{description}.ts`
            );
        }

        // Enforce timestamp ordering
        const now = Date.now();
        const scriptDate = new Date(String(script.timestamp)).getTime();
        const oneYearAgo = now - (365 * 24 * 60 * 60 * 1000);

        if (scriptDate > now) {
            throw new Error(`Script timestamp is in the future: ${script.name}`);
        }

        if (scriptDate < oneYearAgo) {
            console.warn(`⚠️  Script is older than 1 year: ${script.name}`);
        }

        // Custom validation logic
        if (script.name.includes('drop') || script.name.includes('delete')) {
            console.warn(`⚠️  Destructive migration detected: ${script.name}`);
        }
    }
}

Backup and Restore Monitoring

class BackupMonitoringHooks implements IMigrationHooks {
    async onBeforeBackup(): Promise<void> {
        console.log('📦 Creating backup before migration...');

        // Check disk space
        const diskSpace = await this.checkDiskSpace();
        if (diskSpace.available < 1024 * 1024 * 1024) { // Less than 1GB
            console.warn('⚠️  Low disk space - backup may fail');
        }
    }

    async onAfterBackup(backupPath: string): Promise<void> {
        console.log(`✅ Backup created: ${backupPath}`);

        // Verify backup file exists and is readable
        const backupSize = await this.getBackupSize(backupPath);
        console.log(`   Size: ${(backupSize / 1024 / 1024).toFixed(2)} MB`);

        // Optionally upload to S3 or cloud storage
        if (process.env.BACKUP_TO_S3 === 'true') {
            await this.uploadToS3(backupPath);
        }
    }

    async onBeforeRestore(): Promise<void> {
        console.log('⚠️  Migration failed - restoring from backup...');

        // Send alert that rollback is happening
        await this.sendAlert({
            level: 'warning',
            message: 'Migration rollback in progress',
            action: 'Database restoration from backup'
        });
    }

    async onAfterRestore(): Promise<void> {
        console.log('✅ Database restored to previous state');

        // Verify database is operational
        const isHealthy = await this.checkDatabaseHealth();
        if (!isHealthy) {
            console.error('❌ Database health check failed after restore');
            await this.sendAlert({
                level: 'critical',
                message: 'Database unhealthy after restoration',
                action: 'Manual intervention required'
            });
        } else {
            console.log('✅ Database health check passed');
        }
    }

    private async checkDiskSpace(): Promise<{ available: number }> {
        // Implementation depends on your platform
        return { available: 10 * 1024 * 1024 * 1024 }; // 10GB example
    }

    private async getBackupSize(path: string): Promise<number> {
        const fs = await import('fs/promises');
        const stats = await fs.stat(path);
        return stats.size;
    }

    private async uploadToS3(path: string): Promise<void> {
        console.log(`   Uploading backup to S3...`);
        // S3 upload implementation
    }

    private async checkDatabaseHealth(): Promise<boolean> {
        // Run simple query to verify database is accessible
        return true; // Placeholder
    }

    private async sendAlert(alert: { level: string; message: string; action: string }): Promise<void> {
        // Send alert via Slack, PagerDuty, etc.
        console.log(`[${alert.level.toUpperCase()}] ${alert.message}: ${alert.action}`);
    }
}

// Usage
const config = new Config();
config.rollbackStrategy = RollbackStrategy.BACKUP; // Enable backup/restore

const hooks = new BackupMonitoringHooks();
const executor = new MigrationScriptExecutor({ handler,  hooks });

await executor.migrate();

Note: onBeforeRestore and onAfterRestore hooks are only called when:

  1. A migration fails during execution
  2. The rollback strategy is set to RollbackStrategy.BACKUP
  3. A valid backup was created before the migration

Combining Multiple Hooks

Use CompositeHooks to combine multiple hook implementations:

import { CompositeHooks } from 'migration-script-runner';

const hooks = new CompositeHooks([
    new SlackHooks(process.env.SLACK_WEBHOOK!),
    new MetricsHooks(),
    new DetailedFileLoggerHooks('/var/log/migrations.log'),
    new ValidationHooks()
]);

const executor = new MigrationScriptExecutor({ handler,  hooks });
await executor.migrate();

Dynamic Hook Management

const hooks = new CompositeHooks();

// Always use these hooks
hooks.addHook(new SlackHooks(webhookUrl));
hooks.addHook(new ValidationHooks());

// Conditional hooks
if (process.env.NODE_ENV === 'production') {
    hooks.addHook(new MetricsHooks());
    hooks.addHook(new DatadogHooks());
}

if (process.env.ENABLE_DETAILED_LOGGING === 'true') {
    hooks.addHook(new DetailedFileLoggerHooks('/var/log/migrations.log'));
}

const executor = new MigrationScriptExecutor({ handler,  hooks });

Advanced Patterns

Error Recovery Hook

class ErrorRecoveryHooks implements IMigrationHooks {
    private attempts = new Map<string, number>();
    private maxRetries = 3;

    async onMigrationError(script: MigrationScript, error: Error): Promise<void> {
        const attempts = (this.attempts.get(script.name) || 0) + 1;
        this.attempts.set(script.name, attempts);

        if (attempts < this.maxRetries) {
            console.log(`Retry ${attempts}/${this.maxRetries} for ${script.name}`);
            // Could implement retry logic here
        } else {
            console.error(`Max retries exceeded for ${script.name}`);
            await this.notifyAdmins(script, error);
        }
    }

    private async notifyAdmins(script: MigrationScript, error: Error): Promise<void> {
        // Send urgent notification
        console.error(`🚨 URGENT: Migration failed after ${this.maxRetries} attempts`);
    }
}

Conditional Hooks

class ConditionalHooks implements IMigrationHooks {
    constructor(private condition: () => boolean) {}

    async onStart(total: number, pending: number): Promise<void> {
        if (this.condition()) {
            console.log(`Conditional hook activated`);
        }
    }

    async onComplete(result: IMigrationResult): Promise<void> {
        if (this.condition()) {
            console.log(`Migration completed conditionally`);
        }
    }
}

// Usage
const hooks = new ConditionalHooks(() => process.env.NODE_ENV === 'production');

Nested Composites

// Group related hooks
const notificationHooks = new CompositeHooks([
    new SlackHooks(slackWebhook),
    new EmailHooks(emailConfig),
    new PagerDutyHooks(pdApiKey)
]);

const monitoringHooks = new CompositeHooks([
    new MetricsHooks(),
    new DatadogHooks(),
    new NewRelicHooks()
]);

const loggingHooks = new CompositeHooks([
    new DetailedFileLoggerHooks('/var/log/migrations.log'),
    new SentryHooks(),
    new CloudWatchHooks()
]);

// Combine all groups
const allHooks = new CompositeHooks([
    notificationHooks,
    monitoringHooks,
    loggingHooks
]);

const executor = new MigrationScriptExecutor({ handler,  hooks: allHooks });

Best Practices

1. Keep Hooks Focused

Each hook should have a single responsibility:

// ✅ Good - focused on one thing
class SlackNotificationHooks implements IMigrationHooks {
    async onComplete(result: IMigrationResult): Promise<void> {
        await this.sendSlackMessage(result);
    }
}

// ❌ Bad - doing too much
class EverythingHooks implements IMigrationHooks {
    async onComplete(result: IMigrationResult): Promise<void> {
        await this.sendSlackMessage(result);
        await this.updateDatabase(result);
        await this.generateReport(result);
        await this.notifyEmail(result);
    }
}

2. Handle Errors Gracefully

Hooks should not throw errors unless intentional (like dry-run mode):

class SafeHooks implements IMigrationHooks {
    async onComplete(result: IMigrationResult): Promise<void> {
        try {
            await this.sendNotification(result);
        } catch (error) {
            // Log error but don't fail the migration
            console.error('Notification failed:', error);
        }
    }
}

3. Make Hooks Configurable

interface SlackHooksConfig {
    webhookUrl: string;
    channel?: string;
    username?: string;
    notifyOnSuccess?: boolean;
    notifyOnFailure?: boolean;
}

class SlackHooks implements IMigrationHooks {
    constructor(private config: SlackHooksConfig) {}

    async onComplete(result: IMigrationResult): Promise<void> {
        if (this.config.notifyOnSuccess !== false) {
            await this.sendMessage(/* ... */);
        }
    }
}

4. Use TypeScript for Type Safety

import { IMigrationHooks, MigrationScript, IMigrationResult } from 'migration-script-runner';

class TypeSafeHooks implements IMigrationHooks {
    async onAfterMigrate(script: MigrationScript, result: string): Promise<void> {
        // TypeScript ensures correct parameter types
        const duration = script.finishedAt! - script.startedAt!;
        console.log(`${script.name} completed in ${duration}ms`);
    }
}

5. Test Your Hooks

import { expect } from 'chai';
import sinon from 'sinon';

describe('SlackHooks', () => {
    it('should send notification on completion', async () => {
        const fetchStub = sinon.stub(global, 'fetch').resolves();
        const hooks = new SlackHooks('https://hooks.slack.com/...');

        const result: IMigrationResult = {
            success: true,
            executed: [],
            migrated: [],
            ignored: []
        };

        await hooks.onComplete(result);

        expect(fetchStub.calledOnce).to.be.true;
        fetchStub.restore();
    });
});

Troubleshooting

Hook Not Called

Problem: Your hook methods are not being invoked.

Solutions:

  1. Verify hook is passed to MigrationScriptExecutor:
    const executor = new MigrationScriptExecutor({ handler,  hooks: myHooks });
    
  2. Check method names match IMigrationHooks interface exactly

  3. Ensure methods are async and return Promise

Hooks Causing Migration to Fail

Problem: Migration fails due to hook errors.

Solution: Wrap hook logic in try-catch:

async onComplete(result: IMigrationResult): Promise<void> {
    try {
        await this.riskyOperation(result);
    } catch (error) {
        console.error('Hook error (non-fatal):', error);
    }
}

Hooks Not Executing in Order

Problem: Hooks run out of order or in parallel.

Solution: Hooks in a CompositeHooks are called sequentially. If using multiple executors, they may run in parallel.


API Reference

See IMigrationHooks API Documentation for complete interface details.


Example: Production-Ready Hooks

Complete example combining multiple patterns:

import { CompositeHooks, IMigrationHooks, MigrationScript, IMigrationResult } from 'migration-script-runner';

// Create hook factory based on environment
function createHooks(env: string): IMigrationHooks {
    const hooks = new CompositeHooks();

    // Always validate
    hooks.addHook(new ValidationHooks());

    // Environment-specific hooks
    if (env === 'production') {
        hooks.addHook(new SlackHooks(process.env.SLACK_WEBHOOK!));
        hooks.addHook(new DatadogMetricsHooks());
        hooks.addHook(new SentryErrorHooks());
        hooks.addHook(new CloudWatchLogsHooks());
    } else if (env === 'staging') {
        hooks.addHook(new SlackHooks(process.env.SLACK_WEBHOOK!));
        hooks.addHook(new FileLoggerHooks('/var/log/migrations-staging.log'));
    } else {
        // Development
        hooks.addHook(new ConsoleLoggingHooks());
    }

    return hooks;
}

// Use in your application
const hooks = createHooks(process.env.NODE_ENV || 'development');
const executor = new MigrationScriptExecutor({ handler,  hooks });

const result = await executor.migrate();

if (result.success) {
    console.log('Migration completed successfully');
    process.exit(0);
} else {
    console.error('Migration failed');
    process.exit(1);
}