Add rejected mutation requeue flow in sync UI
This commit is contained in:
+30
-2
@@ -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:
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user