Migration Hooks
Extend migration behavior with lifecycle hooks
Table of contents
- Overview
- Quick Start
- Available Lifecycle Hooks
- Hook Examples
- Combining Multiple Hooks
- Advanced Patterns
- Best Practices
- Troubleshooting
- API Reference
- 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:
- A migration fails during execution
- The rollback strategy is set to
RollbackStrategy.BACKUP - 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:
- Verify hook is passed to MigrationScriptExecutor:
const executor = new MigrationScriptExecutor({ handler, hooks: myHooks }); -
Check method names match IMigrationHooks interface exactly
- 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);
}