Skip to main content

syncache 0.4.0 copy "syncache: ^0.4.0" to clipboard
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 Network reports connectivity — with a ReactiveNetwork (such as syncache_flutter's FlutterNetwork) queued mutations are flushed automatically the moment connectivity returns, with no new mutate() 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:

  1. Queued mutations are sent to the server automatically, in order, and the cache is updated with each confirmed server value.
  2. 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.
  3. In syncache_flutter, SyncacheLifecycleObserver also refreshes watched keys on reconnect (configurable via LifecycleConfig.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-deriving apply(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(),
);

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.

1
likes
0
points
341
downloads

Publisher

verified publisherthedevayo.com

Weekly Downloads

An offline-first cache and sync engine for Dart with optimistic mutations, reactive streams, and pluggable storage.

Repository (GitHub)
View/report issues

Topics

#cache #offline-first #sync #state-management

License

unknown (license)

More

Packages that depend on syncache