diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index 198008dc..0c39d806 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'cache/cache_filler.dart'; import 'cache/metadata_prefetcher.dart'; +import 'cache/mutation_queue.dart'; import 'cache/prefetcher.dart'; import 'cache/sync_controller.dart'; import 'shared/live_events_dispatcher.dart'; @@ -48,6 +49,12 @@ class _MinstrelAppState extends ConsumerState { // thereafter. Throttled 200ms between requests so it never // competes with user activity. ref.read(cacheFillerProvider); + // Mutation replayer: drains the cached_mutations queue when + // connectivity comes back. Controllers (LikesController, + // MyQuarantineController, the add-to-playlist + request flows) + // enqueue on REST failure so user intent persists across + // network loss instead of getting rolled back. + ref.read(mutationReplayerProvider); }); } diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index b3fca3b1..26b79754 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -178,6 +178,30 @@ class CachedHomeIndex extends Table { Set get primaryKey => {section, position}; } +/// Outbound mutation queue for offline-resilient REST calls. Likes, +/// quarantine flag/unflag, playlist appendTracks, Lidarr request +/// create/cancel all enqueue here on REST failure so the user's +/// intent persists across network loss. MutationReplayer drains on +/// connectivity transitions and a 1-minute periodic tick; entries +/// with attempts >= 5 are skipped (the server treats them as +/// permanent failures and library_changes sync will correct any +/// resulting drift drift on next sync). Schema 8+. +class CachedMutations extends Table { + IntColumn get id => integer().autoIncrement()(); + /// Stable kind string (see mutation_queue.dart constants). The + /// replayer looks up the handler in a kind→Function map; unknown + /// kinds are dropped to avoid getting stuck. + TextColumn get kind => text()(); + /// JSON-encoded payload — args needed to replay the REST call. + /// Shape depends on kind; see mutation_queue.dart. + TextColumn get payload => text()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + /// When the last drain attempt fired against this row. Null until + /// the first attempt. Useful for diagnostic queries. + DateTimeColumn get lastAttemptAt => dateTime().nullable()(); + IntColumn get attempts => integer().withDefault(const Constant(0))(); +} + /// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of /// /api/quarantine/mine — fully denormalized (track + album + artist /// fields inline) because the Hidden tab renders straight from this row @@ -217,12 +241,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } CachedQuarantineMine, CachedHomeIndex, CachedSystemPlaylistsStatus, + CachedMutations, ]) class AppDb extends _$AppDb { AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration => MigrationStrategy( @@ -270,6 +295,11 @@ class AppDb extends _$AppDb { // populates. await m.createTable(cachedSystemPlaylistsStatus); } + if (from < 8) { + // Schema 8: cached_mutations outbound queue. Empty on + // upgrade; populated by controllers when REST calls fail. + await m.createTable(cachedMutations); + } }, ); } diff --git a/flutter_client/lib/cache/mutation_queue.dart b/flutter_client/lib/cache/mutation_queue.dart new file mode 100644 index 00000000..edafab25 --- /dev/null +++ b/flutter_client/lib/cache/mutation_queue.dart @@ -0,0 +1,298 @@ +// Outbound mutation queue for offline-resilient REST calls. +// +// Controllers (LikesController, MyQuarantineController, the add-to- +// playlist sheet, the Discover request flow, the Requests cancel +// action) write their optimistic local state to drift first and try +// the corresponding REST call. On failure they enqueue here. The +// replayer drains the queue when connectivity comes back; the user's +// intent persists across network loss without rolling back their +// visible action. +// +// Why this is separate from SyncController: sync ingests AUTHORITATIVE +// SERVER state into drift. The mutation queue carries USER INTENT +// outbound to the server. They flow in opposite directions and +// shouldn't be conflated — sync wins on conflict (server is the +// source of truth), and a mutation that fails forever (5 attempts) +// is dropped on the trust that next sync will reconcile drift to +// match the server's actual state. +// +// Drop semantics: 5 attempts then drop. The drop is intentional — +// holding onto a forever-failing mutation just delays sync's +// reconciliation. The user's optimistic drift state will diverge +// from server, and the next library_changes delta corrects it. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart' show DioException, DioExceptionType; +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/discover.dart'; +import '../api/endpoints/likes.dart'; +import '../library/library_providers.dart' show dioProvider; +import '../likes/likes_provider.dart' show likesApiProvider; +import '../models/lidarr.dart' show LidarrRequestKind; +import '../playlists/playlists_provider.dart' show playlistsApiProvider; +import '../quarantine/quarantine_provider.dart' show quarantineApiProvider; +import '../requests/requests_provider.dart' show requestsApiProvider; +import 'audio_cache_manager.dart' show appDbProvider; +import 'connectivity_provider.dart'; +import 'db.dart'; + +/// Stable kind constants. Each controller emits one of these via +/// MutationQueue.enqueue; the replayer dispatches by kind. +class MutationKinds { + MutationKinds._(); + static const likeAdd = 'like.add'; + static const likeRemove = 'like.remove'; + static const quarantineFlag = 'quarantine.flag'; + static const quarantineUnflag = 'quarantine.unflag'; + static const playlistAppend = 'playlist.append'; + static const requestCreate = 'request.create'; + static const requestCancel = 'request.cancel'; +} + +class MutationQueue { + MutationQueue(this._ref); + final Ref _ref; + + /// Persist a mutation for replay. Caller has already done the + /// optimistic local mutation (drift write, in-memory state update) + /// — this only records the REST call that needs to fire eventually. + Future enqueue(String kind, Map payload) async { + final db = _ref.read(appDbProvider); + await db.into(db.cachedMutations).insert( + CachedMutationsCompanion.insert( + kind: kind, + payload: jsonEncode(payload), + ), + ); + // Nudge the replayer in case we're online right now. + unawaited(_ref.read(mutationReplayerProvider).drain()); + } + + /// Count of pending mutations (attempts < 5). Diagnostic / future + /// UI surface for a "syncing N pending changes" indicator. + Future pendingCount() async { + final db = _ref.read(appDbProvider); + final rows = await db.customSelect( + 'SELECT COUNT(*) AS c FROM cached_mutations WHERE attempts < 5', + ).get(); + return rows.first.read('c'); + } +} + +final mutationQueueProvider = + Provider((ref) => MutationQueue(ref)); + +class MutationReplayer { + MutationReplayer(this._ref); + final Ref _ref; + + Timer? _timer; + bool _running = false; + bool _disposed = false; + + /// Periodic poll cadence. The connectivity listener catches most + /// transitions; this is the belt-and-suspenders for cases where + /// connectivity_plus misses a state change (e.g. flaky Wi-Fi). + static const _interval = Duration(minutes: 1); + + /// Max replay attempts before we drop a mutation. 5 is enough to + /// span a few reconnection cycles without holding onto a forever- + /// broken request. See file header for the drop rationale. + static const _maxAttempts = 5; + + void start() { + // Drain shortly after launch in case there are queued mutations + // from a prior session that died offline. 3s lets the auth + + // server-url + dio providers finish their async warmup. + Timer(const Duration(seconds: 3), drain); + + // Drain on every connectivity emission that resolves to online. + // ref.listen fires on changes after subscription; we don't get + // the initial value, but the Timer above + the periodic poll + // cover that. + _ref.listen(connectivityProvider, (prev, next) { + final isOnline = next.value ?? false; + if (isOnline) unawaited(drain()); + }); + + _timer = Timer.periodic(_interval, (_) => drain()); + } + + /// Walk the queue oldest-first, replaying each mutation. Stops on + /// any transient network error (we'll retry on the next tick); a + /// permanent error (404 etc.) increments the attempt counter and + /// moves on to the next mutation. This is public so MutationQueue. + /// enqueue can trigger an immediate attempt after writing. + Future drain() async { + if (_disposed || _running) return; + final online = await _ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true); + if (!online) return; + _running = true; + try { + while (!_disposed) { + final db = _ref.read(appDbProvider); + final next = await (db.select(db.cachedMutations) + ..where((t) => t.attempts.isSmallerThanValue(_maxAttempts)) + ..orderBy([(t) => OrderingTerm.asc(t.createdAt)]) + ..limit(1)) + .getSingleOrNull(); + if (next == null) break; + + final handler = _handlers[next.kind]; + if (handler == null) { + debugPrint('mutation_replayer: unknown kind ${next.kind}, dropping'); + await (db.delete(db.cachedMutations) + ..where((t) => t.id.equals(next.id))) + .go(); + continue; + } + + Map payload; + try { + payload = jsonDecode(next.payload) as Map; + } catch (e) { + debugPrint('mutation_replayer: bad payload for ${next.kind}: $e'); + await (db.delete(db.cachedMutations) + ..where((t) => t.id.equals(next.id))) + .go(); + continue; + } + + try { + await handler(_ref, payload); + await (db.delete(db.cachedMutations) + ..where((t) => t.id.equals(next.id))) + .go(); + } on DioException catch (e) { + await (db.update(db.cachedMutations) + ..where((t) => t.id.equals(next.id))) + .write(CachedMutationsCompanion( + attempts: Value(next.attempts + 1), + lastAttemptAt: Value(DateTime.now()), + )); + if (_isTransient(e)) { + // Network is flaky again — bail and try the whole queue + // later. The current mutation will be retried next tick. + break; + } + // Permanent failure (4xx/5xx with a real response). Leave + // the row with bumped attempts and move on to the next so + // one bad mutation doesn't block the others. + } catch (e, st) { + debugPrint('mutation_replayer: unexpected error: $e\n$st'); + await (db.update(db.cachedMutations) + ..where((t) => t.id.equals(next.id))) + .write(CachedMutationsCompanion( + attempts: Value(next.attempts + 1), + lastAttemptAt: Value(DateTime.now()), + )); + } + } + } finally { + _running = false; + } + } + + bool _isTransient(DioException e) { + switch (e.type) { + case DioExceptionType.connectionError: + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return true; + default: + return false; + } + } + + void dispose() { + _disposed = true; + _timer?.cancel(); + } +} + +final mutationReplayerProvider = Provider((ref) { + final r = MutationReplayer(ref); + ref.onDispose(r.dispose); + r.start(); + return r; +}); + +/// Type signature for kind-specific replay handlers. +typedef _Handler = Future Function(Ref, Map); + +/// Kind → handler dispatch. Each handler decodes its payload and +/// re-fires the original REST call. Throws on failure (caught by the +/// replayer's drain loop above). +/// +/// Payload shapes (see also enqueue call sites for the writer side): +/// * like.add / like.remove: {'kind': 'track'|'album'|'artist', 'id': uuid} +/// * quarantine.flag: {'trackId': uuid, 'reason': str, 'notes': str} +/// * quarantine.unflag: {'trackId': uuid} +/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]} +/// * request.create: {full createRequest args; see DiscoverApi} +/// * request.cancel: {'id': uuid} +final Map _handlers = { + MutationKinds.likeAdd: (ref, p) async { + final api = await ref.read(likesApiProvider.future); + await api.like(_likeKindFromString(p['kind'] as String), p['id'] as String); + }, + MutationKinds.likeRemove: (ref, p) async { + final api = await ref.read(likesApiProvider.future); + await api.unlike( + _likeKindFromString(p['kind'] as String), p['id'] as String); + }, + MutationKinds.quarantineFlag: (ref, p) async { + final api = await ref.read(quarantineApiProvider.future); + await api.flag( + p['trackId'] as String, + p['reason'] as String, + notes: (p['notes'] as String?) ?? '', + ); + }, + MutationKinds.quarantineUnflag: (ref, p) async { + final api = await ref.read(quarantineApiProvider.future); + await api.unflag(p['trackId'] as String); + }, + MutationKinds.playlistAppend: (ref, p) async { + final api = await ref.read(playlistsApiProvider.future); + final ids = (p['trackIds'] as List).cast(); + await api.appendTracks(p['playlistId'] as String, ids); + }, + MutationKinds.requestCreate: (ref, p) async { + final dio = await ref.read(dioProvider.future); + final api = DiscoverApi(dio); + await api.createRequest( + kind: _lidarrKindFromString(p['kind'] as String), + artistMbid: p['artistMbid'] as String, + artistName: p['artistName'] as String, + albumMbid: p['albumMbid'] as String?, + albumTitle: p['albumTitle'] as String?, + trackMbid: p['trackMbid'] as String?, + trackTitle: p['trackTitle'] as String?, + ); + }, + MutationKinds.requestCancel: (ref, p) async { + final api = await ref.read(requestsApiProvider.future); + await api.cancel(p['id'] as String); + }, +}; + +LikeKind _likeKindFromString(String s) => switch (s) { + 'album' => LikeKind.album, + 'artist' => LikeKind.artist, + _ => LikeKind.track, + }; + +LidarrRequestKind _lidarrKindFromString(String s) => switch (s) { + 'album' => LidarrRequestKind.album, + 'track' => LidarrRequestKind.track, + _ => LidarrRequestKind.artist, + }; diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart index ff1ea8a2..118045b0 100644 --- a/flutter_client/lib/discover/discover_screen.dart +++ b/flutter_client/lib/discover/discover_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../api/endpoints/discover.dart'; import '../api/errors.dart'; +import '../cache/mutation_queue.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/lidarr.dart'; import '../shared/widgets/main_app_bar_actions.dart'; @@ -48,14 +49,23 @@ class _DiscoverScreenState extends ConsumerState { Future _request(LidarrSearchResult row) async { final fs = Theme.of(context).extension()!; + final args = { + 'kind': _kind.wire, + 'artistMbid': + _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid, + 'artistName': + _kind == LidarrRequestKind.artist ? row.name : row.secondaryText, + 'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null, + 'albumTitle': _kind == LidarrRequestKind.album ? row.name : null, + }; try { final api = await ref.read(_discoverApiProvider.future); await api.createRequest( kind: _kind, - artistMbid: _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid, - artistName: _kind == LidarrRequestKind.artist ? row.name : row.secondaryText, - albumMbid: _kind == LidarrRequestKind.album ? row.mbid : null, - albumTitle: _kind == LidarrRequestKind.album ? row.name : null, + artistMbid: args['artistMbid'] as String, + artistName: args['artistName'] as String, + albumMbid: args['albumMbid'] as String?, + albumTitle: args['albumTitle'] as String?, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -65,12 +75,18 @@ class _DiscoverScreenState extends ConsumerState { // Re-run search to refresh the `requested` flag on the row. _runSearch(); } - } on DioException catch (e) { + } on DioException catch (_) { + // Queue for replay so the user's request persists across + // network loss. We don't have a drift table for in-flight + // requests yet, so the row won't show on the Requests screen + // until replay succeeds — accept that for v1. + await ref + .read(mutationQueueProvider) + .enqueue(MutationKinds.requestCreate, args); if (mounted) { - final code = ApiError.fromDio(e).code; ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Request failed: $code'), - backgroundColor: fs.error, + content: Text('Request queued: ${row.name}'), + backgroundColor: fs.iron, )); } } diff --git a/flutter_client/lib/likes/likes_provider.dart b/flutter_client/lib/likes/likes_provider.dart index 947824ac..ed0bcb3a 100644 --- a/flutter_client/lib/likes/likes_provider.dart +++ b/flutter_client/lib/likes/likes_provider.dart @@ -7,6 +7,7 @@ import '../cache/audio_cache_manager.dart' show appDbProvider; import '../cache/cache_first.dart'; import '../cache/connectivity_provider.dart'; import '../cache/db.dart'; +import '../cache/mutation_queue.dart'; import '../library/library_providers.dart'; final likesApiProvider = FutureProvider((ref) async { @@ -147,26 +148,18 @@ class LikesController { } else { await api.like(kind, id); } - } catch (e, st) { - // Rollback drift - if (wasLiked) { - await db.into(db.cachedLikes).insert( - CachedLikesCompanion.insert( - userId: user.id, - entityType: entityType, - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } else { - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(user.id) & - t.entityType.equals(entityType) & - t.entityId.equals(id))) - .go(); - } - Error.throwWithStackTrace(e, st); + } catch (_) { + // REST failed (network or HTTP). Don't roll back drift — the + // user's intent is to like/unlike, and we want that visible to + // them even when offline. Queue the call for replay; the + // MutationReplayer will retry on next connectivity transition. + // If retries exhaust (5 attempts), the row drops and the next + // SyncController.sync brings drift back in line with the + // server's authoritative state. + await _ref.read(mutationQueueProvider).enqueue( + wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd, + {'kind': entityType, 'id': id}, + ); } } diff --git a/flutter_client/lib/quarantine/quarantine_provider.dart b/flutter_client/lib/quarantine/quarantine_provider.dart index 4ca2b3d6..d84fa334 100644 --- a/flutter_client/lib/quarantine/quarantine_provider.dart +++ b/flutter_client/lib/quarantine/quarantine_provider.dart @@ -8,6 +8,7 @@ import '../api/endpoints/quarantine.dart'; import '../cache/audio_cache_manager.dart' show appDbProvider; import '../cache/connectivity_provider.dart'; import '../cache/db.dart'; +import '../cache/mutation_queue.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/quarantine_mine.dart'; import '../models/track.dart'; @@ -110,17 +111,18 @@ class MyQuarantineController extends AsyncNotifier> { try { final api = await ref.read(quarantineApiProvider.future); await api.flag(track.id, reason, notes: notes); - } catch (e, st) { - // Rollback the optimistic insert; watch() re-emits the prior set. - await (db.delete(db.cachedQuarantineMine) - ..where((t) => t.trackId.equals(track.id))) - .go(); - Error.throwWithStackTrace(e, st); + } catch (_) { + // REST failed — don't roll back; queue for replay so the user's + // "hide this track" intent persists offline. + await ref.read(mutationQueueProvider).enqueue( + MutationKinds.quarantineFlag, + {'trackId': track.id, 'reason': reason, 'notes': notes}, + ); } } - /// Optimistic unflag: removes the drift row, calls server, restores - /// on error. + /// Optimistic unflag: removes the drift row, calls server, queues + /// the call on failure so the unhide intent persists offline. Future unflag(String trackId) async { final db = ref.read(appDbProvider); final existing = await (db.select(db.cachedQuarantineMine) @@ -135,13 +137,11 @@ class MyQuarantineController extends AsyncNotifier> { try { final api = await ref.read(quarantineApiProvider.future); await api.unflag(trackId); - } catch (e, st) { - // Restore the row we just deleted — toCompanion(true) preserves - // every field including the original fetchedAt timestamp. - await db - .into(db.cachedQuarantineMine) - .insertOnConflictUpdate(existing.toCompanion(true)); - Error.throwWithStackTrace(e, st); + } catch (_) { + // Don't restore the drift row; queue the unflag for replay. + await ref + .read(mutationQueueProvider) + .enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId}); } } diff --git a/flutter_client/lib/requests/requests_provider.dart b/flutter_client/lib/requests/requests_provider.dart index 259329c4..a00c47d4 100644 --- a/flutter_client/lib/requests/requests_provider.dart +++ b/flutter_client/lib/requests/requests_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/requests.dart'; +import '../cache/mutation_queue.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/admin_request.dart'; @@ -41,17 +42,20 @@ class MyRequestsController extends AsyncNotifier> { } } - /// Optimistic remove + REST cancel. Restores the row on failure so - /// the user can retry — silent failure would lie about success. + /// Optimistic remove + REST cancel. On failure the row stays + /// removed in-memory and the cancel is queued for replay — this + /// keeps the user's intent visible across network loss instead of + /// restoring a row they explicitly asked to remove. Future cancel(String id) async { final api = await ref.read(requestsApiProvider.future); final current = state.value ?? const []; state = AsyncData(current.where((r) => r.id != id).toList()); try { await api.cancel(id); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); + } catch (_) { + await ref + .read(mutationQueueProvider) + .enqueue(MutationKinds.requestCancel, {'id': id}); } } } diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart index bb5aeef5..68e2391d 100644 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart +++ b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart @@ -1,8 +1,12 @@ +import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../api/endpoints/likes.dart'; +import '../../../cache/audio_cache_manager.dart' show appDbProvider; +import '../../../cache/db.dart'; +import '../../../cache/mutation_queue.dart'; import '../../../likes/likes_provider.dart'; import '../../../models/track.dart'; import '../../../player/player_provider.dart'; @@ -255,7 +259,38 @@ typedef AddToPlaylistAction = Future Function(String playlistId, String tr final addToPlaylistActionProvider = Provider((ref) { return (playlistId, trackId) async { - final api = await ref.read(playlistsApiProvider.future); - await api.appendTracks(playlistId, [trackId]); + final db = ref.read(appDbProvider); + // Optimistic drift write so the playlist detail screen shows the + // new track instantly. The position is best-effort — append after + // the current max for this playlist. The server's authoritative + // position lands on next playlistDetailProvider fetch (SWR), or + // when the queued mutation replays. + final maxPos = await (db.selectOnly(db.cachedPlaylistTracks) + ..addColumns([db.cachedPlaylistTracks.position.max()]) + ..where(db.cachedPlaylistTracks.playlistId.equals(playlistId))) + .getSingleOrNull(); + final nextPos = + ((maxPos?.read(db.cachedPlaylistTracks.position.max()) ?? -1) + 1); + await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate( + CachedPlaylistTracksCompanion.insert( + playlistId: playlistId, + trackId: trackId, + position: drift.Value(nextPos), + ), + ); + + try { + final api = await ref.read(playlistsApiProvider.future); + await api.appendTracks(playlistId, [trackId]); + } catch (_) { + // REST failed — keep the optimistic drift row; queue the call. + await ref.read(mutationQueueProvider).enqueue( + MutationKinds.playlistAppend, + { + 'playlistId': playlistId, + 'trackIds': [trackId], + }, + ); + } }; }); diff --git a/flutter_client/test/quarantine/quarantine_provider_test.dart b/flutter_client/test/quarantine/quarantine_provider_test.dart index d632ddf3..2da87322 100644 --- a/flutter_client/test/quarantine/quarantine_provider_test.dart +++ b/flutter_client/test/quarantine/quarantine_provider_test.dart @@ -92,8 +92,8 @@ void main() { expect(rows.first.reason, 'bad_rip'); }); - test('flag rolls back the drift insert on server failure', skip: _skipDrift, - () async { + test('flag keeps drift optimistic + queues mutation on server failure', + skip: _skipDrift, () async { final db = AppDb(NativeDatabase.memory()); addTearDown(db.close); final api = _StubQuarantineApi(shouldThrow: true); @@ -101,14 +101,18 @@ void main() { addTearDown(container.dispose); await container.read(myQuarantineProvider.future); - await expectLater( - () => container - .read(myQuarantineProvider.notifier) - .flag(_track, 'bad_rip', ''), - throwsException, - ); + // No longer rethrows — flag swallows the REST failure and queues + // for replay so the user's intent persists offline. + await container + .read(myQuarantineProvider.notifier) + .flag(_track, 'bad_rip', ''); final rows = await db.select(db.cachedQuarantineMine).get(); - expect(rows, isEmpty); + expect(rows, hasLength(1), + reason: 'optimistic drift row should persist across REST failure'); + final mutations = await db.select(db.cachedMutations).get(); + expect(mutations, hasLength(1), + reason: 'failed flag should have been enqueued for replay'); + expect(mutations.first.kind, 'quarantine.flag'); }); test('unflag deletes the drift row and calls the server', skip: _skipDrift,