📖 7 min read

Sync Patterns Guide#

This guide covers common synchronization patterns and best practices for handling data sync in different scenarios like app startup, user login, and relogin.

Table of Contents#

Initial App Sync#

When your app starts, you want to ensure at least one remote data fetch happens to populate the app with current server data.

Implementation#

// In your app initializer (e.g., future_initializer_pod)
final datum = await Datum.initialize(/* config */);

// Ensure at least one remote data fetch on app start
final datumInstance = datum.fold(
  (l, s) => throw l,
  (r) => r,
);

// If there's a current user, perform an initial sync
final currentUserId = Supabase.instance.client.auth.currentUser?.id;
if (currentUserId != null) {
  try {
    talker.info('Performing initial remote data fetch on app start');
    await datumInstance.synchronize(
      currentUserId,
      options: const DatumSyncOptions(
        direction: SyncDirection.pullThenPush,
        forceFullSync: true,
      ),
    );
    talker.info('Initial remote data fetch completed');
  } catch (e) {
    talker.warning('Initial sync failed, but app continues: $e');
    // Don't crash the app if initial sync fails
  }
}

Key Points#

  • Guaranteed remote call: Ensures fresh data on app start
  • Non-blocking: App continues if sync fails
  • User-aware: Only syncs if user is authenticated
  • Proper logging: Track success/failure

Login/Relogin Sync#

Handle sync when users login or relogin after logout, ensuring they always see fresh data.

Implementation#

// In your auth state listener
_authSubscription = Supabase.instance.client.auth.onAuthStateChange.listen(
  (authState) async {
    if (authState.event == AuthChangeEvent.signedOut) {
      Datum.instance.pauseSync();
      // Navigate to login
    } else if (authState.event == AuthChangeEvent.signedIn) {
      final userId = authState.session?.user.id;
      if (userId != null) {
        // Show loading state
        setState(() => _waitingForInitialSync = true);

        // Setup sync status monitoring
        _updateSyncStatusListener(userId);

        // Resume sync (in case it was paused during logout)
        Datum.instance.resumeSync();

        // Clear sync metadata for fresh data
        final remoteAdapter = Datum.manager<Task>().remoteAdapter;
        if (remoteAdapter is SupabaseRemoteAdapter<Task>) {
          await remoteAdapter.clearSyncMetadata(userId);
        }

        // Perform eager sync
        await Datum.instance.synchronize(
          userId,
          options: const DatumSyncOptions(
            direction: SyncDirection.pullThenPush,
            forceFullSync: true,
          ),
        );
      }
    }
  },
);

UI Loading State#

Widget _buildAuthenticatedBody(String userId) {
  if (_waitingForInitialSync) {
    return const Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          CircularProgressIndicator(),
          SizedBox(height: 16),
          Text('Syncing latest data...'),
        ],
      ),
    );
  }

  // Show actual data
  final tasksAsync = ref.watch(tasksStreamProvider(userId));
  return TaskList(tasksAsync: tasksAsync);
}

Sync Status Monitoring#

void _updateSyncStatusListener(String userId) {
  _syncStatusSubscription.close();
  _syncStatusSubscription = ref.listenManual(
    syncStatusProvider(userId),
    (previous, next) {
      if (_waitingForInitialSync &&
          next != null &&
          next.hasValue &&
          next.value != null &&
          (next.value!.status == DatumSyncStatus.completed ||
           next.value!.status == DatumSyncStatus.idle ||
           next.value!.status == DatumSyncStatus.failed)) {
        setState(() => _waitingForInitialSync = false);
      }
    },
  );
}

Fetch Initial Sync Metadata#

You can fetch sync metadata from the server to understand a user's sync state before performing operations.

Get Remote Sync Metadata#

// Fetch sync metadata from the remote server
final remoteAdapter = Datum.manager<Task>().remoteAdapter;
final remoteMetadata = await remoteAdapter.getSyncMetadata(userId);

if (remoteMetadata != null) {
  print('User last synced: ${remoteMetadata.lastSuccessfulSyncTime}');
  print('Sync status: ${remoteMetadata.syncStatus}');
  print('Total conflicts: ${remoteMetadata.conflictCount}');
  print('Devices synced: ${remoteMetadata.deviceCount}');

  // Check if user has never synced
  if (remoteMetadata.isNeverSynced) {
    print('First time user - will do full sync');
  }

  // Check for conflicts
  if (remoteMetadata.hasConflicts) {
    print('User has ${remoteMetadata.conflictCount} conflicts to resolve');
  }
} else {
  print('No remote sync metadata found - first time sync');
}

Sync Metadata Contents#

The DatumSyncMetadata object contains:

class DatumSyncMetadata {
  final String userId;
  final DateTime? lastSyncTime;           // Last sync attempt
  final DateTime? lastSuccessfulSyncTime; // Last successful sync
  final String? dataHash;                 // Global data integrity hash
  final String? deviceId;                 // Current device ID
  final Map<String, DateTime>? devices;   // All synced devices
  final Map<String, dynamic>? customMetadata; // Custom fields
  final Map<String, DatumEntitySyncDetails>? entityCounts; // Per-entity stats
  final SyncStatus syncStatus;            // Current sync state
  final int conflictCount;                // Number of conflicts
  final String? errorMessage;             // Last error message
  final int retryCount;                   // Failed sync retries
  final int? syncDuration;                // Last sync duration (ms)
}

