Harden sync models and durable outbox identity

This commit is contained in:
bponders12-cell 2026-09-01 16:56:25 -04:00
parent 6bc15fb179
commit ec12bf4dfc
1 changed files with 64 additions and 128 deletions

View File

@ -1,16 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// Strongly typed synchronization statuses with documented lifecycle transitions: /// Persisted synchronization lifecycle.
///
/// [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.
enum SyncStatus { enum SyncStatus {
localOnly, localOnly,
queued, queued,
@ -27,10 +17,11 @@ enum SyncStatus {
bool get isInProgress => this == uploading; bool get isInProgress => this == uploading;
} }
/// Immutable outbox synchronization item referencing an authoritative local ride. /// Immutable outbox item. Raw telemetry is never stored here.
/// Never stores raw sub-second telemetry points internally.
@immutable @immutable
class SyncItem { class SyncItem {
static const int schemaVersion = 2;
final int localRideId; final int localRideId;
final String providerId; final String providerId;
final SyncStatus status; final SyncStatus status;
@ -57,19 +48,16 @@ class SyncItem {
this.bundleLocalPath, this.bundleLocalPath,
}); });
/// Deterministic composite idempotency key preventing duplicate queue entries /// Stable logical identity for one ride/provider destination.
String get key => '${localRideId}_$providerId'; 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) { bool isEligibleForAttempt(DateTime now) {
if (status == SyncStatus.synced || status == SyncStatus.failed || status == SyncStatus.cancelled) { if (status.isTerminal || status == SyncStatus.uploading) return false;
return false;
}
if (status == SyncStatus.uploading) {
return false;
}
if (status == SyncStatus.retryWait && nextRetryAt != null) { if (status == SyncStatus.retryWait && nextRetryAt != null) {
return now.isAfter(nextRetryAt!) || now.isAtSameMomentAs(nextRetryAt!); return !now.isBefore(nextRetryAt!);
} }
return true; return true;
} }
@ -88,6 +76,8 @@ class SyncItem {
String? bundleLocalPath, String? bundleLocalPath,
bool clearLastError = false, bool clearLastError = false,
bool clearNextRetry = false, bool clearNextRetry = false,
bool clearRemoteId = false,
bool clearUploadedAt = false,
}) { }) {
return SyncItem( return SyncItem(
localRideId: localRideId ?? this.localRideId, localRideId: localRideId ?? this.localRideId,
@ -97,14 +87,15 @@ class SyncItem {
attemptCount: attemptCount ?? this.attemptCount, attemptCount: attemptCount ?? this.attemptCount,
lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt, lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt,
nextRetryAt: clearNextRetry ? null : (nextRetryAt ?? this.nextRetryAt), nextRetryAt: clearNextRetry ? null : (nextRetryAt ?? this.nextRetryAt),
uploadedAt: uploadedAt ?? this.uploadedAt, uploadedAt: clearUploadedAt ? null : (uploadedAt ?? this.uploadedAt),
remoteId: remoteId ?? this.remoteId, remoteId: clearRemoteId ? null : (remoteId ?? this.remoteId),
lastError: clearLastError ? null : (lastError ?? this.lastError), lastError: clearLastError ? null : (lastError ?? this.lastError),
bundleLocalPath: bundleLocalPath ?? this.bundleLocalPath, bundleLocalPath: bundleLocalPath ?? this.bundleLocalPath,
); );
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'schemaVersion': schemaVersion,
'localRideId': localRideId, 'localRideId': localRideId,
'providerId': providerId, 'providerId': providerId,
'status': status.name, 'status': status.name,
@ -118,34 +109,50 @@ class SyncItem {
'bundleLocalPath': bundleLocalPath, 'bundleLocalPath': bundleLocalPath,
}; };
factory SyncItem.fromJson(Map<String, dynamic> json) => SyncItem( factory SyncItem.fromJson(Map<String, dynamic> json) {
localRideId: json['localRideId'] as int, final localRideId = (json['localRideId'] as num?)?.toInt();
providerId: json['providerId'] as String, final providerId = json['providerId'];
status: SyncStatus.values.firstWhere( final createdAtRaw = json['createdAt'];
(e) => e.name == json['status'], if (localRideId == null || localRideId <= 0 || providerId is! String || providerId.trim().isEmpty || createdAtRaw is! String) {
orElse: () => SyncStatus.queued, throw const FormatException('Invalid sync item identity or createdAt');
), }
createdAt: DateTime.parse(json['createdAt'] as String),
attemptCount: json['attemptCount'] as int? ?? 0, final statusName = json['status'] as String?;
lastAttemptAt: json['lastAttemptAt'] != null final status = SyncStatus.values.firstWhere(
? DateTime.tryParse(json['lastAttemptAt'] as String) (e) => e.name == statusName,
: null, orElse: () => SyncStatus.queued,
nextRetryAt: json['nextRetryAt'] != null );
? DateTime.tryParse(json['nextRetryAt'] as String) final createdAt = DateTime.tryParse(createdAtRaw);
: null, if (createdAt == null) throw const FormatException('Invalid sync item createdAt');
uploadedAt: json['uploadedAt'] != null
? DateTime.tryParse(json['uploadedAt'] as String) DateTime? parseDate(String key) {
: null, final value = json[key];
remoteId: json['remoteId'] as String?, if (value == null) return null;
lastError: json['lastError'] as String?, if (value is! String) throw FormatException('Invalid $key');
bundleLocalPath: json['bundleLocalPath'] as String?, 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 @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) ||
identical(this, other) ||
other is SyncItem && other is SyncItem &&
runtimeType == other.runtimeType &&
localRideId == other.localRideId && localRideId == other.localRideId &&
providerId == other.providerId && providerId == other.providerId &&
status == other.status && status == other.status &&
@ -155,21 +162,12 @@ class SyncItem {
lastError == other.lastError; lastError == other.lastError;
@override @override
int get hashCode => int get hashCode => Object.hash(localRideId, providerId, status, attemptCount, nextRetryAt, remoteId, lastError);
localRideId.hashCode ^
providerId.hashCode ^
status.hashCode ^
attemptCount.hashCode ^
nextRetryAt.hashCode ^
remoteId.hashCode ^
lastError.hashCode;
@override @override
String toString() => String toString() => 'SyncItem(ride: $localRideId, provider: $providerId, status: ${status.name}, attempts: $attemptCount, nextRetry: $nextRetryAt)';
'SyncItem(ride: $localRideId, provider: $providerId, status: ${status.name}, attempts: $attemptCount, nextRetry: $nextRetryAt)';
} }
/// Result returned from a SyncProvider upload operation
@immutable @immutable
class SyncResult { class SyncResult {
final bool success; final bool success;
@ -188,56 +186,15 @@ class SyncResult {
DateTime? timestamp, DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now(); }) : timestamp = timestamp ?? DateTime.now();
SyncResult.success({ SyncResult.success({required String remoteId, DateTime? timestamp}) : this(success: true, remoteId: remoteId, isRetryable: false, requiresAuth: false, timestamp: timestamp);
required String remoteId, SyncResult.temporaryFailure(String message, {DateTime? timestamp}) : this(success: false, errorMessage: message, isRetryable: true, timestamp: timestamp);
DateTime? timestamp, SyncResult.permanentFailure(String message, {DateTime? timestamp}) : this(success: false, errorMessage: message, isRetryable: false, timestamp: timestamp);
}) : this( SyncResult.authRequired({String message = 'Authentication credentials required for sync', DateTime? timestamp}) : this(success: false, errorMessage: message, isRetryable: true, requiresAuth: true, timestamp: timestamp);
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,
);
@override @override
String toString() => String toString() => 'SyncResult(success: $success, remoteId: $remoteId, error: $errorMessage, retryable: $isRetryable, auth: $requiresAuth)';
'SyncResult(success: $success, remoteId: $remoteId, error: $errorMessage, retryable: $isRetryable, auth: $requiresAuth)';
} }
/// Aggregated synchronization state exposed to Riverpod UI
@immutable @immutable
class SyncManagerState { class SyncManagerState {
final bool isProcessing; final bool isProcessing;
@ -257,25 +214,4 @@ class SyncManagerState {
this.lastError, this.lastError,
this.activeProviderId = 'none', 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,
);
}
} }