Writing Migration Scripts

Best practices and patterns for writing Firebase Realtime Database migrations.

Table of contents

  1. Migration File Structure
    1. File Naming Convention
    2. Class-Based Migration Template
  2. Writing the up() Function
    1. Creating Data
    2. Updating Existing Data
    3. Data Migration
  3. Writing the down() Function
  4. Best Practices
    1. 1. Idempotency
    2. 2. Batch Operations
    3. 3. Error Handling
    4. 4. Validate Data Before Migration
  5. Common Patterns
    1. Adding a New Field to All Records
    2. Restructuring Data
    3. Creating Indexes
  6. Using EntityService for Type-Safe Operations
    1. Why Use EntityService?
    2. Quick Example
    3. Common EntityService Patterns
      1. Creating Initial Data
      2. Adding Fields to All Entities
      3. Querying and Transforming
  7. Testing Migrations
  8. See Also

Migration File Structure

File Naming Convention

Migration files must follow this naming pattern:

V<timestamp>_<description>.ts

Examples:

  • V202501010001_create_users.ts
  • V202501010002_add_email_index.ts
  • V202501010003_migrate_user_profiles.ts

Use Date.now() in JavaScript/TypeScript to generate timestamps, or create them manually in format YYYYMMDDHHmm.

Class-Based Migration Template

import { IRunnableScript, IMigrationInfo } from '@migration-script-runner/core';
import { IFirebaseDB, FirebaseHandler } from '@migration-script-runner/firebase';

export default class YourMigration implements IRunnableScript<IFirebaseDB> {
  async up(
    db: IFirebaseDB,
    info: IMigrationInfo,
    handler: FirebaseHandler
  ): Promise<string> {
    // Your migration code here
    return 'Migration completed successfully';
  }

  async down(
    db: IFirebaseDB,
    info: IMigrationInfo,
    handler: FirebaseHandler
  ): Promise<string> {
    // Your rollback code here
    return 'Migration rolled back successfully';
  }
}

Writing the up() Function

The up() function applies your migration changes.

Creating Data

export default class CreateUsers implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    await db.database.ref(handler.cfg.buildPath('users')).set({
      user1: {
        name: 'John Doe',
        email: 'john@example.com',
        createdAt: Date.now()
      }
    });
    return 'Created users node';
  }
}

Updating Existing Data

export default class AddVerifiedField implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const usersRef = db.database.ref(handler.cfg.buildPath('users'));
    const snapshot = await usersRef.once('value');

    const updates: Record<string, any> = {};
    snapshot.forEach((child) => {
      updates[`${child.key}/verified`] = false;
    });

    await usersRef.update(updates);
    return `Added verified field to ${snapshot.numChildren()} users`;
  }
}

Data Migration

export default class MigrateUserData implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    // Read from old structure
    const oldRef = db.database.ref(handler.cfg.buildPath('oldPath'));
    const oldData = await oldRef.once('value');

    // Transform data
    const newData: Record<string, any> = {};
    oldData.forEach((child) => {
      const value = child.val();
      newData[child.key!] = {
        ...value,
        migratedAt: Date.now()
      };
    });

    // Write to new structure
    await db.database.ref(handler.cfg.buildPath('newPath')).set(newData);

    // Optionally remove old data
    await oldRef.remove();

    return `Migrated ${Object.keys(newData).length} records`;
  }
}

Writing the down() Function

The down() function reverts your migration (optional but recommended).

export default class CreateUsers implements IRunnableScript<IFirebaseDB> {
  async down(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    // Reverse the changes made in up()
    await db.database.ref(handler.cfg.buildPath('users')).remove();
    return 'Removed users node';
  }
}

If you don’t provide a down() function, use backup-based rollback by configuring backupMode: 'full'.

Best Practices

1. Idempotency

Make migrations idempotent (safe to run multiple times):

export default class SetFeatureFlag implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const ref = db.database.ref(handler.cfg.buildPath('config/feature_flag'));
    const snapshot = await ref.once('value');

    // Only set if not already set
    if (!snapshot.exists()) {
      await ref.set(true);
      return 'Feature flag enabled';
    }

    return 'Feature flag already enabled';
  }
}

2. Batch Operations

Use multi-path updates for better performance:

export default class UpdateMultiplePaths implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const updates: Record<string, any> = {};
    updates[handler.cfg.buildPath('users/user1/status')] = 'active';
    updates[handler.cfg.buildPath('users/user2/status')] = 'active';
    updates[handler.cfg.buildPath('meta/lastUpdated')] = Date.now();

    await db.database.ref().update(updates);
    return 'Updated multiple paths atomically';
  }
}

3. Error Handling

Always handle errors appropriately:

export default class SafeMigration implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    try {
      const snapshot = await db.database.ref(handler.cfg.buildPath('users')).once('value');

      if (!snapshot.exists()) {
        throw new Error('Users node does not exist');
      }

      // Migration logic...
      return 'Migration completed';
    } catch (error) {
      console.error('Migration failed:', error);
      throw error; // Re-throw to mark migration as failed
    }
  }
}

4. Validate Data Before Migration

