Add additive MotorCloud restore orchestration
This commit is contained in:
parent
83c6bb6ad8
commit
1d565716a7
|
|
@ -0,0 +1,145 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Reference in New Issue