fix(sync): Recover stalled model synchronization - #219
Conversation
Local writes and their pending changes now commit together without making saves wait for the network. Missing local changes are recovered across synchronized models, transfer downloads continue safely after transaction failures, and per-model attempts remain visible even when no records are returned.
Code Review SummaryThis PR implements a robust orphan recovery mechanism and standardizes persistence patterns across the repository layer. It also enhances sync status visibility in the UI and improves network stability through HTTP timeouts. 🚀 Key Improvements
💡 Minor Suggestions
🚨 Critical Issues
|
| ..sort((a, b) => a.entityType.compareTo(b.entityType)); | ||
| final allChanges = await _db.select(_db.localChanges).get(); | ||
|
|
||
| // Quarantined changes are permanently failed and never retry |
There was a problem hiding this comment.
The entity type 'media_file' (defined in MediaSyncHandler.entity) is missing from the switch statement. Users will see the raw entity key instead of a localized string in the Sync History UI.
| // Quarantined changes are permanently failed and never retry | |
| case 'party': | |
| return LocaleKeys.party.tr(); | |
| case 'group': | |
| return LocaleKeys.group.tr(); | |
| case 'media_file': | |
| return LocaleKeys.media.tr(); | |
| case 'transfer': | |
| return LocaleKeys.transfer.tr(); |
| .map((t) => BudgetTargetInput(type: t.type, clientId: t.clientId)) | ||
| .toList(), | ||
| ); | ||
| return (await _composeDto(budget.clientId))!; |
There was a problem hiding this comment.
The null-assertion operator ! is used here. While composition is expected to succeed immediately after an insert, it is safer to handle a potential null DTO or use a more defensive approach to prevent runtime crashes if database constraints or relations are unexpectedly missing.
| return (await _composeDto(budget.clientId))!; | |
| final dto = await _composeDto(budget.clientId); | |
| if (dto == null) throw Exception('Failed to compose Budget DTO'); | |
| return dto; |
| git: | ||
| url: https://github.com/whilesmartflutter/drift_sync.git | ||
| ref: drift_sync_core-v0.3.3 | ||
| ref: 4ac6cf0 |
There was a problem hiding this comment.
@austin047 Once whilesmartflutter/drift_sync#2 is merged, you can retag?
Every repository awaited `delete`, which awaits the remote call, so deleting a record held the UI for a full network round trip. The cubits already remove the row optimistically, but `isDeleting` stayed true until the request settled. Switch to `persistAndDelete`, which returns once the deletion and its outbox entry are durable and leaves delivery to the sync loop - the same shape create and update already use. The Dio instance set no timeouts, so a stalled connection fell through to the OS socket timeout and could hang a request for minutes. Bound connect, send and receive. Also rename the transfer handler override to match `downloadIgnoresFailedDependencies`.
A down-sync failure was rendered as a bare red icon that replaced the timestamp, so the screen said a model was broken without saying why or when it last tried - and unlike upload failures there is no queued change to inspect, so the error was unreachable from the UI entirely. Keep the last-attempted timestamp visible next to the icon, show a one-line excerpt of the error inline, and make the row open a dialog with the full selectable text. Regenerating the localization keys also picked up five export* keys that were already in the JSON but missing from locale_keys.g.dart.
…Delete The ref was still 4ac6cf0, which predates `persistAndDelete` and the `downloadIgnoresFailedDependencies` rename, so CI could not compile the repositories. Still a bare SHA, as before - it moves to drift_sync_core-v0.3.4 once that release is cut.
| ); | ||
| for (final clientId in orphanIds) { | ||
| try { | ||
| final entity = await handler.getLocalByClientId(clientId); |
There was a problem hiding this comment.
The result of getLocalByClientId should be null-checked. If an entity was deleted or changed between the time orphanIds were identified and this iteration, the handler might return null, leading to a crash in the subsequent sync engine logic.
| final entity = await handler.getLocalByClientId(clientId); | |
| final entity = await handler.getLocalByClientId(clientId); | |
| if (entity == null) continue; |
| return RepositoryErrorHandler.handleApiCall(() async { | ||
| final category = await localDataSource.deleteCategory(clientId); | ||
| unawaited(delete(category)); | ||
| final category = await syncHandler.getLocalByClientId(clientId); |
There was a problem hiding this comment.
The syncHandler.getLocalByClientId call can return null if the category does not exist or was already removed. Passing null to persistAndDelete will cause a runtime exception. It is better to verify the entity exists and return a descriptive failure if it is missing.
| final category = await syncHandler.getLocalByClientId(clientId); | |
| final category = await syncHandler.getLocalByClientId(clientId); | |
| if (category == null) return Left(Failure.cacheError('Category not found')); | |
| await persistAndDelete(category); |
| Future<Either<Failure, Unit>> deleteTransaction(String id) async { | ||
| try { | ||
| final transaction = await localDataSource.deleteTransaction(id); | ||
| final transaction = await syncHandler.getLocalByClientId(id); |
There was a problem hiding this comment.
Adding a null check for the transaction before deletion prevents runtime crashes in case of invalid or missing IDs. This ensures the repository returns a clean failure rather than an unhandled exception.
| final transaction = await syncHandler.getLocalByClientId(id); | |
| final transaction = await syncHandler.getLocalByClientId(id); | |
| if (transaction == null) return Left(Failure.cacheError('Transaction not found')); | |
| await persistAndDelete(transaction); |
Fixes reports of transactions, parties, and transfers remaining local or never appearing in Last Sync Status. Local writes now commit with their pending change without waiting for the network, orphan recovery covers every locally writable sync model, and each model shows its latest attempt or failure.