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:
Vendored
+31
-1
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user