export default class ValidatedMigration implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const snapshot = await db.database.ref(handler.cfg.buildPath('users')).once('value');
    const users = snapshot.val();

    // Validate structure
    if (!users || typeof users !== 'object') {
      throw new Error('Invalid users structure');
    }

    // Proceed with migration...
    return 'Migration completed';
  }
}

Common Patterns

Adding a New Field to All Records

export default class AddEmailField implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const snapshot = await db.database.ref(handler.cfg.buildPath('users')).once('value');
    const updates: Record<string, any> = {};

    snapshot.forEach((child) => {
      updates[`users/${child.key}/email`] = '';
    });

    await db.database.ref(handler.cfg.buildPath('')).update(updates);
    return `Added email field to ${snapshot.numChildren()} users`;
  }
}

Restructuring Data

export default class RestructureUsers implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const oldSnapshot = await db.database.ref(handler.cfg.buildPath('users')).once('value');
    const newStructure: Record<string, any> = {};

    oldSnapshot.forEach((child) => {
      const user = child.val();
      newStructure[child.key!] = {
        profile: {
          name: user.name,
          email: user.email
        },
        settings: {
          notifications: user.notifications || true
        }
      };
    });

    await db.database.ref(handler.cfg.buildPath('users')).set(newStructure);
    return 'Restructured user data';
  }
}

Creating Indexes

export default class CreateEmailIndex implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const usersSnapshot = await db.database.ref(handler.cfg.buildPath('users')).once('value');
    const emailIndex: Record<string, string> = {};

    usersSnapshot.forEach((child) => {
      const user = child.val();
      if (user.email) {
        // Firebase keys cannot contain '.'
        const safeEmail = user.email.replace(/\./g, ',');
        emailIndex[safeEmail] = child.key!;
      }
    });

    await db.database.ref(handler.cfg.buildPath('indexes/email')).set(emailIndex);
    return `Created email index with ${Object.keys(emailIndex).length} entries`;
  }
}

Using EntityService for Type-Safe Operations

For working with collections of entities, EntityService provides a cleaner, type-safe alternative to raw Firebase API calls.

Why Use EntityService?

  • Type Safety - Full TypeScript support with generics
  • Clean API - Simple CRUD methods instead of raw Firebase references
  • Batch Operations - Built-in updateAll() for updating multiple entities
  • Query Support - Find entities by property values with findAllBy()
  • Less Code - Reduce boilerplate in your migrations

Quick Example

Without EntityService (Raw Firebase API):

export default class AddEmailField implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const snapshot = await db.database.ref(handler.cfg.buildPath('users')).once('value');
    const updates: Record<string, any> = {};

    snapshot.forEach((child) => {
      updates[`users/${child.key}/email`] = '';
    });

    await db.database.ref(handler.cfg.buildPath('')).update(updates);
    return `Added email field to ${snapshot.numChildren()} users`;
  }
}

With EntityService (Recommended):

import { EntityService, IEntity } from '@migration-script-runner/firebase';

interface User extends IEntity {
  name: string;
  email?: string;
}

export default class AddEmailField implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const userService = new EntityService<User>(
      db.database,
      handler.cfg.buildPath('users')
    );

    const results = await userService.updateAll((user) => {
      if (user.email !== undefined) return false;
      user.email = '';
      return true;
    });

    return `Added email field to ${results.updated.length} users`;
  }
}

Common EntityService Patterns

Creating Initial Data

import { EntityService, IEntity } from '@migration-script-runner/firebase';

interface User extends IEntity {
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export default class CreateInitialUsers implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const userService = new EntityService<User>(
      db.database,
      handler.cfg.buildPath('users')
    );

    const users = [
      { name: 'Admin', email: 'admin@example.com', role: 'admin' },
      { name: 'User', email: 'user@example.com', role: 'user' }
    ];

    const keys = await Promise.all(users.map(u => userService.create(u)));
    return `Created ${keys.length} users`;
  }
}

Adding Fields to All Entities

export default class AddVerifiedField implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const userService = new EntityService<User>(
      db.database,
      handler.cfg.buildPath('users')
    );

    const results = await userService.updateAll((user) => {
      if (user.verified !== undefined) return false; // Skip if exists
      user.verified = false;
      return true; // Modified
    });

    return `Added verified field to ${results.updated.length} users`;
  }
}

Querying and Transforming

export default class UpgradeAdminUsers implements IRunnableScript<IFirebaseDB> {
  async up(db: IFirebaseDB, info: IMigrationInfo, handler: FirebaseHandler) {
    const userService = new EntityService<User>(
      db.database,
      handler.cfg.buildPath('users')
    );

    // Find all admin users
    const admins = await userService.findAllBy('role', 'admin');

    // Add permissions
    const updatePromises = admins.map(admin =>
      userService.update(admin.key!, { permissions: ['read', 'write', 'delete'] })
    );

    await Promise.all(updatePromises);
    return `Updated ${admins.length} admin users`;
  }
}

Complete Guide: See Using EntityService for comprehensive documentation with 5+ complete examples, best practices, and API reference.


Testing Migrations

Always test migrations before production:

  1. Test with Firebase emulator
  2. Test with a copy of production data
  3. Verify rollback works correctly

See Testing Guide for detailed instructions.


See Also