syncache 0.4.0
syncache: ^0.4.0 copied to clipboard
An offline-first cache and sync engine for Dart with optimistic mutations, reactive streams, and pluggable storage.
Syncache #
An offline-first cache and sync engine for Dart applications.
Syncache provides a unified API for caching data with multiple strategies, reactive updates via streams, and optimistic mutations with automatic background sync.
Installation #
dependencies:
syncache: ^0.4.0
Quick Start #
import 'package:syncache/syncache.dart';
// Create a cache instance
final cache = Syncache<User>(
store: MemoryStore<User>(),
);
// Fetch data with offline-first policy
final user = await cache.get(
key: 'user:123',
fetch: (request) => api.getUser(123),
);
// Watch for reactive updates
cache.watch(
key: 'user:123',
fetch: (request) => api.getUser(123),
).listen((user) {
print('User updated: ${user.name}');
});
Features #
Caching Policies #
Control how data is fetched and cached with six built-in policies:
| Policy | Behavior |
|---|---|
offlineFirst |
Returns cached data if valid; fetches if expired or missing (default) |
cacheOnly |
Returns only cached data; never makes network requests |
networkOnly |
Always fetches from network; ignores cache |
refresh |
Fetches from network if online; falls back to cache if offline |
staleWhileRefresh |
Returns cache immediately; refreshes in background if expired |
cacheAndRefresh |
Returns cache immediately; always refreshes in background |
final user = await cache.get(
key: 'user:123',
fetch: fetchUser,
policy: Policy.staleWhileRefresh,
ttl: Duration(minutes: 5),
);
Reactive Streams #
Subscribe to cache updates with watch():
cache.watch(key: 'user:123', fetch: fetchUser).listen((user) {
// Called on initial fetch and every subsequent update
});
// With metadata (cache status, staleness, age)
cache.watchWithMeta(key: 'user:123', fetch: fetchUser).listen((result) {
print('From cache: ${result.meta.isFromCache}');
print('Is stale: ${result.meta.isStale}');
});
Optimistic Mutations #
Update the UI immediately while syncing to the server in the background:
await cache.mutate(
key: 'user:123',
mutation: Mutation<User>(
apply: (user) => user.copyWith(name: 'New Name'),
send: (user) => api.updateUser(user),
),
);
Failed mutations are automatically queued and retried when the network becomes available.
The retry runs whenever your
Networkreports connectivity — with aReactiveNetwork(such assyncache_flutter'sFlutterNetwork) queued mutations are flushed automatically the moment connectivity returns, with no newmutate()call.
Offline writes that survive restarts #
By default the mutation queue lives in memory: killing the app while offline loses queued writes. Configure an outbox to make them durable:
import 'package:syncache_hive/syncache_hive.dart';
final outbox = await HiveMutationOutbox.open();
final cache = Syncache<User>(
store: store,
outbox: outbox, // <- offline writes now survive process death
// Decodes optimistic snapshots at recovery (see below).
decodeOptimisticSnapshot: (snapshot) => User.fromJson(snapshot as Map<String, dynamic>),
);
// Pass a payload describing the change and a snapshot of the optimistic
// value — both are persisted with the record.
final handle = await cache.mutate(
key: 'user:123',
mutation: Mutation<User>(
apply: (user) => user.copyWith(name: 'New Name'),
send: (user) => api.updateUser(user),
),
payload: {'op': 'rename', 'userId': 123, 'name': 'New Name'},
optimisticSnapshot: user.copyWith(name: 'New Name').toJson(),
);
Every enqueued mutation is mirrored to the outbox and removed once it settles
(sent, exhausted, or discarded). On the next startup, replay what's left with
a resolver — a callback that maps a persisted record back to a live
Mutation:
final restored = await cache.recoverPendingMutations((record) async {
switch (record.payload['op']) {
case 'rename':
return Mutation<User>(
apply: (user) =>
user.copyWith(name: record.payload['name'] as String),
send: (user) => api.updateUser(user),
);
default:
throw UnimplementedError('unknown op ${record.payload['op']}');
}
});
print('$restored unsynced write(s) re-enqueued');
Recovery preserves enqueue order, does not double-apply the optimistic update
(it was already written before the process died), and flushes immediately if
the network is online. Because the record carries the optimisticSnapshot
(decoded by decodeOptimisticSnapshot), replay sends the exact pre-death
value — base-dependent mutations like increments and appends replay
correctly. Records stored without a snapshot fall back to re-deriving
apply(currentCached), which can double-apply those same mutations; prefer
always passing a snapshot. In syncache_flutter, SyncacheScope runs
recovery for you on mount:
SyncacheScope<User>(
cache: cache,
resolvePendingMutation: (record) async => /* rebuild the Mutation */,
onRecovered: (count) => debugPrint('$count writes restored'),
child: MyApp(),
)
Using the outbox without syncache_hive
The outbox is a four-method interface in core syncache — implement it over
any persistence you already own, or use the shipped in-memory reference:
class MemoryMutationOutbox implements MutationOutbox { /* ... */ }
// A drift cache with a Hive outbox is a valid mix: the outbox backend is
// independent of the cache Store backend.
final cache = Syncache<User>(store: driftStore, outbox: myOutbox);
The contract: save/delete/clear/loadAll (ordered by seq). Failing
writes never block the cache — they are reported to observers via
SyncacheObserver.onOutboxError. Calling mutate(payload: ...) without any
outbox configured throws StateError rather than silently dropping the
payload.
Make endpoints idempotent. Retries and replay can double-apply non-idempotent operations. Put a client-generated operation id in the payload and deduplicate server-side.
What happens when connectivity returns #
With a ReactiveNetwork connected (for Flutter apps, just pass
FlutterNetwork() to your Syncache), the reconnect sequence is:
- Queued mutations are sent to the server automatically, in order, and the cache is updated with each confirmed server value.
- Watchers of keys invalidated by a mutation (
invalidates:/invalidateTags:) refetch with their own parameters — streams stay open and their listeners keep the last value until fresh data arrives. - In
syncache_flutter,SyncacheLifecycleObserveralso refreshes watched keys on reconnect (configurable viaLifecycleConfig.refetchOnReconnect).
You can also trigger a pass manually, e.g. from a "Retry now" button:
await cache.processPendingMutations();
Queue visibility for sync indicators is fully reactive — late subscribers are seeded with the current state:
cache.pendingMutationsStream.listen((pending) {
showSyncBanner(pending.isNotEmpty);
});
// Await a specific mutation's server confirmation:
final handle = await cache.mutate(key: 'todo:1', mutation: mutation);
switch (await handle.outcome) {
case MutationSuccess(:final serverValue):
// Server confirmed; the cache holds the server value.
case MutationFailure(:final error, :final retried):
// Retries exhausted; the optimistic value remains cached.
case MutationDiscarded():
// Removed via clearPendingMutations / removePendingMutation / dispose.
}
Tag-Based Invalidation #
Group related cache entries with tags for bulk invalidation:
// Store with tags
await cache.get(
key: 'user:123',
fetch: fetchUser,
tags: ['users', 'account:456'],
);
// Invalidate all entries with a tag
await cache.invalidateTag('users');
// Invalidate by pattern
await cache.invalidatePattern('user:*');
Request Deduplication #
Concurrent requests for the same key are automatically deduplicated:
// These three calls result in only one network request
final results = await Future.wait([
cache.get(key: 'user:123', fetch: fetchUser),
cache.get(key: 'user:123', fetch: fetchUser),
cache.get(key: 'user:123', fetch: fetchUser),
]);
Retry with Exponential Backoff #
Configure automatic retries for transient failures:
final user = await cache.get(
key: 'user:123',
fetch: fetchUser,
retry: RetryConfig(
maxAttempts: 3,
retryIf: (error) => error is SocketException,
),
);
Cancellation #
Cancel in-flight requests with cancellation tokens:
final token = CancellationToken();
// Start fetch
final future = cache.get(
key: 'user:123',
fetch: fetchUser,
cancel: token,
);
// Cancel if needed
token.cancel();
Scoped Caches #
Isolate cache entries with key prefixes for multi-tenant scenarios:
final tenantCache = cache.scoped('tenant:abc');
// All operations are prefixed with 'tenant:abc:'
await tenantCache.get(key: 'user:123', fetch: fetchUser);
// Actually stored as 'tenant:abc:user:123'
Prefetching #
Prefetch multiple items in parallel or with dependency ordering:
// Parallel prefetch
final results = await cache.prefetch([
PrefetchRequest(key: 'user:1', fetch: fetchUser1),
PrefetchRequest(key: 'user:2', fetch: fetchUser2),
]);
// Dependency graph prefetch
final results = await cache.prefetchGraph([
PrefetchNode(key: 'config', fetch: fetchConfig),
PrefetchNode(key: 'user', fetch: fetchUser, dependsOn: ['config']),
PrefetchNode(key: 'posts', fetch: fetchPosts, dependsOn: ['user']),
]);
Observability #
Monitor cache operations for logging and analytics:
final cache = Syncache<User>(
store: MemoryStore<User>(),
observers: [LoggingObserver()],
);
// Or implement your own
class AnalyticsObserver extends SyncacheObserver {
@override
void onCacheHit(String key) => analytics.track('cache_hit', {'key': key});
@override
void onCacheMiss(String key) => analytics.track('cache_miss', {'key': key});
}
In Flutter apps, route log output through debugPrint and quiet the
high-frequency events:
import 'package:syncache_flutter/syncache_flutter.dart';
observers: [LoggingObserver(log: syncacheDebugLog, verbose: false)]
Stats #
Every cache keeps constant-memory health counters — snapshot them any time:
final stats = await cache.stats;
print('hits: ${stats.hits}, misses: ${stats.misses}');
print('pending mutations: ${stats.pendingMutations}');
print('entries: ${stats.entryCount}'); // null if the store can't enumerate
cache.resetStats(); // zero everything
cache.resetStats({SyncacheCounter.hits}); // or a single counter
Cache Lifecycle #
Bounded memory, expired-entry collection:
// Bounded store: least-recently-used eviction past 500 entries.
final cache = Syncache<User>(
store: MemoryStore<User>(maxLength: 500),
);
// Evictions fire SyncacheObserver.onEvict; watchers are not refetched.
// Periodic expired-entry sweep (timer is cancelled on dispose).
final cache2 = Syncache<User>(
store: MemoryStore<User>(),
cleanupInterval: const Duration(minutes: 10),
);
// Or sweep manually / opportunistically:
final removed = await cache2.cleanup();
final cache3 = Syncache<User>(
store: MemoryStore<User>(),
opportunisticCleanupEvery: 64, // at most once per 64 writes
);
Known limitations #
- Replay without optimistic snapshots re-derives values. Outbox records
stored without
optimisticSnapshot:fall back to re-derivingapply(currentCached)at recovery, which can double-apply base-dependent mutations (increments, appends) when the cache retained the optimistic state. Always pass a snapshot for exact replay (see Offline writes that survive restarts). - Concurrent
mutate()calls on the same key have racy optimistic views. Parallel mutates each apply from the value they read, so the cached value may briefly lag the full sum; the queue re-bases on send and converges to the exact fold after the flush. - Invalidation does not cancel queued mutations. An invalidated key with queued offline writes still delivers them on reconnect; the server-confirmed value overwrites the (invalidated) cached entry.
Storage Backends #
MemoryStore #
In-memory storage with optional tag support:
final cache = Syncache<User>(
store: MemoryStore<User>(),
);
Pass maxLength to bound memory — the least-recently-used entry is evicted
when the limit is exceeded (see Cache Lifecycle):
final cache = Syncache<User>(
store: MemoryStore<User>(maxLength: 500),
);
SharedMemoryStore #
Share cache data across multiple Syncache instances:
final cache1 = Syncache<User>(store: SharedMemoryStore<User>('users'));
final cache2 = Syncache<User>(store: SharedMemoryStore<User>('users'));
// Both instances share the same underlying data
Custom Store #
Implement the Store interface for custom backends (SQLite, Hive, etc.):
class SqliteStore<T> implements Store<T> {
@override
Future<void> write(String key, Stored<T> entry) async { ... }
@override
Future<Stored<T>?> read(String key) async { ... }
@override
Future<void> delete(String key) async { ... }
@override
Future<void> clear() async { ... }
}
Network Awareness #
Provide a custom Network implementation for offline detection:
class ConnectivityNetwork implements Network {
final Connectivity _connectivity;
ConnectivityNetwork(this._connectivity);
@override
bool get isOnline => _connectivity.hasConnection;
}
final cache = Syncache<User>(
store: MemoryStore<User>(),
network: ConnectivityNetwork(),
);
Related Packages #
Looking for additional functionality? Check out these companion packages:
| Package | Description |
|---|---|
| syncache_flutter | Flutter integration with widgets (CacheBuilder, CacheConsumer), lifecycle management, and connectivity detection via FlutterNetwork |
| syncache_hive | Persistent storage backend using Hive for cross-platform caching (iOS, Android, Web, Desktop) |
License #
MIT License. See LICENSE for details.