Use Cases#

  • Welcome screens: Show "Welcome back!" vs "First time setup"
  • Sync progress: Display last sync time and status
  • Conflict alerts: Notify users of pending conflicts
  • Device management: Show which devices have synced
  • Debugging: Inspect sync state for troubleshooting
  • Conditional sync: Skip sync if recently synced

Compare Local vs Remote Metadata#

// Get both local and remote metadata
final localAdapter = Datum.manager<Task>().localAdapter;
final remoteAdapter = Datum.manager<Task>().remoteAdapter;

final localMetadata = await localAdapter.getSyncMetadata(userId);
final remoteMetadata = await remoteAdapter.getSyncMetadata(userId);

// Compare last sync times
if (localMetadata?.lastSuccessfulSyncTime != null &&
    remoteMetadata?.lastSuccessfulSyncTime != null) {

  final localTime = localMetadata!.lastSuccessfulSyncTime!;
  final remoteTime = remoteMetadata!.lastSuccessfulSyncTime!;

  if (localTime.isBefore(remoteTime)) {
    print('Remote has newer data - sync needed');
  } else if (localTime.isAfter(remoteTime)) {
    print('Local has newer data - push needed');
  } else {
    print('Data is in sync');
  }
}

Convenience APIs#

For easier access, you can also use these convenience methods:

// On DatumManager (recommended for single entity)
final metadata = await Datum.manager<Task>().getRemoteSyncMetadata(userId);

// On Datum (for any entity type)
final metadata = await Datum.getRemoteSyncMetadata<Task>(userId);

Force Fresh Data#

Sometimes you need to completely ignore cached sync state and force fresh data from the server.

Clear Sync Metadata#

// Cast to specific adapter type to access clearSyncMetadata
final remoteAdapter = Datum.manager<Task>().remoteAdapter;
if (remoteAdapter is SupabaseRemoteAdapter<Task>) {
  await remoteAdapter.clearSyncMetadata(userId);
}

Force Full Sync Options#

const DatumSyncOptions(
  direction: SyncDirection.pullThenPush,  // Pull first, then push
  forceFullSync: true,                    // Ignore cached timestamps
)

When to Use#

  • Login/Relogin: Ensure users see latest server data
  • Manual refresh: When user explicitly requests fresh data
  • Data inconsistency: When you suspect local data is stale
  • Testing: To verify server data is loading correctly

Sync Status Monitoring#

Monitor sync progress and handle different sync states in your UI.

Provider Setup#

final syncStatusProvider =
    StreamProvider.autoDispose.family<DatumSyncStatusSnapshot?, String>(
  (ref, userId) async* {
    final datum = ref.watch(simpleDatumProvider);
    yield* datum.statusForUser(userId);
  },
);

Status Values#

enum DatumSyncStatus {
  idle,       // No sync in progress
  syncing,    // Sync currently running
  completed,  // Sync finished successfully
  failed,     // Sync failed
  paused,     // Sync is paused
}

UI Integration#

ref.watch(syncStatusProvider(userId)).easyWhen(
  data: (status) {
    if (status?.status == DatumSyncStatus.syncing) {
      return CircularProgressIndicator(
        value: status!.progress > 0 ? status.progress : null,
      );
    }
    return IconButton(icon: Icon(Icons.sync), onPressed: _sync);
  },
  loadingWidget: () => CircularProgressIndicator(),
);

Common Patterns#

1. Loading State Management#

class _MyWidgetState extends State<MyWidget> {
  bool _isLoading = false;

  void _startSync() async {
    setState(() => _isLoading = true);
    try {
      await Datum.instance.synchronize(userId);
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }
}

2. Error Handling#

try {
  final result = await Datum.instance.synchronize(userId);
  if (result.isSuccess) {
    showSuccessSnack('Sync completed: ${result.syncedCount} items');
  } else {
    showErrorSnack('Sync failed: ${result.failedCount} errors');
  }
} catch (e) {
  showErrorSnack('Sync error: $e');
}

3. Background Sync#

// In DatumConfig
DatumConfig(
  autoStartSync: true,
  autoSyncInterval: Duration(minutes: 15),
  // ...
)

4. Manual Sync with Options#

// Pull only
await Datum.instance.synchronize(
  userId,
  options: const DatumSyncOptions(direction: SyncDirection.pullOnly),
);

// Push only
await Datum.instance.synchronize(
  userId,
  options: const DatumSyncOptions(direction: SyncDirection.pushOnly),
);

// Custom batch size
await Datum.instance.synchronize(
  userId,
  options: const DatumSyncOptions(overrideBatchSize: 50),
);

Best Practices#

✅ Do's#

  • Monitor sync status for better UX
  • Handle errors gracefully - don't crash on sync failures
  • Clear metadata when you need guaranteed fresh data
  • Use appropriate directions (pullThenPush for login scenarios)
  • Show loading states during sync operations

❌ Don'ts#

  • Don't block UI indefinitely on sync failures
  • Don't ignore sync status - keep users informed
  • Don't overuse forceFullSync - it's expensive
  • Don't forget to resume sync after pausing
  • Don't access AsyncValue directly - use .value property

🔧 Debugging Tips#

  • Check logs for sync operations and metadata
  • Monitor sync status in dev tools
  • Test relogin scenarios thoroughly
  • Verify metadata clearing is working
  • Use forceFullSync temporarily for debugging