feat(cache): outbound mutation queue for offline-resilient REST

User intent (likes, hides, playlist adds, Lidarr requests, cancels)
now persists across network loss. Controllers write their optimistic
local state to drift first, then try the REST call; on failure
the call is enqueued in cached_mutations rather than rolled back.
MutationReplayer drains the queue on connectivity transitions and
a 1-minute periodic tick.

**Infrastructure (schema 8):**
* CachedMutations table — id / kind / payload (JSON) / createdAt
  / lastAttemptAt / attempts. Drop-after-5-attempts semantics: a
  permanently-failing mutation eventually drops, and next sync
  reconciles drift to the server's authoritative state.
* MutationQueue.enqueue / pendingCount
* MutationReplayer.start + .drain — start fires from app.dart's
  postFrameCallback alongside SyncController / Prefetcher / etc.
* Kind registry: like.add / like.remove / quarantine.flag /
  quarantine.unflag / playlist.append / request.create /
  request.cancel — each with a Ref+payload handler that re-fires
  the corresponding REST call.

**Wired surfaces:**
* LikesController.toggle — optimistic drift like/unlike stays
  across REST failure; queues the call. Drops the old rollback.
* MyQuarantineController.flag / .unflag — same pattern. Hide/unhide
  visibly persists offline; replays when back online.
* addToPlaylistActionProvider — now does an optimistic
  cached_playlist_tracks write (position = max + 1) so the
  playlist detail screen shows the new track instantly. Queues
  appendTracks on REST failure.
* DiscoverScreen._request — queues request.create on DioException.
  No drift state for the request itself (myRequestsProvider is
  still REST-only) so the row won't show on /requests until replay
  succeeds — acceptable for v1.
* MyRequestsController.cancel — optimistic in-memory remove no
  longer restores on failure; queues request.cancel instead.

**Test update:**
quarantine_provider_test "flag rolls back on server failure"
renamed and rewritten to assert the new offline behavior:
optimistic drift row persists, mutation is enqueued for replay.

**Out of scope (v2):**
* Playlist create / rename / delete (no Flutter UI exposes these yet)
* Lidarr request optimistic local row (would need a cached_requests
  drift table)
* UI "syncing N pending changes" indicator (operator preference:
  silent unless we find a concrete need)
This commit is contained in:
2026-05-14 18:27:05 -04:00
parent 60085b1368
commit 335940cf23
9 changed files with 447 additions and 60 deletions
@@ -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<void> Function(String playlistId, String tr
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((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],
},
);
}
};
});