diff --git a/lib/services/sync/sync_models.dart b/lib/services/sync/sync_models.dart index 0c9d13f..f988bcd 100644 --- a/lib/services/sync/sync_models.dart +++ b/lib/services/sync/sync_models.dart @@ -1,16 +1,6 @@ import 'package:flutter/foundation.dart'; -/// Strongly typed synchronization statuses with documented lifecycle transitions: -/// -/// [localOnly] -> Initial state of historical or un-queued local rides. -/// [queued] -> Placed in the outbox queue, ready for processing. -/// [waitingForNetwork] -> Temporarily blocked due to absence of internet connectivity. -/// [uploading] -> Actively transferring payload to the remote provider. -/// [synced] -> Successfully confirmed and uploaded to the remote provider. -/// [failed] -> Permanent failure or maximum retries exceeded. -/// [retryWait] -> Temporary failure waiting for exponential backoff window. -/// [authRequired] -> Provider requires authentication credentials before proceeding. -/// [cancelled] -> Explicitly cancelled by user or invalidated by session. +/// Persisted synchronization lifecycle. enum SyncStatus { localOnly, queued, @@ -27,10 +17,11 @@ enum SyncStatus { bool get isInProgress => this == uploading; } -/// Immutable outbox synchronization item referencing an authoritative local ride. -/// Never stores raw sub-second telemetry points internally. +/// Immutable outbox item. Raw telemetry is never stored here. @immutable class SyncItem { + static const int schemaVersion = 2; + final int localRideId; final String providerId; final SyncStatus status; @@ -57,19 +48,16 @@ class SyncItem { this.bundleLocalPath, }); - /// Deterministic composite idempotency key preventing duplicate queue entries - String get key => '${localRideId}_$providerId'; + /// Stable logical identity for one ride/provider destination. + String get key => '$providerId:$localRideId'; + + /// Stable idempotency identity shared by retries and app restarts. + String get idempotencyKey => key; - /// Whether this item is eligible for execution at the provided [now] timestamp bool isEligibleForAttempt(DateTime now) { - if (status == SyncStatus.synced || status == SyncStatus.failed || status == SyncStatus.cancelled) { - return false; - } - if (status == SyncStatus.uploading) { - return false; - } + if (status.isTerminal || status == SyncStatus.uploading) return false; if (status == SyncStatus.retryWait && nextRetryAt != null) { - return now.isAfter(nextRetryAt!) || now.isAtSameMomentAs(nextRetryAt!); + return !now.isBefore(nextRetryAt!); } return true; } @@ -88,6 +76,8 @@ class SyncItem { String? bundleLocalPath, bool clearLastError = false, bool clearNextRetry = false, + bool clearRemoteId = false, + bool clearUploadedAt = false, }) { return SyncItem( localRideId: localRideId ?? this.localRideId, @@ -97,14 +87,15 @@ class SyncItem { attemptCount: attemptCount ?? this.attemptCount, lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt, nextRetryAt: clearNextRetry ? null : (nextRetryAt ?? this.nextRetryAt), - uploadedAt: uploadedAt ?? this.uploadedAt, - remoteId: remoteId ?? this.remoteId, + uploadedAt: clearUploadedAt ? null : (uploadedAt ?? this.uploadedAt), + remoteId: clearRemoteId ? null : (remoteId ?? this.remoteId), lastError: clearLastError ? null : (lastError ?? this.lastError), bundleLocalPath: bundleLocalPath ?? this.bundleLocalPath, ); } Map toJson() => { + 'schemaVersion': schemaVersion, 'localRideId': localRideId, 'providerId': providerId, 'status': status.name, @@ -118,34 +109,50 @@ class SyncItem { 'bundleLocalPath': bundleLocalPath, }; - factory SyncItem.fromJson(Map json) => SyncItem( - localRideId: json['localRideId'] as int, - providerId: json['providerId'] as String, - status: SyncStatus.values.firstWhere( - (e) => e.name == json['status'], - orElse: () => SyncStatus.queued, - ), - createdAt: DateTime.parse(json['createdAt'] as String), - attemptCount: json['attemptCount'] as int? ?? 0, - lastAttemptAt: json['lastAttemptAt'] != null - ? DateTime.tryParse(json['lastAttemptAt'] as String) - : null, - nextRetryAt: json['nextRetryAt'] != null - ? DateTime.tryParse(json['nextRetryAt'] as String) - : null, - uploadedAt: json['uploadedAt'] != null - ? DateTime.tryParse(json['uploadedAt'] as String) - : null, - remoteId: json['remoteId'] as String?, - lastError: json['lastError'] as String?, - bundleLocalPath: json['bundleLocalPath'] as String?, - ); + factory SyncItem.fromJson(Map json) { + final localRideId = (json['localRideId'] as num?)?.toInt(); + final providerId = json['providerId']; + final createdAtRaw = json['createdAt']; + if (localRideId == null || localRideId <= 0 || providerId is! String || providerId.trim().isEmpty || createdAtRaw is! String) { + throw const FormatException('Invalid sync item identity or createdAt'); + } + + final statusName = json['status'] as String?; + final status = SyncStatus.values.firstWhere( + (e) => e.name == statusName, + orElse: () => SyncStatus.queued, + ); + final createdAt = DateTime.tryParse(createdAtRaw); + if (createdAt == null) throw const FormatException('Invalid sync item createdAt'); + + DateTime? parseDate(String key) { + final value = json[key]; + if (value == null) return null; + if (value is! String) throw FormatException('Invalid $key'); + return DateTime.tryParse(value); + } + + final attempts = (json['attemptCount'] as num?)?.toInt() ?? 0; + if (attempts < 0) throw const FormatException('Negative attemptCount'); + + return SyncItem( + localRideId: localRideId, + providerId: providerId.trim(), + status: status, + createdAt: createdAt, + attemptCount: attempts, + lastAttemptAt: parseDate('lastAttemptAt'), + nextRetryAt: parseDate('nextRetryAt'), + uploadedAt: parseDate('uploadedAt'), + remoteId: json['remoteId'] as String?, + lastError: json['lastError'] as String?, + bundleLocalPath: json['bundleLocalPath'] as String?, + ); + } @override - bool operator ==(Object other) => - identical(this, other) || + bool operator ==(Object other) => identical(this, other) || other is SyncItem && - runtimeType == other.runtimeType && localRideId == other.localRideId && providerId == other.providerId && status == other.status && @@ -155,21 +162,12 @@ class SyncItem { lastError == other.lastError; @override - int get hashCode => - localRideId.hashCode ^ - providerId.hashCode ^ - status.hashCode ^ - attemptCount.hashCode ^ - nextRetryAt.hashCode ^ - remoteId.hashCode ^ - lastError.hashCode; + int get hashCode => Object.hash(localRideId, providerId, status, attemptCount, nextRetryAt, remoteId, lastError); @override - String toString() => - 'SyncItem(ride: $localRideId, provider: $providerId, status: ${status.name}, attempts: $attemptCount, nextRetry: $nextRetryAt)'; + String toString() => 'SyncItem(ride: $localRideId, provider: $providerId, status: ${status.name}, attempts: $attemptCount, nextRetry: $nextRetryAt)'; } -/// Result returned from a SyncProvider upload operation @immutable class SyncResult { final bool success; @@ -188,56 +186,15 @@ class SyncResult { DateTime? timestamp, }) : timestamp = timestamp ?? DateTime.now(); - SyncResult.success({ - required String remoteId, - DateTime? timestamp, - }) : this( - success: true, - remoteId: remoteId, - isRetryable: false, - requiresAuth: false, - timestamp: timestamp, - ); - - SyncResult.temporaryFailure( - String message, { - DateTime? timestamp, - }) : this( - success: false, - errorMessage: message, - isRetryable: true, - requiresAuth: false, - timestamp: timestamp, - ); - - SyncResult.permanentFailure( - String message, { - DateTime? timestamp, - }) : this( - success: false, - errorMessage: message, - isRetryable: false, - requiresAuth: false, - timestamp: timestamp, - ); - - SyncResult.authRequired({ - String message = 'Authentication credentials required for sync', - DateTime? timestamp, - }) : this( - success: false, - errorMessage: message, - isRetryable: true, - requiresAuth: true, - timestamp: timestamp, - ); + SyncResult.success({required String remoteId, DateTime? timestamp}) : this(success: true, remoteId: remoteId, isRetryable: false, requiresAuth: false, timestamp: timestamp); + SyncResult.temporaryFailure(String message, {DateTime? timestamp}) : this(success: false, errorMessage: message, isRetryable: true, timestamp: timestamp); + SyncResult.permanentFailure(String message, {DateTime? timestamp}) : this(success: false, errorMessage: message, isRetryable: false, timestamp: timestamp); + SyncResult.authRequired({String message = 'Authentication credentials required for sync', DateTime? timestamp}) : this(success: false, errorMessage: message, isRetryable: true, requiresAuth: true, timestamp: timestamp); @override - String toString() => - 'SyncResult(success: $success, remoteId: $remoteId, error: $errorMessage, retryable: $isRetryable, auth: $requiresAuth)'; + String toString() => 'SyncResult(success: $success, remoteId: $remoteId, error: $errorMessage, retryable: $isRetryable, auth: $requiresAuth)'; } -/// Aggregated synchronization state exposed to Riverpod UI @immutable class SyncManagerState { final bool isProcessing; @@ -257,25 +214,4 @@ class SyncManagerState { this.lastError, this.activeProviderId = 'none', }); - - SyncManagerState copyWith({ - bool? isProcessing, - int? pendingCount, - int? syncedCount, - int? failedCount, - DateTime? lastSuccessfulSync, - String? lastError, - String? activeProviderId, - bool clearLastError = false, - }) { - return SyncManagerState( - isProcessing: isProcessing ?? this.isProcessing, - pendingCount: pendingCount ?? this.pendingCount, - syncedCount: syncedCount ?? this.syncedCount, - failedCount: failedCount ?? this.failedCount, - lastSuccessfulSync: lastSuccessfulSync ?? this.lastSuccessfulSync, - lastError: clearLastError ? null : (lastError ?? this.lastError), - activeProviderId: activeProviderId ?? this.activeProviderId, - ); - } }