diff --git a/packages/syncache/lib/src/policy.dart b/packages/syncache/lib/src/policy.dart index c8da7fc..d83ff48 100644 --- a/packages/syncache/lib/src/policy.dart +++ b/packages/syncache/lib/src/policy.dart @@ -55,4 +55,18 @@ enum Policy { /// 3. If no cache exists and online, fetch and return /// 4. If no cache and offline, throw [CacheMissException] staleWhileRefresh, + + /// Returns cached data if available; always refreshes in background. + /// + /// Similar to [staleWhileRefresh] but triggers a background refresh even + /// if the cache is still valid. This ensures that subscribers always + /// receive the most up-to-date data while still benefiting from fast + /// cache access. + /// + /// Behavior: + /// 1. If cache exists, return it immediately + /// 2. Always trigger background refresh if online + /// 3. If no cache exists and online, fetch and return + /// 4. If no cache and offline, throw [CacheMissException] + cacheAndRefresh } diff --git a/packages/syncache/lib/src/syncache.dart b/packages/syncache/lib/src/syncache.dart index eda1342..1678443 100644 --- a/packages/syncache/lib/src/syncache.dart +++ b/packages/syncache/lib/src/syncache.dart @@ -218,94 +218,15 @@ class Syncache { _checkNotDisposed(); cancel?.throwIfCancelled(); - final effectiveRetry = retry ?? defaultRetry; - - switch (policy) { - case Policy.cacheOnly: - final value = await _getFromCache(key); - _notify(key, isFromCache: true); - return value; - - case Policy.networkOnly: - final value = await _fetchEngine.fetchAndStore( - key, fetch, ttl, effectiveRetry, cancel, tags); - _notify(key, isFromCache: false); - return value; - - case Policy.refresh: - if (network.isOnline) { - final value = await _fetchEngine.fetchAndStore( - key, fetch, ttl, effectiveRetry, cancel, tags); - _notify(key, isFromCache: false); - return value; - } - - final value = await _getFromCache(key); - _notify(key, isFromCache: true); - return value; - - case Policy.offlineFirst: - final cached = await store.read(key); - if (cached != null && !cached.meta.isExpired) { - _notifyObservers((o) => o.onCacheHit(key)); - _notify(key, isFromCache: true); - return cached.value; - } - - _notifyObservers((o) => o.onCacheMiss(key)); - - if (network.isOnline) { - try { - final value = await _fetchEngine.fetchAndStore( - key, fetch, ttl, effectiveRetry, cancel, tags); - _notify(key, isFromCache: false); - return value; - } on CancelledException { - rethrow; - } catch (_) { - if (cached != null) { - _notify(key, isFromCache: true); - return cached.value; - } - rethrow; - } - } - - if (cached != null) { - _notify(key, isFromCache: true); - return cached.value; - } - - throw CacheMissException(key); - - case Policy.staleWhileRefresh: - final cached = await store.read(key); - if (cached != null) { - _notifyObservers((o) => o.onCacheHit(key)); - if (network.isOnline && cached.meta.isExpired) { - _fetchEngine - .fetchAndStore(key, fetch, ttl, effectiveRetry, null, tags) - .then((_) => _notify(key, isFromCache: false)) - .catchError((Object e, StackTrace st) { - _notifyObservers((o) => o.onFetchError(key, e, st)); - }); - } - - _notify(key, isFromCache: true); - return cached.value; - } - - _notifyObservers((o) => o.onCacheMiss(key)); - - if (network.isOnline) { - final value = await _fetchEngine.fetchAndStore( - key, fetch, ttl, effectiveRetry, cancel, tags); - _notify(key, isFromCache: false); - return value; - } - - throw CacheMissException(key); - } + return _executePolicy( + key: key, + policy: policy, + strategy: _StandardFetchStrategy(fetch), + ttl: ttl, + retry: retry ?? defaultRetry, + cancel: cancel, + tags: tags, + ); } /// Retrieves a value with cache metadata. @@ -343,149 +264,15 @@ class Syncache { _checkNotDisposed(); cancel?.throwIfCancelled(); - final effectiveRetry = retry ?? defaultRetry; - - switch (policy) { - case Policy.cacheOnly: - final cached = await store.read(key); - if (cached == null) { - throw CacheMissException(key); - } - return CacheResult( - value: cached.value, - meta: CacheResultMeta.fromCache( - isStale: cached.meta.isExpired, - storedAt: cached.meta.storedAt, - version: cached.meta.version, - ), - ); - - case Policy.networkOnly: - return _fetchAndStoreWithMeta(key, fetch, ttl, effectiveRetry, cancel); - - case Policy.refresh: - if (network.isOnline) { - return _fetchAndStoreWithMeta( - key, fetch, ttl, effectiveRetry, cancel); - } - final cached = await store.read(key); - if (cached == null) { - throw CacheMissException(key); - } - return CacheResult( - value: cached.value, - meta: CacheResultMeta.fromCache( - isStale: cached.meta.isExpired, - storedAt: cached.meta.storedAt, - version: cached.meta.version, - ), - ); - - case Policy.offlineFirst: - final cached = await store.read(key); - if (cached != null && !cached.meta.isExpired) { - _notifyObservers((o) => o.onCacheHit(key)); - return CacheResult( - value: cached.value, - meta: CacheResultMeta.fromCache( - isStale: false, - storedAt: cached.meta.storedAt, - version: cached.meta.version, - ), - ); - } - - _notifyObservers((o) => o.onCacheMiss(key)); - - if (network.isOnline) { - try { - return await _fetchAndStoreWithMeta( - key, fetch, ttl, effectiveRetry, cancel); - } on CancelledException { - rethrow; - } catch (_) { - if (cached != null) { - return CacheResult( - value: cached.value, - meta: CacheResultMeta.fromCache( - isStale: cached.meta.isExpired, - storedAt: cached.meta.storedAt, - version: cached.meta.version, - ), - ); - } - rethrow; - } - } - - if (cached != null) { - return CacheResult( - value: cached.value, - meta: CacheResultMeta.fromCache( - isStale: cached.meta.isExpired, - storedAt: cached.meta.storedAt, - version: cached.meta.version, - ), - ); - } - - throw CacheMissException(key); - - case Policy.staleWhileRefresh: - final cached = await store.read(key); - if (cached != null) { - _notifyObservers((o) => o.onCacheHit(key)); - if (network.isOnline && cached.meta.isExpired) { - _fetchEngine - .fetchAndStore(key, fetch, ttl, effectiveRetry, null) - .then((_) => _notify(key, isFromCache: false)) - .catchError((Object e, StackTrace st) { - _notifyObservers((o) => o.onFetchError(key, e, st)); - }); - } - - return CacheResult( - value: cached.value, - meta: CacheResultMeta.fromCache( - isStale: cached.meta.isExpired, - storedAt: cached.meta.storedAt, - version: cached.meta.version, - ), - ); - } - - _notifyObservers((o) => o.onCacheMiss(key)); - - if (network.isOnline) { - return _fetchAndStoreWithMeta( - key, fetch, ttl, effectiveRetry, cancel); - } - - throw CacheMissException(key); - } - } - - /// Fetches and stores with metadata. Notifies regular [watch] streams - /// but not [_metaControllers] since [watchWithMeta] handles its own emission. - Future> _fetchAndStoreWithMeta( - String key, - Fetcher fetch, - Duration? ttl, - RetryConfig retry, - CancellationToken? cancel, - ) async { - final value = - await _fetchEngine.fetchAndStore(key, fetch, ttl, retry, cancel); - final cached = await store.read(key); - - await _notifyValue(key); - - return CacheResult( - value: value, - meta: CacheResultMeta.fresh( - version: cached?.meta.version ?? 1, - storedAt: cached?.meta.storedAt, - ), + return _executePolicy>( + key: key, + policy: policy, + strategy: _MetaFetchStrategy(fetch, store, _notifyValue), + ttl: ttl, + retry: retry ?? defaultRetry, + cancel: cancel, + tags: null, + skipNotify: true, // Meta strategy handles its own notifications ); } @@ -531,106 +318,15 @@ class Syncache { _checkNotDisposed(); cancel?.throwIfCancelled(); - final effectiveRetry = retry ?? defaultRetry; - - switch (policy) { - case Policy.cacheOnly: - final value = await _getFromCache(key); - _notify(key, isFromCache: true); - return value; - - case Policy.networkOnly: - final value = await _fetchEngine.fetchAndStoreConditional( - key, fetch, ttl, effectiveRetry, cancel); - _notify(key, isFromCache: false); - return value; - - case Policy.refresh: - if (network.isOnline) { - final value = await _fetchEngine.fetchAndStoreConditional( - key, fetch, ttl, effectiveRetry, cancel); - _notify(key, isFromCache: false); - return value; - } - final value = await _getFromCache(key); - _notify(key, isFromCache: true); - return value; - - case Policy.offlineFirst: - final cached = await store.read(key); - if (cached != null && !cached.meta.isExpired) { - _notifyObservers((o) => o.onCacheHit(key)); - _notify(key, isFromCache: true); - return cached.value; - } - - _notifyObservers((o) => o.onCacheMiss(key)); - - if (network.isOnline) { - try { - final value = await _fetchEngine.fetchAndStoreConditional( - key, - fetch, - ttl, - effectiveRetry, - cancel, - ); - _notify(key, isFromCache: false); - return value; - } on CancelledException { - rethrow; - } on CacheMissForConditionalException { - throw CacheMissException(key); - } catch (_) { - if (cached != null) { - _notify(key, isFromCache: true); - return cached.value; - } - rethrow; - } - } - - if (cached != null) { - _notify(key, isFromCache: true); - return cached.value; - } - throw CacheMissException(key); - - case Policy.staleWhileRefresh: - final cached = await store.read(key); - if (cached != null) { - _notifyObservers((o) => o.onCacheHit(key)); - if (network.isOnline && cached.meta.isExpired) { - _fetchEngine - .fetchAndStoreConditional(key, fetch, ttl, effectiveRetry, null) - .then((_) => _notify(key, isFromCache: false)) - .catchError((Object e, StackTrace st) { - _notifyObservers((o) => o.onFetchError(key, e, st)); - }); - } - _notify(key, isFromCache: true); - return cached.value; - } - - _notifyObservers((o) => o.onCacheMiss(key)); - - if (network.isOnline) { - final value = await _fetchEngine.fetchAndStoreConditional( - key, fetch, ttl, effectiveRetry, cancel); - _notify(key, isFromCache: false); - return value; - } - - throw CacheMissException(key); - } - } - - Future _getFromCache(String key) async { - final cached = await store.read(key); - if (cached == null) { - throw CacheMissException(key); - } - return cached.value; + return _executePolicy( + key: key, + policy: policy, + strategy: _ConditionalFetchStrategy(fetch), + ttl: ttl, + retry: retry ?? defaultRetry, + cancel: cancel, + tags: null, + ); } /// Sets a value directly in the cache without fetching. @@ -1699,6 +1395,191 @@ class Syncache { _metaControllers.remove(key); } } + + /// Executes caching logic based on the given [policy] using a [strategy]. + /// + /// This unified method handles all policy variants, + /// delegating fetch/wrap operations to the strategy. + /// + /// When [skipNotify] is true, the method will not call [_notify] after + /// cache hits or fetches. This is used by [getWithMeta] since the meta + /// strategy handles value notifications internally via [_notifyValue], + /// and [watchWithMeta] handles meta notifications via manual emission. + Future _executePolicy({ + required String key, + required Policy policy, + required _FetchStrategy strategy, + required Duration? ttl, + required RetryConfig retry, + required CancellationToken? cancel, + required List? tags, + bool skipNotify = false, + }) async { + switch (policy) { + case Policy.cacheOnly: + final cached = await store.read(key); + if (cached == null) { + throw CacheMissException(key); + } + if (!skipNotify) _notify(key, isFromCache: true); + return strategy.wrapCached(cached, isStale: cached.meta.isExpired); + + case Policy.networkOnly: + final result = await strategy.fetchAndStore( + _fetchEngine, + key, + ttl, + retry, + cancel, + tags, + ); + if (!skipNotify) _notify(key, isFromCache: false); + return result; + + case Policy.refresh: + if (network.isOnline) { + final result = await strategy.fetchAndStore( + _fetchEngine, + key, + ttl, + retry, + cancel, + tags, + ); + if (!skipNotify) _notify(key, isFromCache: false); + return result; + } + final cached = await store.read(key); + if (cached == null) { + throw CacheMissException(key); + } + if (!skipNotify) _notify(key, isFromCache: true); + return strategy.wrapCached(cached, isStale: cached.meta.isExpired); + + case Policy.offlineFirst: + final cached = await store.read(key); + if (cached != null && !cached.meta.isExpired) { + _notifyObservers((o) => o.onCacheHit(key)); + if (!skipNotify) _notify(key, isFromCache: true); + return strategy.wrapCached(cached, isStale: false); + } + + _notifyObservers((o) => o.onCacheMiss(key)); + + if (network.isOnline) { + try { + final result = await strategy.fetchAndStore( + _fetchEngine, + key, + ttl, + retry, + cancel, + tags, + ); + if (!skipNotify) _notify(key, isFromCache: false); + return result; + } on CancelledException { + rethrow; + } catch (e) { + if (strategy.isConvertibleToCacheMiss(e)) { + throw CacheMissException(key); + } + if (cached != null) { + if (!skipNotify) _notify(key, isFromCache: true); + return strategy.wrapCached(cached, + isStale: cached.meta.isExpired); + } + rethrow; + } + } + + if (cached != null) { + if (!skipNotify) _notify(key, isFromCache: true); + return strategy.wrapCached(cached, isStale: cached.meta.isExpired); + } + + throw CacheMissException(key); + + case Policy.staleWhileRefresh: + return _executeStaleWhileRefresh( + key: key, + strategy: strategy, + ttl: ttl, + retry: retry, + cancel: cancel, + tags: tags, + refreshOnlyIfExpired: true, + skipNotify: skipNotify, + ); + + case Policy.cacheAndRefresh: + return _executeStaleWhileRefresh( + key: key, + strategy: strategy, + ttl: ttl, + retry: retry, + cancel: cancel, + tags: tags, + refreshOnlyIfExpired: false, + skipNotify: skipNotify, + ); + } + } + + /// Shared logic for staleWhileRefresh and cacheAndRefresh policies. + /// + /// The only difference between these policies is [refreshOnlyIfExpired]: + /// - staleWhileRefresh: only refreshes if cache is expired + /// - cacheAndRefresh: always refreshes in background + Future _executeStaleWhileRefresh({ + required String key, + required _FetchStrategy strategy, + required Duration? ttl, + required RetryConfig retry, + required CancellationToken? cancel, + required List? tags, + required bool refreshOnlyIfExpired, + bool skipNotify = false, + }) async { + final cached = await store.read(key); + + if (cached != null) { + _notifyObservers((o) => o.onCacheHit(key)); + + final shouldRefresh = + network.isOnline && (!refreshOnlyIfExpired || cached.meta.isExpired); + + if (shouldRefresh) { + strategy + .backgroundRefresh(_fetchEngine, key, ttl, retry, tags) + .then((_) { + _notify(key, isFromCache: false); + }).catchError((Object e, StackTrace st) { + _notifyObservers((o) => o.onFetchError(key, e, st)); + }); + } + + if (!skipNotify) _notify(key, isFromCache: true); + return strategy.wrapCached(cached, isStale: cached.meta.isExpired); + } + + _notifyObservers((o) => o.onCacheMiss(key)); + + if (network.isOnline) { + final result = await strategy.fetchAndStore( + _fetchEngine, + key, + ttl, + retry, + cancel, + tags, + ); + if (!skipNotify) _notify(key, isFromCache: false); + return result; + } + + throw CacheMissException(key); + } } /// Internal class to track dependency watcher information. @@ -1726,3 +1607,160 @@ class _DependencyWatcher { required this.dependsOn, }); } + +/// Strategy for fetching and wrapping cache results. +abstract class _FetchStrategy { + /// Fetches fresh data and stores it, returning the wrapped result. + Future fetchAndStore( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + CancellationToken? cancel, + List? tags, + ); + + /// Wraps a cached value into the result type. + R wrapCached(Stored cached, {required bool isStale}); + + /// Performs a background refresh (fire-and-forget). + /// Returns a Future that completes when the refresh is done. + Future backgroundRefresh( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + List? tags, + ); + + /// Additional error types to catch and convert to CacheMissException. + /// Override in subclasses that need special error handling. + bool isConvertibleToCacheMiss(Object error) => false; +} + +/// Standard fetch strategy that returns T directly. +class _StandardFetchStrategy extends _FetchStrategy { + final Fetcher fetch; + + _StandardFetchStrategy(this.fetch); + + @override + Future fetchAndStore( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + CancellationToken? cancel, + List? tags, + ) { + return engine.fetchAndStore(key, fetch, ttl, retry, cancel, tags); + } + + @override + T wrapCached(Stored cached, {required bool isStale}) => cached.value; + + @override + Future backgroundRefresh( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + List? tags, + ) { + return engine.fetchAndStore(key, fetch, ttl, retry, null, tags); + } +} + +/// Fetch strategy that returns CacheResult with metadata. +class _MetaFetchStrategy extends _FetchStrategy> { + final Fetcher fetch; + final Store store; + final Future Function(String key) notifyValue; + + _MetaFetchStrategy(this.fetch, this.store, this.notifyValue); + + @override + Future> fetchAndStore( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + CancellationToken? cancel, + List? tags, + ) async { + final value = + await engine.fetchAndStore(key, fetch, ttl, retry, cancel, tags); + final cached = await store.read(key); + + await notifyValue(key); + + return CacheResult( + value: value, + meta: CacheResultMeta.fresh( + version: cached?.meta.version ?? 1, + storedAt: cached?.meta.storedAt, + ), + ); + } + + @override + CacheResult wrapCached(Stored cached, {required bool isStale}) { + return CacheResult( + value: cached.value, + meta: CacheResultMeta.fromCache( + isStale: isStale, + storedAt: cached.meta.storedAt, + version: cached.meta.version, + ), + ); + } + + @override + Future backgroundRefresh( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + List? tags, + ) { + return engine.fetchAndStore(key, fetch, ttl, retry, null, tags); + } +} + +/// Conditional fetch strategy that supports HTTP 304. +class _ConditionalFetchStrategy extends _FetchStrategy { + final ConditionalFetcher fetch; + + _ConditionalFetchStrategy(this.fetch); + + @override + Future fetchAndStore( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + CancellationToken? cancel, + List? tags, + ) { + return engine.fetchAndStoreConditional( + key, fetch, ttl, retry, cancel, tags); + } + + @override + T wrapCached(Stored cached, {required bool isStale}) => cached.value; + + @override + Future backgroundRefresh( + FetchEngine engine, + String key, + Duration? ttl, + RetryConfig retry, + List? tags, + ) { + return engine.fetchAndStoreConditional(key, fetch, ttl, retry, null, tags); + } + + @override + bool isConvertibleToCacheMiss(Object error) => + error is CacheMissForConditionalException; +} diff --git a/packages/syncache/test/cache_and_refresh_test.dart b/packages/syncache/test/cache_and_refresh_test.dart new file mode 100644 index 0000000..6fb7db0 --- /dev/null +++ b/packages/syncache/test/cache_and_refresh_test.dart @@ -0,0 +1,618 @@ +import 'dart:async'; + +import 'package:syncache/syncache.dart'; +import 'package:test/test.dart'; + +/// A Network implementation that is always offline. +class _OfflineNetwork implements Network { + @override + bool get isOnline => false; +} + +/// A simple observer that records events. +class _TestObserver extends SyncacheObserver { + final events = []; + + @override + void onCacheHit(String key) => events.add('cache_hit:$key'); + + @override + void onCacheMiss(String key) => events.add('cache_miss:$key'); + + @override + void onFetchStart(String key) => events.add('fetch_start:$key'); + + @override + void onFetchSuccess(String key, Duration duration) => + events.add('fetch_success:$key'); + + @override + void onFetchError(String key, Object error, StackTrace stackTrace) => + events.add('fetch_error:$key:$error'); + + @override + void onStore(String key) => events.add('store:$key'); + + @override + void onInvalidate(String key) => events.add('invalidate:$key'); + + @override + void onClear() => events.add('clear'); +} + +void main() { + group('Policy.cacheAndRefresh', () { + late Syncache cache; + late MemoryStore store; + + setUp(() { + store = MemoryStore(); + cache = Syncache(store: store); + }); + + group('get', () { + test('returns cached value immediately and triggers background refresh', + () async { + // Pre-populate cache with valid (non-expired) value + await store.write( + 'car-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + var fetchCount = 0; + final completer = Completer(); + + final result = await cache.get( + key: 'car-key', + fetch: (_) async { + fetchCount++; + return completer.future; + }, + policy: Policy.cacheAndRefresh, + ); + + // Should return cached value immediately + expect(result, equals('cached-value')); + + // Allow microtask to start background fetch + await Future.delayed(Duration.zero); + + // Background fetch should have started (even though cache is valid) + expect(fetchCount, equals(1)); + + // Complete the background fetch + completer.complete('fresh-value'); + await Future.delayed(const Duration(milliseconds: 50)); + + // Cache should now have fresh value + final cached = await store.read('car-key'); + expect(cached!.value, equals('fresh-value')); + }); + + test('returns stale cached value and triggers background refresh', + () async { + // Pre-populate cache with expired value + await store.write( + 'stale-car-key', + Stored( + value: 'stale-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now().subtract(const Duration(hours: 2)), + ttl: const Duration(hours: 1), + ), + ), + ); + + var fetchCount = 0; + final completer = Completer(); + + final result = await cache.get( + key: 'stale-car-key', + fetch: (_) async { + fetchCount++; + return completer.future; + }, + policy: Policy.cacheAndRefresh, + ); + + // Should return stale value immediately + expect(result, equals('stale-value')); + + // Allow microtask to start background fetch + await Future.delayed(Duration.zero); + + // Background fetch should have started + expect(fetchCount, equals(1)); + + // Complete the background fetch + completer.complete('fresh-value'); + await Future.delayed(const Duration(milliseconds: 50)); + + // Cache should now have fresh value + final cached = await store.read('stale-car-key'); + expect(cached!.value, equals('fresh-value')); + }); + + test('fetches and returns when no cache exists', () async { + var fetchCount = 0; + + final result = await cache.get( + key: 'no-cache-key', + fetch: (_) async { + fetchCount++; + return 'fetched-value'; + }, + policy: Policy.cacheAndRefresh, + ); + + expect(result, equals('fetched-value')); + expect(fetchCount, equals(1)); + + // Verify it was cached + final cached = await store.read('no-cache-key'); + expect(cached!.value, equals('fetched-value')); + }); + + test('throws CacheMissException when offline with no cache', () async { + final offlineCache = Syncache( + store: store, + network: _OfflineNetwork(), + ); + + expect( + () => offlineCache.get( + key: 'offline-no-cache', + fetch: (_) async => 'value', + policy: Policy.cacheAndRefresh, + ), + throwsA(isA()), + ); + }); + + test('returns cached value without background refresh when offline', + () async { + final offlineCache = Syncache( + store: store, + network: _OfflineNetwork(), + ); + + // Pre-populate cache + await store.write( + 'offline-cached-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + var fetchCount = 0; + + final result = await offlineCache.get( + key: 'offline-cached-key', + fetch: (_) async { + fetchCount++; + return 'should-not-fetch'; + }, + policy: Policy.cacheAndRefresh, + ); + + expect(result, equals('cached-value')); + // Should not attempt fetch when offline + expect(fetchCount, equals(0)); + }); + + test('background fetch error does not affect returned cached value', + () async { + await store.write( + 'error-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + final result = await cache.get( + key: 'error-key', + fetch: (_) async { + throw Exception('Network error'); + }, + policy: Policy.cacheAndRefresh, + ); + + // Should return cached value + expect(result, equals('cached-value')); + + // Wait for background fetch to complete (with error) + await Future.delayed(const Duration(milliseconds: 50)); + + // Cache should still have original value + final cached = await store.read('error-key'); + expect(cached!.value, equals('cached-value')); + }); + }); + + group('getWithMeta', () { + test('returns cached value with metadata and triggers background refresh', + () async { + final storedAt = DateTime.now(); + await store.write( + 'meta-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: storedAt, + ttl: const Duration(hours: 1), + ), + ), + ); + + var fetchCount = 0; + final completer = Completer(); + + final result = await cache.getWithMeta( + key: 'meta-key', + fetch: (_) async { + fetchCount++; + return completer.future; + }, + policy: Policy.cacheAndRefresh, + ); + + // Should return cached value with metadata + expect(result.value, equals('cached-value')); + expect(result.meta.isFromCache, isTrue); + expect(result.meta.isStale, isFalse); + + // Allow microtask to start background fetch + await Future.delayed(Duration.zero); + + // Background fetch should have started + expect(fetchCount, equals(1)); + + // Complete background fetch + completer.complete('fresh-value'); + await Future.delayed(const Duration(milliseconds: 50)); + + // Cache should be updated + final cached = await store.read('meta-key'); + expect(cached!.value, equals('fresh-value')); + }); + + test('returns stale metadata when cache is expired', () async { + await store.write( + 'stale-meta-key', + Stored( + value: 'stale-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now().subtract(const Duration(hours: 2)), + ttl: const Duration(hours: 1), + ), + ), + ); + + final completer = Completer(); + + final result = await cache.getWithMeta( + key: 'stale-meta-key', + fetch: (_) async => completer.future, + policy: Policy.cacheAndRefresh, + ); + + expect(result.value, equals('stale-value')); + expect(result.meta.isFromCache, isTrue); + expect(result.meta.isStale, isTrue); + + completer.complete('fresh-value'); + }); + + test('throws CacheMissException when offline with no cache', () async { + final offlineCache = Syncache( + store: store, + network: _OfflineNetwork(), + ); + + expect( + () => offlineCache.getWithMeta( + key: 'no-cache-meta', + fetch: (_) async => 'value', + policy: Policy.cacheAndRefresh, + ), + throwsA(isA()), + ); + }); + }); + + group('watch', () { + test('emits cached value then fresh value after background refresh', + () async { + await store.write( + 'watch-car-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + final completer = Completer(); + final values = []; + + final subscription = cache + .watch( + key: 'watch-car-key', + fetch: (_) async => completer.future, + policy: Policy.cacheAndRefresh, + ) + .listen(values.add); + + // Wait for initial emission + await Future.delayed(const Duration(milliseconds: 50)); + + // Should have cached value + expect(values, contains('cached-value')); + + // Complete background refresh + completer.complete('fresh-value'); + await Future.delayed(const Duration(milliseconds: 50)); + + // Should have both cached and fresh values + expect(values, contains('cached-value')); + expect(values, contains('fresh-value')); + + await subscription.cancel(); + }); + + test('emits only fresh value when no cache exists', () async { + final values = []; + + final subscription = cache + .watch( + key: 'watch-no-cache', + fetch: (_) async => 'fresh-value', + policy: Policy.cacheAndRefresh, + ) + .listen(values.add); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(values, equals(['fresh-value'])); + + await subscription.cancel(); + }); + }); + + group('request deduplication', () { + test('deduplicates background refresh requests', () async { + await store.write( + 'dedup-car-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + var fetchCount = 0; + final completer = Completer(); + + Future fetcher(SyncacheRequest _) async { + fetchCount++; + return completer.future; + } + + // Start concurrent requests + final future1 = cache.get( + key: 'dedup-car-key', + fetch: fetcher, + policy: Policy.cacheAndRefresh, + ); + final future2 = cache.get( + key: 'dedup-car-key', + fetch: fetcher, + policy: Policy.cacheAndRefresh, + ); + + // Wait for both to complete + final results = await Future.wait([future1, future2]); + + // Both should return cached value immediately + expect(results[0], equals('cached-value')); + expect(results[1], equals('cached-value')); + + // Complete background refresh + completer.complete('fresh-value'); + await Future.delayed(const Duration(milliseconds: 50)); + + // Should have only triggered one background fetch + expect(fetchCount, equals(1)); + + // Cache should have fresh value + final cached = await store.read('dedup-car-key'); + expect(cached!.value, equals('fresh-value')); + }); + }); + + group('observer notifications', () { + late _TestObserver observer; + late Syncache observedCache; + + setUp(() { + observer = _TestObserver(); + observedCache = Syncache( + store: store, + observers: [observer], + ); + }); + + test('notifies onCacheHit when cache exists', () async { + await store.write( + 'observer-hit-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + final completer = Completer(); + + await observedCache.get( + key: 'observer-hit-key', + fetch: (_) async => completer.future, + policy: Policy.cacheAndRefresh, + ); + + expect(observer.events, contains('cache_hit:observer-hit-key')); + + completer.complete('fresh'); + }); + + test('notifies onCacheMiss when no cache exists', () async { + await observedCache.get( + key: 'observer-miss-key', + fetch: (_) async => 'fetched', + policy: Policy.cacheAndRefresh, + ); + + expect(observer.events, contains('cache_miss:observer-miss-key')); + }); + + test('notifies onFetchError when background refresh fails', () async { + await store.write( + 'observer-error-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + await observedCache.get( + key: 'observer-error-key', + fetch: (_) async => throw Exception('Network error'), + policy: Policy.cacheAndRefresh, + ); + + // Wait for background fetch to fail + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + observer.events, + contains(startsWith('fetch_error:observer-error-key:')), + ); + }); + + test('notifies onStore after successful background refresh', () async { + await store.write( + 'observer-store-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + await observedCache.get( + key: 'observer-store-key', + fetch: (_) async => 'fresh-value', + policy: Policy.cacheAndRefresh, + ); + + // Wait for background fetch to complete + await Future.delayed(const Duration(milliseconds: 50)); + + expect(observer.events, contains('store:observer-store-key')); + }); + }); + + group('comparison with staleWhileRefresh', () { + test( + 'cacheAndRefresh refreshes even when cache is valid, staleWhileRefresh does not', + () async { + // Pre-populate cache with valid (non-expired) value + await store.write( + 'compare-key', + Stored( + value: 'cached-value', + meta: Metadata( + version: 1, + storedAt: DateTime.now(), + ttl: const Duration(hours: 1), + ), + ), + ); + + var swrFetchCount = 0; + var carFetchCount = 0; + + // staleWhileRefresh should NOT trigger background refresh for valid cache + await cache.get( + key: 'compare-key', + fetch: (_) async { + swrFetchCount++; + return 'swr-fresh'; + }, + policy: Policy.staleWhileRefresh, + ); + + // Allow microtask queue to flush + await Future.delayed(Duration.zero); + + expect(swrFetchCount, equals(0), + reason: 'staleWhileRefresh should not fetch when cache is valid'); + + // cacheAndRefresh SHOULD trigger background refresh even for valid cache + final completer = Completer(); + await cache.get( + key: 'compare-key', + fetch: (_) async { + carFetchCount++; + return completer.future; + }, + policy: Policy.cacheAndRefresh, + ); + + // Allow microtask to start background fetch + await Future.delayed(Duration.zero); + + expect(carFetchCount, equals(1), + reason: 'cacheAndRefresh should always fetch when online'); + + completer.complete('car-fresh'); + }); + }); + }); +}