Handle Flutter InstantDB package updates, data migrations, and schema changes with confidence using proven migration strategies and tools.
Package Updates
Semantic Versioning
Flutter InstantDB 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 version
void 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 needed
Future<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 initialization
Future<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 examples
Future<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,
);
}
}
// Usage
Future<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: None
Future<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