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
+7
View File
@@ -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<MinstrelApp> {
// 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);
});
}
+31 -1
View File
@@ -178,6 +178,30 @@ class CachedHomeIndex extends Table {
Set<Column> 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);
}
},
);
}
+298
View File
@@ -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<void> enqueue(String kind, Map<String, dynamic> 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<int> 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<int>('c');
}
}
final mutationQueueProvider =
Provider<MutationQueue>((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<void> 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<String, dynamic> payload;
try {
payload = jsonDecode(next.payload) as Map<String, dynamic>;
} 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<MutationReplayer>((ref) {
final r = MutationReplayer(ref);
ref.onDispose(r.dispose);
r.start();
return r;
});
/// Type signature for kind-specific replay handlers.
typedef _Handler = Future<void> Function(Ref, Map<String, dynamic>);
/// 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<String, _Handler> _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<String>();
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,
};
@@ -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<DiscoverScreen> {
Future<void> _request(LidarrSearchResult row) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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<DiscoverScreen> {
// 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,
));
}
}
+13 -20
View File
@@ -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<LikesApi>((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},
);
}
}
@@ -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<List<QuarantineMineRow>> {
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<void> unflag(String trackId) async {
final db = ref.read(appDbProvider);
final existing = await (db.select(db.cachedQuarantineMine)
@@ -135,13 +137,11 @@ class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
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});
}
}
@@ -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<List<AdminRequest>> {
}
}
/// 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<void> cancel(String id) async {
final api = await ref.read(requestsApiProvider.future);
final current = state.value ?? const <AdminRequest>[];
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});
}
}
}
@@ -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],
},
);
}
};
});