Writing Migration Scripts
Best practices and patterns for writing Firebase Realtime Database migrations.
Table of contents
- Migration File Structure
- Writing the
up()Function - Writing the
down()Function - Best Practices
- Common Patterns
- Using EntityService for Type-Safe Operations
- Testing Migrations
- See Also
Migration File Structure
File Naming Convention
Migration files must follow this naming pattern:
V<timestamp>_<description>.ts
Examples:
V202501010001_create_users.tsV202501010002_add_email_index.tsV202501010003_migrate_user_profiles.ts
Use
Date.now()in JavaScript/TypeScript to generate timestamps, or create them manually in formatYYYYMMDDHHmm.
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 configuringbackupMode: '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:
- Test with Firebase emulator
- Test with a copy of production data
- Verify rollback works correctly
See Testing Guide for detailed instructions.
See Also
- Using EntityService - Complete EntityService guide with examples
- Transactions - Understanding Firebase transaction limitations
- Testing - Testing migrations with Firebase Emulator
- Best Practices - Firebase-specific patterns and tips