Harden sync outbox ownership scoping
This commit is contained in:
parent
f31e0b8eaa
commit
a6e732179e
|
|
@ -4,21 +4,38 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
import 'sync_models.dart';
|
||||
|
||||
class SyncOutbox {
|
||||
static const String _outboxStorageKey = 'motorsignal_sync_outbox_manifest_v1';
|
||||
static const String _outboxStorageKeyPrefix = 'motorsignal_sync_outbox_manifest_v2';
|
||||
static const int maxOutboxCapacity = 500;
|
||||
final SharedPreferences _prefs;
|
||||
final String _storageNamespace;
|
||||
final Map<String, SyncItem> _items = {};
|
||||
Future<void> _saveChain = Future<void>.value();
|
||||
bool _isLoaded = false;
|
||||
|
||||
SyncOutbox(this._prefs) { load(); }
|
||||
/// The namespace must be tied to the current Firebase UID (or a local
|
||||
/// signed-out namespace). This prevents one account's sync state from being
|
||||
/// mistaken for another account's state on the same device.
|
||||
SyncOutbox(this._prefs, {String? storageNamespace})
|
||||
: _storageNamespace = _normalizeNamespace(storageNamespace) {
|
||||
load();
|
||||
}
|
||||
|
||||
static String _normalizeNamespace(String? value) {
|
||||
final normalized = (value == null || value.trim().isEmpty) ? 'local' : value.trim();
|
||||
return normalized.replaceAll(RegExp(r'[^a-zA-Z0-9_.-]'), '_');
|
||||
}
|
||||
|
||||
String get storageKey => '${_outboxStorageKeyPrefix}_$_storageNamespace';
|
||||
bool get isLoaded => _isLoaded;
|
||||
int get count => _items.length;
|
||||
|
||||
void load() {
|
||||
_items.clear();
|
||||
final rawJson = _prefs.getString(_outboxStorageKey);
|
||||
if (rawJson == null || rawJson.trim().isEmpty) { _isLoaded = true; return; }
|
||||
final rawJson = _prefs.getString(storageKey);
|
||||
if (rawJson == null || rawJson.trim().isEmpty) {
|
||||
_isLoaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(rawJson);
|
||||
if (decoded is! List) throw const FormatException('Outbox manifest is not a list');
|
||||
|
|
@ -26,27 +43,33 @@ class SyncOutbox {
|
|||
try {
|
||||
final map = entry is Map<String, dynamic> ? entry : Map<String, dynamic>.from(entry as Map);
|
||||
final item = SyncItem.fromJson(map);
|
||||
// An app/process death cannot leave an upload genuinely in progress.
|
||||
// Requeue it so the provider can safely retry its deterministic operation.
|
||||
_items[item.key] = item.status == SyncStatus.uploading
|
||||
? item.copyWith(status: SyncStatus.queued, clearNextRetry: true)
|
||||
: item;
|
||||
} catch (itemError) { debugPrint('[SyncOutbox] Skipped malformed item: $itemError'); }
|
||||
} catch (itemError) {
|
||||
debugPrint('[SyncOutbox] Skipped malformed item: $itemError');
|
||||
}
|
||||
}
|
||||
} catch (e) { debugPrint('[SyncOutbox] Corrupt manifest recovered safely: $e'); _items.clear(); }
|
||||
} catch (e) {
|
||||
debugPrint('[SyncOutbox] Corrupt manifest recovered safely: $e');
|
||||
_items.clear();
|
||||
}
|
||||
_isLoaded = true;
|
||||
}
|
||||
|
||||
Future<bool> _save() {
|
||||
final operation = _saveChain.then((_) async {
|
||||
if (_items.length > maxOutboxCapacity) {
|
||||
final terminal = _items.values.where((i) => i.status.isTerminal).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
final terminal = _items.values.where((i) => i.status.isTerminal).toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
while (_items.length > maxOutboxCapacity && terminal.isNotEmpty) {
|
||||
_items.remove(terminal.removeAt(0).key);
|
||||
}
|
||||
}
|
||||
if (_items.length > maxOutboxCapacity) throw StateError('Sync outbox capacity reached with no evictable terminal items.');
|
||||
return _prefs.setString(_outboxStorageKey, jsonEncode(_items.values.map((e) => e.toJson()).toList()));
|
||||
if (_items.length > maxOutboxCapacity) {
|
||||
throw StateError('Sync outbox capacity reached with no evictable terminal items.');
|
||||
}
|
||||
return _prefs.setString(storageKey, jsonEncode(_items.values.map((e) => e.toJson()).toList()));
|
||||
});
|
||||
_saveChain = operation.then((_) {}, onError: (_) {});
|
||||
return operation;
|
||||
|
|
@ -58,22 +81,44 @@ class SyncOutbox {
|
|||
List<SyncItem> getEligibleItems(DateTime now) => _items.values.where((i) => i.isEligibleForAttempt(now)).toList(growable: false);
|
||||
|
||||
Future<SyncItem> enqueue(int localRideId, String providerId, {DateTime? now}) async {
|
||||
if (localRideId <= 0 || providerId.trim().isEmpty) throw ArgumentError('A positive localRideId and non-empty providerId are required.');
|
||||
if (localRideId <= 0 || providerId.trim().isEmpty) {
|
||||
throw ArgumentError('A positive localRideId and non-empty providerId are required.');
|
||||
}
|
||||
final normalizedProvider = providerId.trim();
|
||||
final key = '$normalizedProvider:$localRideId';
|
||||
final existing = _items[key];
|
||||
if (existing != null) {
|
||||
if (existing.status == SyncStatus.synced || existing.status == SyncStatus.queued || existing.status == SyncStatus.uploading) return existing;
|
||||
if (existing.status == SyncStatus.synced || existing.status == SyncStatus.queued || existing.status == SyncStatus.uploading) {
|
||||
return existing;
|
||||
}
|
||||
final rearmed = existing.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true);
|
||||
_items[key] = rearmed; await _save(); return rearmed;
|
||||
_items[key] = rearmed;
|
||||
await _save();
|
||||
return rearmed;
|
||||
}
|
||||
if (_items.length >= maxOutboxCapacity && !_items.values.any((i) => i.status.isTerminal)) {
|
||||
throw StateError('Sync outbox capacity reached; pending rides were not discarded.');
|
||||
}
|
||||
if (_items.length >= maxOutboxCapacity && !_items.values.any((i) => i.status.isTerminal)) throw StateError('Sync outbox capacity reached; pending rides were not discarded.');
|
||||
final item = SyncItem(localRideId: localRideId, providerId: normalizedProvider, status: SyncStatus.queued, createdAt: now ?? DateTime.now());
|
||||
_items[key] = item; await _save(); return item;
|
||||
_items[key] = item;
|
||||
await _save();
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<void> update(SyncItem item) async {
|
||||
_items[item.key] = item;
|
||||
await _save();
|
||||
}
|
||||
|
||||
Future<void> remove(int localRideId, String providerId) async {
|
||||
if (_items.remove('${providerId.trim()}:$localRideId') != null) await _save();
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_items.clear();
|
||||
_saveChain = Future<void>.value();
|
||||
await _prefs.remove(storageKey);
|
||||
}
|
||||
|
||||
Future<void> update(SyncItem item) async { _items[item.key] = item; await _save(); }
|
||||
Future<void> remove(int localRideId, String providerId) async { if (_items.remove('${providerId.trim()}:$localRideId') != null) await _save(); }
|
||||
Future<void> clear() async { _items.clear(); _saveChain = Future<void>.value(); await _prefs.remove(_outboxStorageKey); }
|
||||
bool isRideSynced(int localRideId, String providerId) => getItem(localRideId, providerId)?.status == SyncStatus.synced;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue