// 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) // // With `alwaysRefresh: true`, also kicks off a one-shot REST refresh // in the background after the first non-empty emission. Use for // aggregate lists (playlists, etc.) where the server may have rows // the local sync didn't pick up — yields cache immediately, refreshes // silently, and drift watch() re-emits with whatever new rows landed. // // 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'; import 'package:flutter/foundation.dart' show debugPrint; // `tag` parameter is preserved for future ad-hoc instrumentation. // Normal operation only logs failure paths so the per-screen log // noise stays low. /// 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, bool alwaysRefresh = false, String? tag, }) async* { // Tracks whether we've already kicked off a stale-while-revalidate // refresh for this stream subscription, so we don't fire one on every // drift re-emission (otherwise the populate cycles forever). var revalidated = false; // Tracks whether we've already tried a cold-cache fetch on this // subscription. Without this, providers can spin forever in the // rows-empty branch when fetchAndPopulate writes rows that don't // match THIS filter — e.g. three liked-tab streams sharing one // populate: the track populate writes track-typed rows, drift // watch fires for the cached_likes table, the album stream // re-emits with rows-empty (no album likes), the rows-empty // branch re-fires populate, repeats forever. Setting this guard // makes the first fetch attempt also the last for any given // subscription. var coldFetchAttempted = false; await for (final rows in driftStream) { if (rows.isNotEmpty) { yield toResult(rows); coldFetchAttempted = true; // Stale-while-revalidate: yield cache immediately, then kick off // a REST refresh in the background. Drift watch() picks up the // resulting writes and re-emits via this same stream loop. // Useful for aggregate lists (e.g. playlists) where the server // may have rows the local sync didn't pick up. if (alwaysRefresh && !revalidated && await isOnline()) { revalidated = true; unawaited(_safeFetch(fetchAndPopulate)); } continue; } // rows is empty. If we've already attempted a cold fetch and // drift is still empty for this filter, yield empty so the UI // shows the no-content state instead of spinning forever. if (coldFetchAttempted) { yield toResult(rows); continue; } coldFetchAttempted = true; if (await isOnline()) { try { await fetchAndPopulate(); // Yield the current (still-empty) rows so the UI moves past // loading even if populate was a no-op for this filter // (server returned nothing matching, or all rows were // already in drift via sync). If populate DID write rows // matching this filter, the drift watch's next emission // yields them via the rows.isNotEmpty branch — UI briefly // shows empty then populates, instead of spinning. yield toResult(rows); } catch (e, st) { if (tag != null) { debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st'); } yield toResult(rows); // empty result; caller surfaces error } } else { yield toResult(rows); // empty result; offline } } } Future _safeFetch(Future Function() fn) async { try { await fn(); } catch (_) { // Background revalidate — swallow; UI already showed cached state. } }