From 964f37dd629369e4486aa811699bd18679678d1e Mon Sep 17 00:00:00 2001 From: Hamdal Date: Wed, 18 Mar 2026 14:19:58 +0100 Subject: [PATCH 1/4] feat: add cacheAndRefresh policy that always refreshes in background --- packages/syncache/lib/src/policy.dart | 14 + packages/syncache/lib/src/syncache.dart | 86 +++ .../syncache/test/cache_and_refresh_test.dart | 615 ++++++++++++++++++ 3 files changed, 715 insertions(+) create mode 100644 packages/syncache/test/cache_and_refresh_test.dart 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..2c749a7 100644 --- a/packages/syncache/lib/src/syncache.dart +++ b/packages/syncache/lib/src/syncache.dart @@ -304,6 +304,34 @@ class Syncache { return value; } + throw CacheMissException(key); + + case Policy.cacheAndRefresh: + final cached = await store.read(key); + if (cached != null) { + _notifyObservers((o) => o.onCacheHit(key)); + if (network.isOnline) { + _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); } } @@ -461,6 +489,37 @@ class Syncache { key, fetch, ttl, effectiveRetry, cancel); } + throw CacheMissException(key); + + case Policy.cacheAndRefresh: + final cached = await store.read(key); + if (cached != null) { + _notifyObservers((o) => o.onCacheHit(key)); + if (network.isOnline) { + _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); } } @@ -621,6 +680,33 @@ class Syncache { return value; } + throw CacheMissException(key); + + case Policy.cacheAndRefresh: + final cached = await store.read(key); + if (cached != null) { + _notifyObservers((o) => o.onCacheHit(key)); + if (network.isOnline) { + _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); } } 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..7ac852d --- /dev/null +++ b/packages/syncache/test/cache_and_refresh_test.dart @@ -0,0 +1,615 @@ +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 result1 = await cache.get( + key: 'dedup-car-key', + fetch: fetcher, + policy: Policy.cacheAndRefresh, + ); + final result2 = await cache.get( + key: 'dedup-car-key', + fetch: fetcher, + policy: Policy.cacheAndRefresh, + ); + + // Both should return cached value immediately + expect(result1, equals('cached-value')); + expect(result2, 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'); + }); + }); + }); +} From ab7c1242bb08dc76dcdb7c240b2910af27eab698 Mon Sep 17 00:00:00 2001 From: Hamdal Date: Wed, 18 Mar 2026 14:51:19 +0100 Subject: [PATCH 2/4] refactor: unify policy execution with strategy pattern --- packages/syncache/lib/src/syncache.dart | 775 +++++++++++------------- 1 file changed, 358 insertions(+), 417 deletions(-) diff --git a/packages/syncache/lib/src/syncache.dart b/packages/syncache/lib/src/syncache.dart index 2c749a7..11eeb27 100644 --- a/packages/syncache/lib/src/syncache.dart +++ b/packages/syncache/lib/src/syncache.dart @@ -218,122 +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); - - case Policy.cacheAndRefresh: - final cached = await store.read(key); - if (cached != null) { - _notifyObservers((o) => o.onCacheHit(key)); - if (network.isOnline) { - _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. @@ -371,180 +264,14 @@ 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); - - case Policy.cacheAndRefresh: - final cached = await store.read(key); - if (cached != null) { - _notifyObservers((o) => o.onCacheHit(key)); - if (network.isOnline) { - _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, ); } @@ -590,133 +317,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); - - case Policy.cacheAndRefresh: - final cached = await store.read(key); - if (cached != null) { - _notifyObservers((o) => o.onCacheHit(key)); - if (network.isOnline) { - _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. @@ -1785,6 +1394,181 @@ 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. + Future _executePolicy({ + required String key, + required Policy policy, + required _FetchStrategy strategy, + required Duration? ttl, + required RetryConfig retry, + required CancellationToken? cancel, + required List? tags, + }) async { + switch (policy) { + case Policy.cacheOnly: + final cached = await store.read(key); + if (cached == null) { + throw CacheMissException(key); + } + _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, + ); + _notify(key, isFromCache: false); + return result; + + case Policy.refresh: + if (network.isOnline) { + final result = await strategy.fetchAndStore( + _fetchEngine, + key, + ttl, + retry, + cancel, + tags, + ); + _notify(key, isFromCache: false); + return result; + } + final cached = await store.read(key); + if (cached == null) { + throw CacheMissException(key); + } + _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)); + _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, + ); + _notify(key, isFromCache: false); + return result; + } on CancelledException { + rethrow; + } catch (e) { + if (strategy.isConvertibleToCacheMiss(e)) { + throw CacheMissException(key); + } + if (cached != null) { + _notify(key, isFromCache: true); + return strategy.wrapCached(cached, + isStale: cached.meta.isExpired); + } + rethrow; + } + } + + if (cached != null) { + _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, + ); + + case Policy.cacheAndRefresh: + return _executeStaleWhileRefresh( + key: key, + strategy: strategy, + ttl: ttl, + retry: retry, + cancel: cancel, + tags: tags, + refreshOnlyIfExpired: false, + ); + } + } + + /// 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, + }) 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)); + }); + } + + _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, + ); + _notify(key, isFromCache: false); + return result; + } + + throw CacheMissException(key); + } } /// Internal class to track dependency watcher information. @@ -1812,3 +1596,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; +} From 6145e29b612bfccb766d6e3953ce12cdf0a2c4ac Mon Sep 17 00:00:00 2001 From: Hamdal Date: Wed, 18 Mar 2026 14:55:42 +0100 Subject: [PATCH 3/4] style: fix formatting --- packages/syncache/lib/src/syncache.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syncache/lib/src/syncache.dart b/packages/syncache/lib/src/syncache.dart index 11eeb27..4337e60 100644 --- a/packages/syncache/lib/src/syncache.dart +++ b/packages/syncache/lib/src/syncache.dart @@ -1398,7 +1398,7 @@ class Syncache { /// 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. + /// delegating fetch/wrap operations to the strategy. Future _executePolicy({ required String key, required Policy policy, From ab59fc0d50ea46253637eef0540653c41d6f5c2b Mon Sep 17 00:00:00 2001 From: Hamdal Date: Wed, 18 Mar 2026 15:09:19 +0100 Subject: [PATCH 4/4] fix: prevent double notifications in getWithMeta flow --- packages/syncache/lib/src/syncache.dart | 35 ++++++++++++------- .../syncache/test/cache_and_refresh_test.dart | 11 +++--- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/syncache/lib/src/syncache.dart b/packages/syncache/lib/src/syncache.dart index 4337e60..1678443 100644 --- a/packages/syncache/lib/src/syncache.dart +++ b/packages/syncache/lib/src/syncache.dart @@ -272,6 +272,7 @@ class Syncache { retry: retry ?? defaultRetry, cancel: cancel, tags: null, + skipNotify: true, // Meta strategy handles its own notifications ); } @@ -1399,6 +1400,11 @@ class Syncache { /// /// 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, @@ -1407,6 +1413,7 @@ class Syncache { required RetryConfig retry, required CancellationToken? cancel, required List? tags, + bool skipNotify = false, }) async { switch (policy) { case Policy.cacheOnly: @@ -1414,7 +1421,7 @@ class Syncache { if (cached == null) { throw CacheMissException(key); } - _notify(key, isFromCache: true); + if (!skipNotify) _notify(key, isFromCache: true); return strategy.wrapCached(cached, isStale: cached.meta.isExpired); case Policy.networkOnly: @@ -1426,7 +1433,7 @@ class Syncache { cancel, tags, ); - _notify(key, isFromCache: false); + if (!skipNotify) _notify(key, isFromCache: false); return result; case Policy.refresh: @@ -1439,21 +1446,21 @@ class Syncache { cancel, tags, ); - _notify(key, isFromCache: false); + if (!skipNotify) _notify(key, isFromCache: false); return result; } final cached = await store.read(key); if (cached == null) { throw CacheMissException(key); } - _notify(key, isFromCache: true); + 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)); - _notify(key, isFromCache: true); + if (!skipNotify) _notify(key, isFromCache: true); return strategy.wrapCached(cached, isStale: false); } @@ -1469,7 +1476,7 @@ class Syncache { cancel, tags, ); - _notify(key, isFromCache: false); + if (!skipNotify) _notify(key, isFromCache: false); return result; } on CancelledException { rethrow; @@ -1478,7 +1485,7 @@ class Syncache { throw CacheMissException(key); } if (cached != null) { - _notify(key, isFromCache: true); + if (!skipNotify) _notify(key, isFromCache: true); return strategy.wrapCached(cached, isStale: cached.meta.isExpired); } @@ -1487,7 +1494,7 @@ class Syncache { } if (cached != null) { - _notify(key, isFromCache: true); + if (!skipNotify) _notify(key, isFromCache: true); return strategy.wrapCached(cached, isStale: cached.meta.isExpired); } @@ -1502,6 +1509,7 @@ class Syncache { cancel: cancel, tags: tags, refreshOnlyIfExpired: true, + skipNotify: skipNotify, ); case Policy.cacheAndRefresh: @@ -1513,6 +1521,7 @@ class Syncache { cancel: cancel, tags: tags, refreshOnlyIfExpired: false, + skipNotify: skipNotify, ); } } @@ -1530,6 +1539,7 @@ class Syncache { required CancellationToken? cancel, required List? tags, required bool refreshOnlyIfExpired, + bool skipNotify = false, }) async { final cached = await store.read(key); @@ -1542,13 +1552,14 @@ class Syncache { if (shouldRefresh) { strategy .backgroundRefresh(_fetchEngine, key, ttl, retry, tags) - .then((_) => _notify(key, isFromCache: false)) - .catchError((Object e, StackTrace st) { + .then((_) { + _notify(key, isFromCache: false); + }).catchError((Object e, StackTrace st) { _notifyObservers((o) => o.onFetchError(key, e, st)); }); } - _notify(key, isFromCache: true); + if (!skipNotify) _notify(key, isFromCache: true); return strategy.wrapCached(cached, isStale: cached.meta.isExpired); } @@ -1563,7 +1574,7 @@ class Syncache { cancel, tags, ); - _notify(key, isFromCache: false); + if (!skipNotify) _notify(key, isFromCache: false); return result; } diff --git a/packages/syncache/test/cache_and_refresh_test.dart b/packages/syncache/test/cache_and_refresh_test.dart index 7ac852d..6fb7db0 100644 --- a/packages/syncache/test/cache_and_refresh_test.dart +++ b/packages/syncache/test/cache_and_refresh_test.dart @@ -425,20 +425,23 @@ void main() { } // Start concurrent requests - final result1 = await cache.get( + final future1 = cache.get( key: 'dedup-car-key', fetch: fetcher, policy: Policy.cacheAndRefresh, ); - final result2 = await cache.get( + 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(result1, equals('cached-value')); - expect(result2, equals('cached-value')); + expect(results[0], equals('cached-value')); + expect(results[1], equals('cached-value')); // Complete background refresh completer.complete('fresh-value');