import 'cloud_reconciliation_service.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; abstract interface class TelemetryBundleRestore { Future 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 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(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> _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 {}, )) .where((_) => false) .toList(); } String? _readObjectKey(Map 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 data; const CloudRideSnapshot(this.cloudRideId, this.data); }