This document describes the migration testing strategy used to ensure database migrations are reliable and reversible.
Database migrations are critical for:
- Rolling out schema changes to production
- Maintaining data consistency
- Enabling rollbacks when needed
- Versioning database state
The migration testing framework ensures:
- Up migrations work on a fresh database
- Down migrations can rollback changes
- No duplicate migration version numbers
- Proper ordering of migration execution
- Comprehensive validation before deployment
-
Migration Runner (
src/utils/migrationRunner.js)- Executes pending migrations in order
- Tracks applied migrations in
schema_migrationstable - Supports down migrations (rollbacks)
- Implements distributed locking for multi-instance safety
-
Migration Test Suite (
scripts/test-migrations.js)- Tests migrations on a fresh database
- Validates up and down migrations
- Detects duplicate version numbers
- Checks migration ordering
-
Migration Validation (
scripts/check-migration-ids.js)- Validates migration file naming
- Checks for version conflicts
- Ensures consistent formatting
-
CI Workflow (
.github/workflows/migrations-ci.yml)- Runs on PR creation and commit
- Tests fresh database migrations
- Validates migration integrity
Migrations use a numeric prefix for ordering:
src/migrations/
001_initial_schema.js
002_add_users_table.js
003_add_transactions_table.js
010_add_recurring_donations.js
The numeric prefix determines execution order:
- Must be unique: No two migrations can have the same prefix
- Must be ordered: Prefixes should be in ascending order
- Format:
[PREFIX]_[description].jswhere PREFIX is 1+ digits
// src/migrations/001_initial_schema.js
module.exports = {
name: 'initial_schema', // Unique identifier
description: 'Create initial database schema',
/**
* Run this migration (upgrade)
* @param {object} db - Database wrapper with run() and query() methods
*/
async up(db) {
await db.run(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
},
/**
* Rollback this migration (downgrade)
* @param {object} db - Database wrapper
*/
async down(db) {
await db.run('DROP TABLE IF EXISTS users');
}
};Migrations run automatically when the application starts:
npm start
# Automatically runs pending migrations before starting server# Apply pending migrations
npm run migrate
# Rollback one migration
npm run migrate:rollback
# Check migration status
npm run migrate:status
# Validate migration IDs (checks for duplicates, ordering)
npm run migrate:check
# Test migrations on fresh database
npm run test:migrationsThe CI pipeline includes comprehensive migration testing:
- Fresh Database Migration - Test all migrations on a clean database
- Migration Validation - Check for duplicates and proper ordering
- Rollback Testing - Verify down migrations work
- Schema Integrity - Validate resulting schema structure
# Test migrations
npm run test:migrations
# Expected output:
# ✓ Should load migration files
# ✓ No duplicate migration prefixes
# ✓ Migrations should be in ascending numeric order
# ✓ All 15 migrations applied successfully
# ✓ Database should have tables after migrations
# ✓ All 15 migrations rolled back successfully# .github/workflows/migrations-ci.yml
name: Migration Tests
on:
pull_request:
paths:
- 'src/migrations/**'
- 'scripts/test-migrations.js'
push:
branches: [main]
jobs:
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:migrationsThe migration system prevents duplicate version numbers:
# BAD: Duplicate prefix
src/migrations/
001_initial.js
002_add_users.js
002_add_posts.js # ✗ Duplicate prefix "002"!
# Error when running migrations:
# Duplicate migration prefix "002": "002_add_users.js" and "002_add_posts.js"Migrations must be in ascending numeric order:
# BAD: Out of order
src/migrations/
001_initial.js
003_add_users.js
002_add_posts.js # ✗ Out of order!
# Good: Proper order
src/migrations/
001_initial.js
002_add_posts.js
003_add_users.js# Check migration IDs for issues
npm run migrate:check
# Output:
# ✓ All migration IDs are valid
# ✓ No duplicate prefixes
# ✓ Migrations in ascending orderEach migration should do one thing:
// GOOD: Single responsibility
module.exports = {
name: 'add_users_table',
async up(db) {
await db.run(`CREATE TABLE users (...)`);
},
async down(db) {
await db.run('DROP TABLE users');
}
};
// BAD: Multiple unrelated changes
module.exports = {
name: 'multiple_changes',
async up(db) {
await db.run('CREATE TABLE users (...)');
await db.run('CREATE TABLE posts (...)');
await db.run('CREATE TABLE comments (...)');
},
};Enable rollbacks by implementing down():
async down(db) {
// Reverse the up migration
await db.run('DROP TABLE users');
}Ensure migrations can rollback:
# Test reversibility locally
npm run migrate # Apply migrations
npm run migrate:rollback # Rollback one
npm run migrate # Re-applyFor data changes, test with actual data:
module.exports = {
name: 'migrate_user_data',
async up(db) {
// Validate data before and after
const before = await db.query('SELECT COUNT(*) FROM users');
await db.run(`
UPDATE users
SET email = LOWER(TRIM(email))
WHERE email IS NOT NULL
`);
const after = await db.query('SELECT COUNT(*) FROM users');
if (before[0].count !== after[0].count) {
throw new Error('Data loss detected during migration');
}
}
};Wrap migrations in transactions for safety:
async up(db) {
// Note: SQLite transactions require raw SQL
await db.run('BEGIN TRANSACTION');
try {
await db.run('CREATE TABLE users (...)');
await db.run('CREATE INDEX idx_users_email ON users(email)');
await db.run('COMMIT');
} catch (error) {
await db.run('ROLLBACK');
throw error;
}
}The migration system includes locking:
// Automatic locking via migrationRunner
// Lock timeout: 30 seconds (configurable via MIGRATION_LOCK_TIMEOUT_MS)
// Lock poll interval: 500ms (configurable via MIGRATION_LOCK_POLL_INTERVAL_MS)Causes:
- Different database state
- Missing environment-specific data
- Concurrent application instances
Solutions:
- Test on fresh database in CI
- Use
npm run test:migrationsbefore deployment - Ensure database is backed up
- Plan deployment window with no concurrent access
Example:
Error: Duplicate migration prefix "005": "005_add_users.js" and "005_add_posts.js"
Solution:
# Rename one migration to unique prefix
mv src/migrations/005_add_posts.js src/migrations/006_add_posts.js
# Update the migration name if needed
# Edit 006_add_posts.js: name: 'add_posts'Example:
Error: Expected migration prefix 006 but found 008
Solution:
# Rename migrations to proper order
mv src/migrations/008_*.js src/migrations/006_*.js
mv src/migrations/007_*.js src/migrations/007_*.js
# Continue until order is correctCauses:
down()not implemented- Data constraints prevent rollback
- Migration depends on other migrations
Solution:
// Implement proper down migration
async down(db) {
// Must reverse up() exactly
// Be careful with data - migrations might delete data
await db.run('DROP TABLE users');
}# 1. Apply all migrations
npm run migrate
# 2. Check status
npm run migrate:status
# Output: 15 migrations applied
# 3. Rollback one migration
npm run migrate:rollback
# 4. Check status again
npm run migrate:status
# Output: 14 migrations applied
# 5. Re-apply migrations
npm run migrate# 1. Backup current database
cp data/stellar_donations.db data/stellar_donations.db.backup
# 2. Delete database
rm data/stellar_donations.db
# 3. Run migrations on fresh database
npm run migrate
# 4. Verify schema
sqlite3 data/stellar_donations.db ".schema"
# 5. Run migration test
npm run test:migrations- All migrations tested locally
-
npm run test:migrationspasses -
npm run migrate:checkpasses - Database backed up
- Rollback plan documented
- Team notified of deployment window
- No concurrent application instances
-
Backup Database
docker-compose exec api cp /app/data/donations.db /app/data/donations.db.backup-$(date +%Y%m%d)
-
Deploy New Version
git pull && npm install && npm run migrate
-
Verify Deployment
curl http://localhost:3000/health
-
Monitor Logs
docker-compose logs -f api | grep -i migration
If migrations fail:
- Verify - Check what went wrong in logs
- Rollback one - Run
npm run migrate:rollback - Test - Verify application works
- Assess - Determine root cause
- Fix - Update migration and retry
# Timeout for acquiring migration lock (ms)
MIGRATION_LOCK_TIMEOUT_MS=30000
# Poll interval while waiting for lock (ms)
MIGRATION_LOCK_POLL_INTERVAL_MS=500
# Database path
DB_PATH=/app/data/donations.db- Migration Runner: See
src/utils/migrationRunner.js - Migration Validation: See
scripts/check-migration-ids.js - Migration Testing: See
scripts/test-migrations.js - Migration Examples: See
src/migrations/ - Database Schema: See
docs/DATABASE_SCHEMA.md - CI Workflow: See
.github/workflows/migrations-ci.yml
For migration issues:
- Run
npm run migrate:checkto validate - Check logs:
docker-compose logs api | grep -i migration - Test on fresh database:
npm run test:migrations - Review recent migration files for issues
- Contact database team for assistance