From 4eb9935f8e8ca216d5e7dbf3a33e1680f14b80fa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 10 May 2026 11:18:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(flutter/cache):=20cacheFirst=20helper=20+?= =?UTF-8?q?=20drift=E2=86=94model=20adapters=20(#357=20plan=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the provider migrations. cacheFirst wraps the drift.watch() + REST cold-cache fallback pattern: yields cached rows when present, kicks off REST fetch + drift populate when empty + online, yields empty when offline. REST failures swallow to empty so callers can surface errors via toast. adapters.dart adds CachedX → XRef extension methods + reverse XRef.toDrift() companions for Artist/Album/Track/Playlist. Adapters accept some loss of server-derived fields (coverUrl, streamUrl, ownerUsername) — UI already handles empty values; cold-cache fallback briefly shows the real values before drift takes over. cache_first_test covers all 4 branches (non-empty, empty+online, empty+offline, REST failure). adapters_test covers basic round-trips. Both safe to run on CI runner — no drift open required. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/cache/adapters.dart | 109 ++++++++++++++++++ flutter_client/lib/cache/cache_first.dart | 49 ++++++++ flutter_client/test/cache/adapters_test.dart | 70 +++++++++++ .../test/cache/cache_first_test.dart | 75 ++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 flutter_client/lib/cache/adapters.dart create mode 100644 flutter_client/lib/cache/cache_first.dart create mode 100644 flutter_client/test/cache/adapters_test.dart create mode 100644 flutter_client/test/cache/cache_first_test.dart diff --git a/flutter_client/lib/cache/adapters.dart b/flutter_client/lib/cache/adapters.dart new file mode 100644 index 00000000..65ada48f --- /dev/null +++ b/flutter_client/lib/cache/adapters.dart @@ -0,0 +1,109 @@ +// Drift row → model adapters and reverse-write adapters for populating +// drift from REST responses (#357 plan C). +// +// The cache loses some server-derived fields: +// - ArtistRef.coverUrl (server computes from most-recent album) +// - AlbumRef.coverUrl (server-emitted derived path) +// - TrackRef.streamUrl (could be reconstructed but kept empty for clarity) +// UI already handles empty coverUrl/streamUrl gracefully. REST cold-cache +// fallback briefly shows the real values before drift takes over. + +import 'package:drift/drift.dart' as drift; + +import '../models/album.dart'; +import '../models/artist.dart'; +import '../models/playlist.dart'; +import '../models/track.dart'; +import 'db.dart'; + +extension CachedArtistAdapter on CachedArtist { + ArtistRef toRef() => ArtistRef( + id: id, + name: name, + sortName: sortName, + ); +} + +extension ArtistRefDriftWrite on ArtistRef { + CachedArtistsCompanion toDrift() => CachedArtistsCompanion.insert( + id: id, + name: name, + sortName: sortName.isNotEmpty ? sortName : name, + ); +} + +extension CachedAlbumAdapter on CachedAlbum { + /// `artistName` is supplied by the joined CachedArtists row at query time. + AlbumRef toRef({String artistName = ''}) => AlbumRef( + id: id, + title: title, + sortTitle: sortTitle, + artistId: artistId, + artistName: artistName, + ); +} + +extension AlbumRefDriftWrite on AlbumRef { + CachedAlbumsCompanion toDrift() => CachedAlbumsCompanion.insert( + id: id, + artistId: artistId, + title: title, + sortTitle: sortTitle.isNotEmpty ? sortTitle : title, + ); +} + +extension CachedTrackAdapter on CachedTrack { + /// `artistName` and `albumTitle` come from joined rows. + TrackRef toRef({String artistName = '', String albumTitle = ''}) => TrackRef( + id: id, + title: title, + albumId: albumId, + albumTitle: albumTitle, + artistId: artistId, + artistName: artistName, + trackNumber: trackNumber, + discNumber: discNumber, + durationSec: durationMs ~/ 1000, + ); +} + +extension TrackRefDriftWrite on TrackRef { + CachedTracksCompanion toDrift() => CachedTracksCompanion.insert( + id: id, + albumId: albumId, + artistId: artistId, + title: title, + durationMs: drift.Value(durationSec * 1000), + trackNumber: drift.Value(trackNumber), + discNumber: drift.Value(discNumber), + ); +} + +extension CachedPlaylistAdapter on CachedPlaylist { + /// `ownerUsername` is server-derived; cache stores the userId only. + /// Pass empty unless a join supplies it. + Playlist toRef({String ownerUsername = ''}) => Playlist( + id: id, + userId: userId, + name: name, + description: description, + isPublic: isPublic, + systemVariant: null, // not tracked in cache + trackCount: trackCount, + coverUrl: '', // server-derived; cache doesn't persist + ownerUsername: ownerUsername, + createdAt: '', + updatedAt: '', + ); +} + +extension PlaylistDriftWrite on Playlist { + CachedPlaylistsCompanion toDrift() => CachedPlaylistsCompanion.insert( + id: id, + userId: userId, + name: name, + description: drift.Value(description), + isPublic: drift.Value(isPublic), + trackCount: drift.Value(trackCount), + ); +} diff --git a/flutter_client/lib/cache/cache_first.dart b/flutter_client/lib/cache/cache_first.dart new file mode 100644 index 00000000..8af48845 --- /dev/null +++ b/flutter_client/lib/cache/cache_first.dart @@ -0,0 +1,49 @@ +// Drift-first reactive read pattern with REST cold-cache fallback (#357 plan C). +// +// Subscribes to a drift watch() stream. On each emission: +// - non-empty → map to result type T and yield +// - empty + online → fetch via REST, populate drift, await re-emission +// - empty + offline → yield mapped empty result (UI shows empty state) +// +// The pattern lets every read provider trust drift as the source of +// truth. SyncController keeps drift fresh in the background; widget +// rebuilds happen automatically as drift writes propagate via watch(). + +import 'dart:async'; + +/// Wraps the watch + cold-cache fallback pattern. Generic over: +/// D — the drift row type (or TypedResult for joins) +/// T — the result type the caller wants (e.g. List) +/// +/// `fetchAndPopulate` is invoked when drift is empty AND `isOnline()` +/// returns true. It must populate drift via its own side-effect; the +/// drift watch() stream will re-emit and this helper yields the +/// populated rows on the next iteration. +/// +/// REST failures are swallowed — the helper falls through to yielding +/// the empty result. Caller is responsible for surfacing errors via +/// toast etc. +Stream cacheFirst({ + required Stream> driftStream, + required Future Function() fetchAndPopulate, + required T Function(List) toResult, + required Future Function() isOnline, +}) async* { + await for (final rows in driftStream) { + if (rows.isNotEmpty) { + yield toResult(rows); + continue; + } + if (await isOnline()) { + try { + await fetchAndPopulate(); + // The drift watch() stream re-emits with the populated rows on + // the next loop iteration. Don't yield here. + } catch (_) { + yield toResult(rows); // empty result; caller surfaces error + } + } else { + yield toResult(rows); // empty result; offline + } + } +} diff --git a/flutter_client/test/cache/adapters_test.dart b/flutter_client/test/cache/adapters_test.dart new file mode 100644 index 00000000..a17f7354 --- /dev/null +++ b/flutter_client/test/cache/adapters_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/cache/adapters.dart'; +import 'package:minstrel/models/album.dart'; +import 'package:minstrel/models/artist.dart'; +import 'package:minstrel/models/playlist.dart'; +import 'package:minstrel/models/track.dart'; + +void main() { + test('ArtistRef.toDrift preserves id + name + sortName', () { + const ref = ArtistRef(id: 'a1', name: 'Boards of Canada', sortName: 'Boards of Canada'); + final companion = ref.toDrift(); + expect(companion.id.value, 'a1'); + expect(companion.name.value, 'Boards of Canada'); + expect(companion.sortName.value, 'Boards of Canada'); + }); + + test('ArtistRef.toDrift falls back to name when sortName is empty', () { + const ref = ArtistRef(id: 'a1', name: 'The Album Leaf'); + final companion = ref.toDrift(); + expect(companion.sortName.value, 'The Album Leaf'); + }); + + test('AlbumRef.toDrift preserves id + title + artistId', () { + const ref = AlbumRef(id: 'al1', title: 'Geogaddi', artistId: 'ar1'); + final companion = ref.toDrift(); + expect(companion.id.value, 'al1'); + expect(companion.title.value, 'Geogaddi'); + expect(companion.artistId.value, 'ar1'); + }); + + test('TrackRef.toDrift converts seconds to ms + preserves track/disc', () { + const ref = TrackRef( + id: 't1', + title: 'Roygbiv', + albumId: 'al1', + artistId: 'ar1', + durationSec: 137, + trackNumber: 4, + discNumber: 1, + ); + final companion = ref.toDrift(); + expect(companion.id.value, 't1'); + expect(companion.durationMs.value, 137 * 1000); + expect(companion.trackNumber.value, 4); + expect(companion.discNumber.value, 1); + }); + + test('Playlist.toDrift preserves id + userId + name', () { + const p = Playlist( + id: 'p1', + userId: 'u1', + name: 'My Mix', + description: 'a great mix', + isPublic: true, + systemVariant: null, + trackCount: 12, + coverUrl: '', + ownerUsername: 'alice', + createdAt: '', + updatedAt: '', + ); + final companion = p.toDrift(); + expect(companion.id.value, 'p1'); + expect(companion.userId.value, 'u1'); + expect(companion.name.value, 'My Mix'); + expect(companion.isPublic.value, true); + expect(companion.trackCount.value, 12); + }); +} diff --git a/flutter_client/test/cache/cache_first_test.dart b/flutter_client/test/cache/cache_first_test.dart new file mode 100644 index 00000000..f98f7db9 --- /dev/null +++ b/flutter_client/test/cache/cache_first_test.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:minstrel/cache/cache_first.dart'; + +void main() { + test('non-empty drift emission passes through toResult', () async { + final controller = StreamController>(); + final results = cacheFirst( + driftStream: controller.stream, + fetchAndPopulate: () async => fail('should not fetch when rows present'), + toResult: (rows) => rows.fold(0, (a, b) => a + b), + isOnline: () async => true, + ); + + final firstFuture = results.first; + controller.add([1, 2, 3]); + expect(await firstFuture, 6); + await controller.close(); + }); + + test('empty drift emission + online triggers fetchAndPopulate', () async { + final controller = StreamController>(); + var fetchCalled = 0; + final results = cacheFirst( + driftStream: controller.stream, + fetchAndPopulate: () async { + fetchCalled++; + controller.add([42]); // simulate populate causing re-emission + }, + toResult: (rows) => rows.fold(0, (a, b) => a + b), + isOnline: () async => true, + ); + + final firstFuture = results.first; + controller.add([]); + expect(await firstFuture, 42); + expect(fetchCalled, 1); + await controller.close(); + }); + + test('empty drift + offline yields empty result without fetch', () async { + final controller = StreamController>(); + var fetchCalled = 0; + final results = cacheFirst( + driftStream: controller.stream, + fetchAndPopulate: () async { + fetchCalled++; + }, + toResult: (rows) => rows.fold(0, (a, b) => a + b), + isOnline: () async => false, + ); + + final firstFuture = results.first; + controller.add([]); + expect(await firstFuture, 0); + expect(fetchCalled, 0); + await controller.close(); + }); + + test('REST failure falls through to empty result', () async { + final controller = StreamController>(); + final results = cacheFirst( + driftStream: controller.stream, + fetchAndPopulate: () async => throw Exception('REST 500'), + toResult: (rows) => rows.fold(0, (a, b) => a + b), + isOnline: () async => true, + ); + + final firstFuture = results.first; + controller.add([]); + expect(await firstFuture, 0); + await controller.close(); + }); +}