synchronize method

Future<DatumSyncResult<T>> synchronize(
  1. String userId, {
  2. DatumSyncOptions<DatumEntityInterface>? options,
  3. DatumSyncScope? scope,
})

Implementation

Future<DatumSyncResult<T>> synchronize(
  String userId, {
  DatumSyncOptions<DatumEntityInterface>? options,
  DatumSyncScope? scope,
}) async {
  _ensureInitialized();

  if (config.excludedSyncUserIds.contains(userId)) {
    _logger.info('Sync for user $userId skipped: user is excluded from sync.');
    return DatumSyncResult.skipped(
      userId,
      await getPendingCount(userId),
      reason: 'User is excluded from sync',
    );
  }

  _logger.info('🔄 [${T.toString()}] Starting sync for user: $userId');

  return _syncRequestStrategy.execute(
    () async {
      if (_isSyncPaused) {
        _logger.info('Sync for user $userId skipped: manager is paused.');
        return DatumSyncResult.skipped(
          userId,
          await getPendingCount(userId),
          reason: 'Sync is paused',
        );
      }

      // Handle user switching logic before proceeding with synchronization.
      if (_syncEngineInstance.lastActiveUserId != null && _syncEngineInstance.lastActiveUserId != userId) {
        if (config.defaultUserSwitchStrategy == UserSwitchStrategy.promptIfUnsyncedData) {
          final oldUserOps = await _queueManager.getPending(
            _syncEngineInstance.lastActiveUserId ?? '',
          );
          if (oldUserOps.isNotEmpty) {
            throw UserSwitchException(
              oldUserId: _syncEngineInstance.lastActiveUserId,
              newUserId: userId,
              message: 'Cannot switch user while unsynced data exists for the previous user.',
            );
          }
        }
        // Other strategies like syncThenSwitch or clearAndFetch would be handled here.
      }

      try {
        // Merge provided options with defaults from config
        final mergedOptions = _mergeSyncOptions(options);

        // Convert options to the correct type if needed.
        // This handles cases where options might be passed with a different generic type from Datum.
        var typedOptions = mergedOptions != null
            ? DatumSyncOptions<T>(
                includeDeletes: mergedOptions.includeDeletes,
                resolveConflicts: mergedOptions.resolveConflicts,
                forceFullSync: mergedOptions.forceFullSync,
                overrideBatchSize: mergedOptions.overrideBatchSize,
                timeout: mergedOptions.timeout,
                direction: mergedOptions.direction,
                conflictResolver: mergedOptions.conflictResolver is DatumConflictResolver<T> ? mergedOptions.conflictResolver as DatumConflictResolver<T> : null,
                query: mergedOptions.query,
              )
            : null;

        // Allow custom sync direction resolution via callback
        final pendingCount = await getPendingCount(userId);
        final currentDirection = typedOptions?.direction ?? config.defaultSyncDirection;

        if (config.syncDirectionResolver != null) {
          final resolvedDirection = config.syncDirectionResolver!(pendingCount, currentDirection);
          if (resolvedDirection != null && resolvedDirection != currentDirection) {
            _logger.debug('Custom sync direction resolver changed direction from $currentDirection to $resolvedDirection for user $userId');
            final optimizedOptions = typedOptions?.copyWith(direction: resolvedDirection) ?? DatumSyncOptions<T>(direction: resolvedDirection);
            typedOptions = optimizedOptions;
          }
        }

        // If no scope is provided but options contain a query, create a scope from the query
        DatumSyncScope? effectiveScope = scope;
        if (effectiveScope == null && typedOptions?.query != null && typedOptions!.query != const DatumQuery()) {
          effectiveScope = DatumSyncScope(query: typedOptions.query);
        }

        // Check if sync should be skipped based on final direction and pending operations
        // Check if sync should be skipped based on final direction and pending operations
        final finalDirection = typedOptions?.direction ?? config.defaultSyncDirection;
        if (finalDirection == SyncDirection.pushOnly && pendingCount == 0) {
          // If the direction is pushOnly and there are no pending operations,
          // we can skip the sync entirely for this manager.
          _logger.info('Push-only sync for user $userId skipped: no pending operations.');
          return DatumSyncResult.skipped(userId, 0);
        }

        DatumSyncResult<T> result;
        List<DatumSyncEvent<T>> events;

        if (config.useIsolateSync) {
          // Capture dependencies into local variables to avoid capturing 'this' in Isolate.run
          final localAdapterCaptured = localAdapter;
          final remoteAdapterCaptured = remoteAdapter;
          final conflictResolverCaptured = _conflictResolver;
          final queueManagerCaptured = _queueManager;
          final conflictDetectorCaptured = _conflictDetector;
          final loggerCaptured = _logger.getWorkerLogger();
          // Sanitize config to remove unsendable callbacks. Note: copyWith
          // cannot clear a field (null means "unchanged"), so this must use
          // the dedicated sanitizer.
          final configCaptured = config.sanitizedForIsolate<T>();
          final connectivityCaptured = _connectivity;
          final isolateHelperCaptured = _isolateHelper;
          final deviceIdCaptured = deviceId;
          final optionsCaptured = typedOptions;
          final scopeCaptured = effectiveScope;

          // Offload the entire sync process to a background isolate.
          // note: This requires Adapters and other dependencies to be sendable.
          // Spawned via the top-level trampoline — see [_spawnSyncIsolate]
          // for why a closure built here would capture `this`.
          (result, events) = await _spawnSyncIsolate<T>(
            userId,
            localAdapterCaptured,
            remoteAdapterCaptured,
            conflictResolverCaptured,
            queueManagerCaptured,
            conflictDetectorCaptured,
            loggerCaptured,
            configCaptured,
            connectivityCaptured,
            isolateHelperCaptured,
            deviceIdCaptured,
            optionsCaptured,
            scopeCaptured,
          );
        } else {
          // Enforce the configured sync timeout (config.syncTimeout /
          // options.timeout) — previously it was configured everywhere but
          // never applied, so a hung remote call blocked syncs forever (the
          // "already syncing" guard then rejected all future syncs too).
          final effectiveTimeout = typedOptions?.timeout ?? config.syncTimeout;
          final engineFuture = Future.sync(() => _syncEngineInstance.synchronize(
                userId,
                options: typedOptions,
                scope: effectiveScope,
              ));
          var timedOut = false;
          (result, events) = await engineFuture.timeout(
            effectiveTimeout,
            onTimeout: () {
              timedOut = true;
              return (DatumSyncResult<T>.cancelled(userId, 0), <DatumSyncEvent<T>>[]);
            },
          );
          if (timedOut) {
            // Silence the still-running engine future (its loops halt once
            // the status leaves `syncing`) and surface a typed timeout.
            unawaited(engineFuture.then((_) {}, onError: (_, __) {}));
            if (!_statusSubject.isClosed) {
              _statusSubject.add(currentStatus.copyWith(
                status: DatumSyncStatus.failed,
                health: const DatumHealth(status: DatumSyncHealth.error),
              ));
            }
            throw DatumException(
              code: DatumExceptionCode.timeout,
              message: 'Synchronization for user $userId timed out after ${effectiveTimeout.inMilliseconds}ms.',
            );
          }
        }

        _processSyncEvents(events);
        // Persist the result of the sync operation.
        if (!result.wasSkipped) {
          await localAdapter.saveLastSyncResult(userId, result);
          // Also update sync metadata in persistence
          final metadata = await localAdapter.getSyncMetadata(userId);
          if (metadata != null) {
            await persistence?.saveSyncMetadata(userId, metadata);
          }
        }
        return result;
      } on Object catch (e, stack) {
        // Delegate to the shared handler: it processes the events carried
        // inside a SyncExceptionWithEvents and rethrows the original error
        // WITH its original stack trace (the previous inline duplicate of
        // this logic dropped the trace via a bare `throw e.originalError`).
        SyncErrorHandler.handleManagerSyncErrorSync<T>(e, stack, const [], _processSyncEvents);
      }
    },
    isSyncInProgress: () => _syncEngineInstance.isSyncing,
    onSkipped: () {
      _logger.info('Sync for user $userId skipped: another sync is in progress.');
      return DatumSyncResult.skipped(
        userId,
        0, // Can't reliably get pending count here without async, so default to 0.
        reason: 'Sync in progress',
      );
    },
  );
}