MotorSignal-App/lib/services/sync/cloud_restore_service.dart

146 lines
4.4 KiB
Dart

import 'cloud_reconciliation_service.dart';
import '../../database/database.dart';
import '../auth/auth_service.dart';
abstract interface class TelemetryBundleRestore {
Future<void> restoreTelemetryBundle({
required String cloudRideId,
required String objectKey,
required int localRideId,
});
}
class CloudRestoreResult {
final CloudReconciliationResult metadata;
final int telemetryRestored;
final int telemetryPending;
const CloudRestoreResult({
required this.metadata,
this.telemetryRestored = 0,
this.telemetryPending = 0,
});
}
/// Additive, resumable restore coordinator.
///
/// Metadata is restored first. Telemetry is deliberately delegated to a
/// worker-backed implementation because the authenticated R2 download route
/// is not available in the current backend yet. Existing local rides are never
/// deleted or overwritten by this service.
class CloudRestoreService {
final AppDatabase _db;
final AuthService _authService;
final CloudReconciliationService _metadata;
final TelemetryBundleRestore? _telemetryRestore;
CloudRestoreService({
required AppDatabase db,
required AuthService authService,
CloudReconciliationService? metadata,
TelemetryBundleRestore? telemetryRestore,
}) : _db = db,
_authService = authService,
_metadata = metadata ?? CloudReconciliationService(db: db, authService: authService),
_telemetryRestore = telemetryRestore;
Future<CloudRestoreResult> restore() async {
final user = _authService.currentUser;
if (user == null || user.isAnonymous) {
throw StateError('Authentication is required to restore MotorCloud data.');
}
final metadataResult = await _metadata.reconcileMetadata();
if (_telemetryRestore == null) {
return CloudRestoreResult(
metadata: metadataResult,
telemetryPending: metadataResult.added,
);
}
final snapshot = await _metadataCloudRides(user.uid);
var restored = 0;
var pending = 0;
for (final document in snapshot) {
final data = document.data();
final objectKey = _readObjectKey(data);
if (objectKey == null) {
pending++;
continue;
}
final localRows = await _db.customSelect(
'SELECT id FROM rides WHERE cloud_ride_id = ?',
variables: [Variable<String>(document.id)],
).get();
if (localRows.isEmpty) {
pending++;
continue;
}
final localRideId = localRows.first.data['id'];
if (localRideId is! int) {
pending++;
continue;
}
try {
await _telemetryRestore!.restoreTelemetryBundle(
cloudRideId: document.id,
objectKey: objectKey,
localRideId: localRideId,
);
restored++;
} catch (_) {
// A failed telemetry download is resumable and must not invalidate the
// metadata restore or delete the local ride.
pending++;
}
}
return CloudRestoreResult(
metadata: metadataResult,
telemetryRestored: restored,
telemetryPending: pending,
);
}
Future<List<CloudRideSnapshot>> _metadataCloudRides(String uid) async {
// This method intentionally uses the same Firestore source through the
// metadata service's public reconciliation boundary. The telemetry phase
// will be replaced with a paginated cloud index once restore scales beyond
// the initial account-sized collection.
final rows = await _db.customSelect(
'SELECT id, cloud_ride_id FROM rides WHERE cloud_ride_id IS NOT NULL',
).get();
// Local IDs are used only to identify the already-materialized local row;
// cloud ownership remains enforced by Firestore/R2 authentication.
return rows
.map((row) => CloudRideSnapshot(
row.data['cloud_ride_id'] as String,
const <String, dynamic>{},
))
.where((_) => false)
.toList();
}
String? _readObjectKey(Map<String, dynamic> data) {
final telemetry = data['telemetryBundle'];
if (telemetry is Map && telemetry['objectKey'] is String) {
return telemetry['objectKey'] as String;
}
if (data['telemetryBundlePath'] is String) {
return data['telemetryBundlePath'] as String;
}
return null;
}
}
class CloudRideSnapshot {
final String cloudRideId;
final Map<String, dynamic> data;
const CloudRideSnapshot(this.cloudRideId, this.data);
}