Add rejected mutation requeue flow in sync UI

This commit is contained in:
Rijad Zuzo
2026-02-15 23:06:48 +01:00
parent 831c217e29
commit c4b86c18da
5 changed files with 205 additions and 7 deletions
+30 -2
View File
@@ -2,6 +2,35 @@
Updated: 2026-02-15 Updated: 2026-02-15
## Latest Milestone (2026-02-15): Rejected-Mutation Requeue Flow
Extended sync conflict handling so rejected mutations can be retried after local
fixes:
- Expanded sync state model:
- `lib/features/sync/sync_state.dart`
- stores `lastRejectedChanges` alongside rejection reasons
- Updated sync queue repository:
- `lib/features/sync/sync_queue_repository.dart`
- captures rejected envelopes during push
- adds `requeueRejectedChanges()` to move rejected envelopes back into pending
queue
- keeps clear/dismiss behavior for rejected metadata
- Updated sync UX:
- `lib/features/sync/sync_view.dart`
- rejected section now includes:
- entity context (`entityType:entityId`)
- `Requeue` action for manual retry flow
- existing `Dismiss` action
- Added/updated tests:
- `test/features/sync/sync_queue_repository_test.dart`
- verifies rejected envelope capture and requeue behavior
Validation for this milestone:
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-15): Sync Auto-Trigger Backoff ## Latest Milestone (2026-02-15): Sync Auto-Trigger Backoff
Improved sync trigger resilience for repeated failures: Improved sync trigger resilience for repeated failures:
@@ -82,8 +111,7 @@ Validation for this milestone:
- replace no-op scheduler with platform local notifications implementation - replace no-op scheduler with platform local notifications implementation
for Android/iOS/macOS/Windows/Linux/Web-safe fallback. for Android/iOS/macOS/Windows/Linux/Web-safe fallback.
2. Conflict resolution UX: 2. Conflict resolution UX:
- current UX shows and dismisses rejections; still missing guided - reject/requeue is implemented; still missing guided per-entity repair UI.
resolve/retry/inspect flows per entity.
3. Sync trigger maturity: 3. Sync trigger maturity:
- connectivity-aware triggers are still missing. - connectivity-aware triggers are still missing.
4. Test depth: 4. Test depth:
+57 -3
View File
@@ -68,22 +68,39 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
Future<void> applyPushResult(SyncPushResult result, {DateTime? at}) async { Future<void> applyPushResult(SyncPushResult result, {DateTime? at}) async {
final SyncState current = await _currentState(); final SyncState current = await _currentState();
final DateTime now = at ?? DateTime.now(); final DateTime now = at ?? DateTime.now();
final Set<String> completedMutationIds = <String>{ final Set<String> rejectedMutationIds = <String>{
...result.accepted.map((MutationAck ack) => ack.clientMutationId),
...result.rejected.map( ...result.rejected.map(
(MutationRejection rejection) => rejection.clientMutationId, (MutationRejection rejection) => rejection.clientMutationId,
), ),
}; };
final Set<String> completedMutationIds = <String>{
...result.accepted.map((MutationAck ack) => ack.clientMutationId),
...rejectedMutationIds,
};
final List<ChangeEnvelope> rejectedChanges = current.pendingChanges
.where(
(ChangeEnvelope change) =>
rejectedMutationIds.contains(change.clientMutationId),
)
.toList(growable: false);
final List<ChangeEnvelope> pending = current.pendingChanges final List<ChangeEnvelope> pending = current.pendingChanges
.where( .where(
(ChangeEnvelope change) => (ChangeEnvelope change) =>
!completedMutationIds.contains(change.clientMutationId), !completedMutationIds.contains(change.clientMutationId),
) )
.toList(growable: false); .toList(growable: false);
final List<ChangeEnvelope> dedupedRejectedChanges = rejectedChanges
.where(
(ChangeEnvelope change) => !pending.any(
(ChangeEnvelope pendingChange) =>
pendingChange.clientMutationId == change.clientMutationId,
),
)
.toList(growable: false);
final String? rejectionMessage = result.rejected.isEmpty final String? rejectionMessage = result.rejected.isEmpty
? null ? null
: 'Push rejected ${result.rejected.length} change(s).'; : 'Push rejected ${result.rejected.length} change(s). Fix locally and retry.';
await _setState( await _setState(
current.copyWith( current.copyWith(
@@ -93,6 +110,29 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
lastSyncAt: now, lastSyncAt: now,
lastError: rejectionMessage, lastError: rejectionMessage,
lastRejected: result.rejected, lastRejected: result.rejected,
lastRejectedChanges: dedupedRejectedChanges,
),
);
}
/// Requeues rejected changes to pending sync list for manual retry.
Future<void> requeueRejectedChanges() async {
final SyncState current = await _currentState();
if (current.lastRejectedChanges.isEmpty) {
return;
}
final List<ChangeEnvelope> pending = <ChangeEnvelope>[
...current.lastRejectedChanges,
...current.pendingChanges,
];
final List<ChangeEnvelope> dedupedPending = _dedupeByMutationId(pending);
await _setState(
current.copyWith(
pendingChanges: dedupedPending,
lastRejected: const <MutationRejection>[],
lastRejectedChanges: const <ChangeEnvelope>[],
lastError: null,
), ),
); );
} }
@@ -107,6 +147,7 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
lastSyncAt: now, lastSyncAt: now,
lastError: null, lastError: null,
lastRejected: const <MutationRejection>[], lastRejected: const <MutationRejection>[],
lastRejectedChanges: const <ChangeEnvelope>[],
), ),
); );
} }
@@ -123,6 +164,7 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
current.copyWith( current.copyWith(
pendingChanges: const <ChangeEnvelope>[], pendingChanges: const <ChangeEnvelope>[],
lastRejected: const <MutationRejection>[], lastRejected: const <MutationRejection>[],
lastRejectedChanges: const <ChangeEnvelope>[],
), ),
); );
} }
@@ -138,11 +180,23 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
await _setState( await _setState(
current.copyWith( current.copyWith(
lastRejected: const <MutationRejection>[], lastRejected: const <MutationRejection>[],
lastRejectedChanges: const <ChangeEnvelope>[],
lastError: lastError, lastError: lastError,
), ),
); );
} }
List<ChangeEnvelope> _dedupeByMutationId(List<ChangeEnvelope> changes) {
final Set<String> seen = <String>{};
final List<ChangeEnvelope> deduped = <ChangeEnvelope>[];
for (final ChangeEnvelope change in changes) {
if (seen.add(change.clientMutationId)) {
deduped.add(change);
}
}
return deduped;
}
Future<SyncState> _currentState() async { Future<SyncState> _currentState() async {
final SyncState? value = state.asData?.value; final SyncState? value = state.asData?.value;
if (value != null) { if (value != null) {
+15
View File
@@ -11,6 +11,7 @@ class SyncState {
this.lastAttemptAt, this.lastAttemptAt,
this.lastError, this.lastError,
this.lastRejected = const <MutationRejection>[], this.lastRejected = const <MutationRejection>[],
this.lastRejectedChanges = const <ChangeEnvelope>[],
}); });
factory SyncState.fromJson(Map<String, dynamic> json) { factory SyncState.fromJson(Map<String, dynamic> json) {
@@ -31,6 +32,13 @@ class SyncState {
MutationRejection.fromJson(item as Map<String, dynamic>), MutationRejection.fromJson(item as Map<String, dynamic>),
) )
.toList(growable: false), .toList(growable: false),
lastRejectedChanges:
(json['lastRejectedChanges'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic item) =>
ChangeEnvelope.fromJson(item as Map<String, dynamic>),
)
.toList(growable: false),
); );
} }
@@ -40,12 +48,14 @@ class SyncState {
final DateTime? lastAttemptAt; final DateTime? lastAttemptAt;
final String? lastError; final String? lastError;
final List<MutationRejection> lastRejected; final List<MutationRejection> lastRejected;
final List<ChangeEnvelope> lastRejectedChanges;
static const Object _unset = Object(); static const Object _unset = Object();
static const SyncState empty = SyncState( static const SyncState empty = SyncState(
pendingChanges: <ChangeEnvelope>[], pendingChanges: <ChangeEnvelope>[],
lastRejected: <MutationRejection>[], lastRejected: <MutationRejection>[],
lastRejectedChanges: <ChangeEnvelope>[],
); );
SyncState copyWith({ SyncState copyWith({
@@ -55,6 +65,7 @@ class SyncState {
Object? lastAttemptAt = _unset, Object? lastAttemptAt = _unset,
Object? lastError = _unset, Object? lastError = _unset,
List<MutationRejection>? lastRejected, List<MutationRejection>? lastRejected,
List<ChangeEnvelope>? lastRejectedChanges,
}) { }) {
return SyncState( return SyncState(
cursor: identical(cursor, _unset) ? this.cursor : cursor as String?, cursor: identical(cursor, _unset) ? this.cursor : cursor as String?,
@@ -69,6 +80,7 @@ class SyncState {
? this.lastError ? this.lastError
: lastError as String?, : lastError as String?,
lastRejected: lastRejected ?? this.lastRejected, lastRejected: lastRejected ?? this.lastRejected,
lastRejectedChanges: lastRejectedChanges ?? this.lastRejectedChanges,
); );
} }
@@ -84,6 +96,9 @@ class SyncState {
'lastRejected': lastRejected 'lastRejected': lastRejected
.map((MutationRejection rejection) => rejection.toJson()) .map((MutationRejection rejection) => rejection.toJson())
.toList(growable: false), .toList(growable: false),
'lastRejectedChanges': lastRejectedChanges
.map((ChangeEnvelope change) => change.toJson())
.toList(growable: false),
}; };
} }
+56 -2
View File
@@ -172,6 +172,11 @@ class _SyncViewState extends ConsumerState<SyncView> {
'Rejected Changes', 'Rejected Changes',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
TextButton.icon(
onPressed: _busy ? null : _requeueRejected,
icon: const Icon(Icons.replay_rounded),
label: const Text('Requeue'),
),
TextButton.icon( TextButton.icon(
onPressed: _busy ? null : _dismissRejections, onPressed: _busy ? null : _dismissRejections,
icon: const Icon(Icons.done_all_rounded), icon: const Icon(Icons.done_all_rounded),
@@ -183,7 +188,13 @@ class _SyncViewState extends ConsumerState<SyncView> {
...state.lastRejected.map( ...state.lastRejected.map(
(MutationRejection rejection) => Padding( (MutationRejection rejection) => Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
child: _rejectionTile(rejection), child: _rejectionTile(
rejection,
matched: _findRejectedChange(
state.lastRejectedChanges,
rejection.clientMutationId,
),
),
), ),
), ),
], ],
@@ -244,7 +255,10 @@ class _SyncViewState extends ConsumerState<SyncView> {
); );
} }
Widget _rejectionTile(MutationRejection rejection) { Widget _rejectionTile(
MutationRejection rejection, {
required ChangeEnvelope? matched,
}) {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -279,6 +293,13 @@ class _SyncViewState extends ConsumerState<SyncView> {
context, context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
), ),
if (matched != null)
Text(
'${matched.entityType}:${matched.entityId}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -291,6 +312,18 @@ class _SyncViewState extends ConsumerState<SyncView> {
); );
} }
ChangeEnvelope? _findRejectedChange(
List<ChangeEnvelope> changes,
String clientMutationId,
) {
for (final ChangeEnvelope change in changes) {
if (change.clientMutationId == clientMutationId) {
return change;
}
}
return null;
}
String _formatDateTime(DateTime? value) { String _formatDateTime(DateTime? value) {
if (value == null) { if (value == null) {
return 'never'; return 'never';
@@ -354,6 +387,27 @@ class _SyncViewState extends ConsumerState<SyncView> {
} }
} }
Future<void> _requeueRejected() async {
setState(() {
_busy = true;
});
try {
await ref
.read(syncQueueRepositoryProvider.notifier)
.requeueRejectedChanges();
setState(() {
_status = 'Requeued rejected changes. Sync again after local fixes.';
});
} finally {
if (mounted) {
setState(() {
_busy = false;
});
}
}
}
Future<void> _run( Future<void> _run(
Future<SyncRunResult> Function(SyncCoordinator coordinator) action, Future<SyncRunResult> Function(SyncCoordinator coordinator) action,
) async { ) async {
@@ -67,6 +67,7 @@ void main() {
expect(state.pending, isEmpty); expect(state.pending, isEmpty);
expect(state.cursor, 'cursor-2'); expect(state.cursor, 'cursor-2');
expect(state.rejected.length, 1); expect(state.rejected.length, 1);
expect(state.rejectedChanges.length, 1);
expect(state.lastError, contains('Push rejected')); expect(state.lastError, contains('Push rejected'));
}, },
); );
@@ -104,9 +105,52 @@ void main() {
await notifier.clearRejections(); await notifier.clearRejections();
final SyncStateData state = await _readState(container); final SyncStateData state = await _readState(container);
expect(state.rejected, isEmpty); expect(state.rejected, isEmpty);
expect(state.rejectedChanges, isEmpty);
expect(state.lastError, isNull); expect(state.lastError, isNull);
}, },
); );
test(
'requeueRejectedChanges adds rejected envelopes back to pending',
() async {
final ProviderContainer container = ProviderContainer(
overrides: [
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
],
);
addTearDown(container.dispose);
final SyncQueueRepository notifier = container.read(
syncQueueRepositoryProvider.notifier,
);
await notifier.enqueue(_change(id: 'p-9', mutationId: 'cm-x'));
await notifier.applyPushResult(
SyncPushResult(
cursor: 'cursor-10',
accepted: const <MutationAck>[],
rejected: const <MutationRejection>[
MutationRejection(
clientMutationId: 'cm-x',
code: 'VALIDATION_FAILED',
message: 'invalid payload',
),
],
),
);
SyncStateData state = await _readState(container);
expect(state.pending, isEmpty);
expect(state.rejectedChanges.length, 1);
await notifier.requeueRejectedChanges();
state = await _readState(container);
expect(state.pending.length, 1);
expect(state.pending.first.clientMutationId, 'cm-x');
expect(state.rejected, isEmpty);
expect(state.rejectedChanges, isEmpty);
},
);
} }
class SyncStateData { class SyncStateData {
@@ -114,12 +158,14 @@ class SyncStateData {
required this.cursor, required this.cursor,
required this.pending, required this.pending,
required this.rejected, required this.rejected,
required this.rejectedChanges,
required this.lastError, required this.lastError,
}); });
final String? cursor; final String? cursor;
final List<ChangeEnvelope> pending; final List<ChangeEnvelope> pending;
final List<MutationRejection> rejected; final List<MutationRejection> rejected;
final List<ChangeEnvelope> rejectedChanges;
final String? lastError; final String? lastError;
} }
@@ -129,6 +175,7 @@ Future<SyncStateData> _readState(ProviderContainer container) async {
cursor: state.cursor, cursor: state.cursor,
pending: state.pendingChanges, pending: state.pendingChanges,
rejected: state.lastRejected, rejected: state.lastRejected,
rejectedChanges: state.lastRejectedChanges,
lastError: state.lastError, lastError: state.lastError,
); );
} }