Harden sync models and durable outbox identity
This commit is contained in:
parent
6bc15fb179
commit
ec12bf4dfc
|
|
@ -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) {
|
||||||
|
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,
|
orElse: () => SyncStatus.queued,
|
||||||
),
|
);
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
final createdAt = DateTime.tryParse(createdAtRaw);
|
||||||
attemptCount: json['attemptCount'] as int? ?? 0,
|
if (createdAt == null) throw const FormatException('Invalid sync item createdAt');
|
||||||
lastAttemptAt: json['lastAttemptAt'] != null
|
|
||||||
? DateTime.tryParse(json['lastAttemptAt'] as String)
|
DateTime? parseDate(String key) {
|
||||||
: null,
|
final value = json[key];
|
||||||
nextRetryAt: json['nextRetryAt'] != null
|
if (value == null) return null;
|
||||||
? DateTime.tryParse(json['nextRetryAt'] as String)
|
if (value is! String) throw FormatException('Invalid $key');
|
||||||
: null,
|
return DateTime.tryParse(value);
|
||||||
uploadedAt: json['uploadedAt'] != null
|
}
|
||||||
? DateTime.tryParse(json['uploadedAt'] as String)
|
|
||||||
: null,
|
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?,
|
remoteId: json['remoteId'] as String?,
|
||||||
lastError: json['lastError'] as String?,
|
lastError: json['lastError'] as String?,
|
||||||
bundleLocalPath: json['bundleLocalPath'] 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue