Migration Strategies
Handle InstantDB Flutter package updates, data migrations, and schema changes with confidence using proven migration strategies and tools.
Package Updates
Semantic Versioning
InstantDB Flutter follows semantic versioning (semver):
- Patch releases (0.1.1 → 0.1.2): Bug fixes, no breaking changes
- Minor releases (0.1.0 → 0.2.0): New features, backward compatible
- Major releases (0.1.0 → 1.0.0): Breaking changes, requires migration
Safe Update Process
Follow this process for safe updates:
// 1. Check current versionvoid checkCurrentVersion() { // Add to pubspec.yaml temporarily to see current version print('Current InstantDB version: check pubspec.yaml');}
// 2. Read changelog before updating// Always check CHANGELOG.md on GitHub or pub.dev
// 3. Update in development first// Test thoroughly in development before production
// 4. Create backup of critical data if neededFuture<void> backupCriticalData(InstantDB db) async { final criticalEntities = ['users', 'payments', 'orders']; final backup = <String, List<Map<String, dynamic>>>{};
for (final entityType in criticalEntities) { final result = await db.queryOnce({entityType: {}}); backup[entityType] = (result.data?[entityType] as List? ?? []) .cast<Map<String, dynamic>>(); }
// Save backup to file or external storage await _saveBackup(backup);}
Future<void> _saveBackup(Map<String, dynamic> backup) async { // Implementation depends on your backup strategy // Could be local file, cloud storage, etc.}
Breaking Changes Migration
Handle breaking changes systematically:
class MigrationManager { final String fromVersion; final String toVersion;
MigrationManager({ required this.fromVersion, required this.toVersion, });
Future<void> migrate(InstantDB db) async { print('Migrating from $fromVersion to $toVersion');
// Apply migrations based on version ranges if (_shouldApply('0.1.0', '0.2.0')) { await _migrateFrom01To02(db); }
if (_shouldApply('0.2.0', '1.0.0')) { await _migrateFrom02To10(db); }
// Update stored version await _setMigrationVersion(toVersion); }
bool _shouldApply(String minVersion, String maxVersion) { // Implement version comparison logic return _isVersionInRange(fromVersion, minVersion, maxVersion); }
bool _isVersionInRange(String version, String min, String max) { // Simple version comparison - you might want a more robust implementation final versionParts = version.split('.').map(int.parse).toList(); final minParts = min.split('.').map(int.parse).toList(); final maxParts = max.split('.').map(int.parse).toList();
return _compareVersions(versionParts, minParts) >= 0 && _compareVersions(versionParts, maxParts) < 0; }
int _compareVersions(List<int> v1, List<int> v2) { for (int i = 0; i < 3; i++) { final diff = v1[i] - v2[i]; if (diff != 0) return diff; } return 0; }
// Example migration from 0.1.0 to 0.2.0 Future<void> _migrateFrom01To02(InstantDB db) async { print('Applying 0.1.0 → 0.2.0 migration');
// Example: API change in transaction methods // Old: db.transactChunk() → New: db.transact() // This would be handled automatically by the package, // but you might need to update your code
// Example: Schema changes await _updateUserSchema(db); }
Future<void> _migrateFrom02To10(InstantDB db) async { print('Applying 0.2.0 → 1.0.0 migration');
// Major version might have significant changes await _updateAuthSystem(db); await _migratePresenceSystem(db); }
Future<void> _updateUserSchema(InstantDB db) async { // Example: Add new required fields to existing users final users = await db.queryOnce({'users': {}}); final userList = (users.data?['users'] as List? ?? []) .cast<Map<String, dynamic>>();
final operations = <Operation>[];
for (final user in userList) { if (!user.containsKey('profileVersion')) { operations.add( db.update(user['id'], { 'profileVersion': 2, 'preferences': { 'notifications': true, 'theme': 'auto', }, }), ); } }
if (operations.isNotEmpty) { await db.transact(operations); print('Updated ${operations.length} user profiles'); } }
Future<void> _updateAuthSystem(InstantDB db) async { // Example: Migration for auth system changes print('Updating auth system...');
// Handle auth token format changes, session migration, etc. final currentUser = db.auth.currentUser.value; if (currentUser != null) { // Refresh user data with new format await db.auth.refreshUser(); } }
Future<void> _migratePresenceSystem(InstantDB db) async { // Example: Presence API changes print('Migrating presence system...');
// Clean up old presence data that might be incompatible // This is conceptual - actual implementation depends on changes }
Future<void> _setMigrationVersion(String version) async { // Store migration version in local storage final prefs = await SharedPreferences.getInstance(); await prefs.setString('instantdb_migration_version', version); }}
Automated Migration Runner
Create an automated migration system:
class AutoMigrationRunner { static const String _versionKey = 'instantdb_migration_version'; static const String _currentVersion = '1.0.0'; // Your current package version
static Future<void> runMigrationsIfNeeded(InstantDB db) async { final prefs = await SharedPreferences.getInstance(); final storedVersion = prefs.getString(_versionKey);
if (storedVersion == null) { // First install - no migration needed await prefs.setString(_versionKey, _currentVersion); return; }
if (storedVersion != _currentVersion) { print('Migration needed: $storedVersion → $_currentVersion'); await _runMigrations(db, storedVersion, _currentVersion); } }
static Future<void> _runMigrations( InstantDB db, String fromVersion, String toVersion, ) async { try { final migrationManager = MigrationManager( fromVersion: fromVersion, toVersion: toVersion, );
await migrationManager.migrate(db);
// Update stored version final prefs = await SharedPreferences.getInstance(); await prefs.setString(_versionKey, toVersion);
print('Migration completed successfully'); } catch (e) { print('Migration failed: $e'); // Handle migration failure - might need manual intervention rethrow; } }}
// Usage in your app initializationFuture<void> initializeApp() async { final db = await InstantDB.init( appId: 'your-app-id', config: const InstantConfig(syncEnabled: true), );
// Run migrations before using the database await AutoMigrationRunner.runMigrationsIfNeeded(db);
runApp(MyApp(db: db));}
Schema Migrations
Adding New Fields
Safely add new fields to existing entities:
class SchemaMigration { final InstantDB db;
SchemaMigration(this.db);
Future<void> addFieldToEntity({ required String entityType, required String fieldName, required dynamic defaultValue, }) async { print('Adding field $fieldName to $entityType entities');
// Query entities that don't have the new field final result = await db.queryOnce({entityType: {}}); final entities = (result.data?[entityType] as List? ?? []) .cast<Map<String, dynamic>>();
final operations = <Operation>[];
for (final entity in entities) { if (!entity.containsKey(fieldName)) { operations.add( db.update(entity['id'], {fieldName: defaultValue}), ); } }
if (operations.isNotEmpty) { // Process in batches to avoid overwhelming the system await _processBatches(operations, batchSize: 50); print('Added $fieldName to ${operations.length} entities'); } else { print('All $entityType entities already have $fieldName field'); } }
Future<void> _processBatches( List<Operation> operations, { int batchSize = 50, }) async { for (int i = 0; i < operations.length; i += batchSize) { final batch = operations.skip(i).take(batchSize).toList(); await db.transact(batch);
// Small delay between batches to avoid overwhelming the system await Future.delayed(const Duration(milliseconds: 100));
print('Processed batch ${(i / batchSize).ceil() + 1}/${(operations.length / batchSize).ceil()}'); } }
Future<void> removeFieldFromEntity({ required String entityType, required String fieldName, }) async { print('Removing field $fieldName from $entityType entities');
final result = await db.queryOnce({entityType: {}}); final entities = (result.data?[entityType] as List? ?? []) .cast<Map<String, dynamic>>();
final operations = <Operation>[];
for (final entity in entities) { if (entity.containsKey(fieldName)) { final updatedEntity = Map<String, dynamic>.from(entity); updatedEntity.remove(fieldName);
operations.add( db.update(entity['id'], updatedEntity), ); } }
if (operations.isNotEmpty) { await _processBatches(operations); print('Removed $fieldName from ${operations.length} entities'); } }
Future<void> renameField({ required String entityType, required String oldFieldName, required String newFieldName, }) async { print('Renaming field $oldFieldName to $newFieldName in $entityType');
final result = await db.queryOnce({entityType: {}}); final entities = (result.data?[entityType] as List? ?? []) .cast<Map<String, dynamic>>();
final operations = <Operation>[];
for (final entity in entities) { if (entity.containsKey(oldFieldName) && !entity.containsKey(newFieldName)) { final value = entity[oldFieldName]; operations.add( db.update(entity['id'], { newFieldName: value, // Remove old field by setting it to null or omitting it }), ); } }
if (operations.isNotEmpty) { await _processBatches(operations); print('Renamed field in ${operations.length} entities'); } }
Future<void> transformFieldValues({ required String entityType, required String fieldName, required dynamic Function(dynamic oldValue) transformer, }) async { print('Transforming $fieldName values in $entityType');
final result = await db.queryOnce({entityType: {}}); final entities = (result.data?[entityType] as List? ?? []) .cast<Map<String, dynamic>>();
final operations = <Operation>[];
for (final entity in entities) { if (entity.containsKey(fieldName)) { final oldValue = entity[fieldName]; final newValue = transformer(oldValue);
if (oldValue != newValue) { operations.add( db.update(entity['id'], {fieldName: newValue}), ); } } }
if (operations.isNotEmpty) { await _processBatches(operations); print('Transformed $fieldName in ${operations.length} entities'); } }}
// Usage examplesFuture<void> runSchemaMigrations(InstantDB db) async { final migration = SchemaMigration(db);
// Add new field with default value await migration.addFieldToEntity( entityType: 'users', fieldName: 'lastLoginAt', defaultValue: 0, );
// Transform existing data await migration.transformFieldValues( entityType: 'posts', fieldName: 'createdAt', transformer: (oldValue) { // Convert string dates to timestamps if (oldValue is String) { return DateTime.parse(oldValue).millisecondsSinceEpoch; } return oldValue; }, );
// Rename field await migration.renameField( entityType: 'todos', oldFieldName: 'done', newFieldName: 'completed', );}
Data Migrations
Complex Data Transformations
Handle complex data structure changes:
class DataMigrationRunner { final InstantDB db;
DataMigrationRunner(this.db);
Future<void> migrateUserProfiles() async { // Example: Migrate from flat user structure to nested profile print('Migrating user profiles to new structure');
final users = await db.queryOnce({'users': {}}); final userList = (users.data?['users'] as List? ?? []) .cast<Map<String, dynamic>>();
final operations = <Operation>[];
for (final user in userList) { // Check if migration is needed if (user.containsKey('firstName') && !user.containsKey('profile')) { final newProfile = { 'personal': { 'firstName': user['firstName'], 'lastName': user['lastName'], 'dateOfBirth': user['dateOfBirth'], }, 'contact': { 'email': user['email'], 'phone': user['phone'], }, 'preferences': { 'notifications': user['notifications'] ?? true, 'theme': user['theme'] ?? 'auto', 'language': user['language'] ?? 'en', }, };
// Create new structure and remove old fields final updatedUser = <String, dynamic>{ 'profile': newProfile, 'updatedAt': DateTime.now().millisecondsSinceEpoch, };
operations.add(db.update(user['id'], updatedUser)); } }
if (operations.isNotEmpty) { await _processBatchOperations(operations); print('Migrated ${operations.length} user profiles'); } }
Future<void> migratePostsWithTags() async { // Example: Migrate from tag strings to tag objects print('Migrating posts with tags');
final posts = await db.queryOnce({'posts': {}}); final postList = (posts.data?['posts'] as List? ?? []) .cast<Map<String, dynamic>>();
final operations = <Operation>[]; final tagMap = <String, String>{}; // tagName -> tagId
for (final post in postList) { final tags = post['tags'] as List?; if (tags != null && tags.isNotEmpty && tags.first is String) { // Convert string tags to tag objects final newTags = <Map<String, dynamic>>[];
for (final tagName in tags.cast<String>()) { // Get or create tag ID if (!tagMap.containsKey(tagName)) { tagMap[tagName] = db.id();
// Create tag entity operations.add( ...db.create('tags', { 'id': tagMap[tagName]!, 'name': tagName, 'createdAt': DateTime.now().millisecondsSinceEpoch, }), ); }
newTags.add({ 'id': tagMap[tagName]!, 'name': tagName, }); }
// Update post with new tag structure operations.add( db.update(post['id'], { 'tags': newTags, 'updatedAt': DateTime.now().millisecondsSinceEpoch, }), ); } }
if (operations.isNotEmpty) { await _processBatchOperations(operations); print('Migrated ${postList.length} posts and created ${tagMap.length} tags'); } }
Future<void> _processBatchOperations( List<Operation> operations, { int batchSize = 25, }) async { for (int i = 0; i < operations.length; i += batchSize) { final batch = operations.skip(i).take(batchSize).toList();
try { await db.transact(batch); print('Processed batch ${(i / batchSize).floor() + 1}'); } catch (e) { print('Batch failed: $e'); // Decide whether to continue or abort rethrow; }
// Small delay to avoid overwhelming the system await Future.delayed(const Duration(milliseconds: 200)); } }}
Rollback Strategies
Safe Migration with Rollback
Implement rollback capabilities for failed migrations:
class SafeMigrationRunner { final InstantDB db; final List<MigrationStep> _steps = [];
SafeMigrationRunner(this.db);
void addStep(MigrationStep step) { _steps.add(step); }
Future<void> runWithRollback() async { final completedSteps = <MigrationStep>[];
try { for (final step in _steps) { print('Running migration step: ${step.name}'); await step.execute(db); completedSteps.add(step); print('Completed: ${step.name}'); } } catch (e) { print('Migration failed at step: ${completedSteps.length + 1}'); print('Rolling back...');
// Rollback completed steps in reverse order for (final step in completedSteps.reversed) { try { print('Rolling back: ${step.name}'); await step.rollback(db); } catch (rollbackError) { print('Rollback failed for ${step.name}: $rollbackError'); // Continue with other rollbacks } }
rethrow; } }}
abstract class MigrationStep { String get name; Future<void> execute(InstantDB db); Future<void> rollback(InstantDB db);}
class AddFieldMigrationStep implements MigrationStep { final String entityType; final String fieldName; final dynamic defaultValue;
AddFieldMigrationStep({ required this.entityType, required this.fieldName, required this.defaultValue, });
@override String get name => 'Add $fieldName to $entityType';
@override Future<void> execute(InstantDB db) async { final migration = SchemaMigration(db); await migration.addFieldToEntity( entityType: entityType, fieldName: fieldName, defaultValue: defaultValue, ); }
@override Future<void> rollback(InstantDB db) async { final migration = SchemaMigration(db); await migration.removeFieldFromEntity( entityType: entityType, fieldName: fieldName, ); }}
// UsageFuture<void> runSafeMigration(InstantDB db) async { final runner = SafeMigrationRunner(db);
runner.addStep(AddFieldMigrationStep( entityType: 'users', fieldName: 'profileVersion', defaultValue: 2, ));
runner.addStep(CustomMigrationStep( name: 'Update user preferences', executeAction: (db) => _updateUserPreferences(db), rollbackAction: (db) => _revertUserPreferences(db), ));
await runner.runWithRollback();}
Testing Migrations
Migration Testing Framework
Create comprehensive tests for your migrations:
class MigrationTestSuite { late InstantDB testDb;
Future<void> setUp() async { // Create test database instance testDb = await InstantDB.init( appId: 'test-app-id', config: const InstantConfig(syncEnabled: false), // Offline for testing ); }
Future<void> tearDown() async { // Clean up test data await testDb.dispose(); }
Future<void> testUserProfileMigration() async { // 1. Create test data in old format await _createOldFormatUsers();
// 2. Run migration final migration = DataMigrationRunner(testDb); await migration.migrateUserProfiles();
// 3. Verify migration results await _verifyUserProfileMigration(); }
Future<void> _createOldFormatUsers() async { final operations = [ ...testDb.create('users', { 'id': testDb.id(), 'firstName': 'John', 'lastName': 'Doe', 'email': 'john@example.com', 'phone': '+1234567890', 'notifications': true, 'theme': 'dark', }), ...testDb.create('users', { 'id': testDb.id(), 'firstName': 'Jane', 'lastName': 'Smith', 'email': 'jane@example.com', 'phone': '+0987654321', 'notifications': false, 'theme': 'light', }), ];
await testDb.transact(operations); }
Future<void> _verifyUserProfileMigration() async { final result = await testDb.queryOnce({'users': {}}); final users = (result.data?['users'] as List? ?? []) .cast<Map<String, dynamic>>();
for (final user in users) { // Verify new structure exists expect(user['profile'], isNotNull); expect(user['profile']['personal'], isNotNull); expect(user['profile']['contact'], isNotNull); expect(user['profile']['preferences'], isNotNull);
// Verify data was migrated correctly final profile = user['profile'] as Map<String, dynamic>; expect(profile['personal']['firstName'], isNotEmpty); expect(profile['contact']['email'], contains('@'));
// Verify old fields are removed expect(user.containsKey('firstName'), isFalse); expect(user.containsKey('lastName'), isFalse); } }
Future<void> testMigrationRollback() async { // Test that rollback works correctly await _createOldFormatUsers();
final runner = SafeMigrationRunner(testDb); runner.addStep(AddFieldMigrationStep( entityType: 'users', fieldName: 'testField', defaultValue: 'test', ));
// Force a failure in the second step to test rollback runner.addStep(FailingMigrationStep());
try { await runner.runWithRollback(); fail('Migration should have failed'); } catch (e) { // Verify rollback occurred final result = await testDb.queryOnce({'users': {}}); final users = (result.data?['users'] as List? ?? []) .cast<Map<String, dynamic>>();
for (final user in users) { expect(user.containsKey('testField'), isFalse); } } }}
class FailingMigrationStep implements MigrationStep { @override String get name => 'Failing step';
@override Future<void> execute(InstantDB db) async { throw Exception('Intentional failure for testing'); }
@override Future<void> rollback(InstantDB db) async { // Nothing to rollback }}
Best Practices
1. Version Your Migrations
class VersionedMigration { final String version; final String description; final Future<void> Function(InstantDB) migration;
VersionedMigration({ required this.version, required this.description, required this.migration, });}
final migrations = [ VersionedMigration( version: '1.1.0', description: 'Add user preferences', migration: (db) => _addUserPreferences(db), ), VersionedMigration( version: '1.2.0', description: 'Migrate posts to new format', migration: (db) => _migratePostFormat(db), ),];
2. Make Migrations Idempotent
Future<void> idempotentMigration(InstantDB db) async { // Check if migration already applied final users = await db.queryOnce({'users': {'limit': 1}}); final userList = (users.data?['users'] as List? ?? []);
if (userList.isNotEmpty) { final firstUser = userList.first as Map<String, dynamic>; if (firstUser.containsKey('profileVersion')) { print('Migration already applied'); return; } }
// Run migration await _actualMigration(db);}
3. Monitor Migration Performance
Future<void> monitoredMigration(InstantDB db) async { final stopwatch = Stopwatch()..start();
try { await _runMigration(db); print('Migration completed in ${stopwatch.elapsedMilliseconds}ms'); } catch (e) { print('Migration failed after ${stopwatch.elapsedMilliseconds}ms: $e'); rethrow; }}
4. Document Migration Impact
/// Migration: Add user preferences/// Impact: All existing users will get default preferences/// Rollback: Remove preferences field from all users/// Estimated time: ~30 seconds for 10k users/// Dependencies: NoneFuture<void> documentedMigration(InstantDB db) async { // Implementation}
Next Steps
Learn more about maintaining robust InstantDB applications:
- Troubleshooting - Debug migration issues
- Performance Optimization - Optimize migration performance
- Offline Functionality - Handle migrations while offline
- API Reference - Complete API documentation for migrations