syncache 0.6.0
syncache: ^0.6.0 copied to clipboard
An offline-first cache and sync engine for Dart with optimistic mutations, reactive streams, and pluggable storage.
Changelog #
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.6.0 #
Added #
- Pagination / infinite lists.
PaginatedCache<T>(core) fetches pages one at a time and accumulates them into a windowed list stored as a normal cached value — so persistence,mutateList/ListOperationoptimistic edits, outbox durability, and pattern/tag invalidation all work on paginated data. Includes:loadMore(coalesced, failure-safe, cursor-preserving),refresh(re-fetch page 1 and merge, deduping the tail by==),invalidate(re-fetch page 1, drop the tail),maxItemsfront-trimming, automatic refetch on external invalidation (autoRefreshOnInvalidation, default on; eviction/clear never refetch), and a post-restart rule that refresh-merges instead of duplicating the rehydrated window. Flutter:PaginatedCacheBuilder<T>— a thin builder exposing state plus aloadMorecallback; scroll wiring stays app-owned. - Dynamic observer registration.
Syncache.addObserver(observer)/removeObserver(observer)attach and detach observers after construction (the constructor list stays immutable). Additive. - Conflict resolution. Throw
SyncacheConflictException<T>(serverValue)from a mutation'ssendwhen the server rejects a stale write (typically HTTP 409 with the server's current state in the body). Syncache never retries a conflict: it consults aConflictResolver<T>— passed per call viamutate(conflictResolver: ...), or configured cache-wide viaSyncache(conflictResolver: ...)— stores the resolved value (re-applying mutations queued behind it), removes the outbox record, and settles the handle with a newMutationResolved<T>(resolvedValue, wasConflict)outcome. Built-in resolvers:serverWins<T>()andclientWins<T>(). New observer events:onMutationConflict(key, error)andonMutationResolved(key).
Changed #
- BREAKING:
MutationOutcomegained theMutationResolvedcase, so exhaustiveswitches over outcomes must add it (sealed-class exhaustiveness makes this a compile-time break for code that switches without a default). - Conflicts thrown without any configured
ConflictResolvernow fail the mutation terminally instead of being retried (only affects code already throwingSyncacheConflictException, which did not exist before this release).
0.5.0 #
Added #
- Typed entries API.
cache.entry(key, fetch: ..., policy: ..., ttl: ..., tags: ...)returns aSyncacheEntry<T>— a lightweight, stateless view of one cache key bound to its fetcher and default parameters. Entries exposeget/getWithMeta(with per-call policy override),watch/watchWithMeta,refresh(get withPolicy.refresh),set, andinvalidate, each delegating to the identicalSyncacheoperation. Entries hold no stream controllers or other resources: they are cheap to create, shareable, and never need disposing.ScopedSyncache.entry(...)creates entries with automatic scope prefixing (entry.keyis the resolved, prefixed key).
0.4.0 #
Added #
- Bounded caches with LRU eviction.
MemoryStorenow implements the new optionalBoundedStorecapability: passmaxLength(constructor or the settable property) to evict the least-recently-used entry whenever a write exceeds the limit — reads and writes both refresh recency, evicted entries leave the tag maps, and every eviction firesonEvict. Default stays unbounded.Syncachewires a bounded store's evictions into its observers, so apps seeSyncacheObserver.onEvict(key)(watchers are not refetched — eviction is a capacity event, not an invalidation). IterableStorecapability (keys()): implemented byMemoryStore,SharedMemoryStore, andHiveStore.Storeitself is unchanged — both new capabilities are opt-in interfaces so third-party backends keep compiling.Syncache.cleanup(): removes every expired entry in one pass and returns the count; swept keys notify observers (onInvalidate) and their watchers refetch exactly likeinvalidate()does (streams stay open). Returns 0 for stores without key enumeration.- Automatic cleanup options (both default
null= manual only):cleanupIntervalruns a periodic sweep (timer cancelled indispose), andopportunisticCleanupEverysweeps at most once per N writes — constant amortized cost, disable withnull. SyncacheStatssnapshots via the asyncSyncache.statsgetter: hits, misses, fetches started/succeeded/failed/retried/cancelled, stores, invalidations, evictions, live pending-mutation count, and approximateentryCount(null when the store can't enumerate). Counters only — no per-key maps.resetStats()zeroes everything; pass aSet<SyncacheCounter>to reset individual counters. Powered by an internal observer that never appears in theobserverslist.SyncacheObserver.onEvict(key)for bounded-store evictions.LoggingObserver.verbose(defaulttrue): setfalseto suppress the high-frequencyCACHE HIT/FETCH START/STORElines.
Changed #
LoggingObserverdocumentation now points Flutter apps atsyncacheDebugLog(fromsyncache_flutter) to route output throughdebugPrint; pure Dart keepsprint.
0.3.0 #
Added #
- Durable mutation outbox (opt-in). Pass a
MutationOutboxto theSyncacheconstructor and every enqueued mutation is mirrored to it as aPendingMutationRecord(id, key, monotonicseq, caller-supplied JSONpayload,queuedAt). Records are deleted when the mutation settles and are preserved acrossdispose(), so a freshly constructed cache can replay unsynced writes after a process restart. Syncache.recoverPendingMutations(resolve): replays persisted records in enqueue order without re-writing the optimistic update, reports records that fail to resolve viaSyncacheObserver.onOutboxReplayFailed(never dropped silently), and flushes immediately when online. Returns the number of mutations re-enqueued.Syncache.mutate(payload: ...): persists a JSON-safe payload with the record. ThrowsStateErrorwhen a payload is given but no outbox is configured — fail loud instead of silently losing durability.- Exact replay via optimistic snapshots.
Syncache.mutateacceptsoptimisticSnapshot:— a JSON-safe snapshot of the optimistic value — persisted with the record, andSyncacheacceptsdecodeOptimisticSnapshot:to decode it at recovery. Replay then sends the exact pre-death value instead of re-derivingapply(currentCached), which double-applied base-dependent mutations (increments, appends) when the cache retained the optimistic state. Records stored without a snapshot fall back to re-derivation (documented below); a record that carries a snapshot but no configured codec fails loudly viaonOutboxReplayFailedinstead of silently falling back. SyncacheObserver.onOutboxError(Object error): outbox write failures never block cache operations.MemoryMutationOutbox: in-process reference implementation of the outbox contract (survives dispose/recreate, not process death).PendingMutationInfo.payloadso a UI can describe queued changes.ScopedSyncache.mutate(payload: ...)andScopedSyncache.recoverPendingMutations.
Implementation notes #
- When an outbox is configured,
mutate()resolves only after the record is persisted (a crash aftermutate()completes can never lose the write). Memory-only behavior (no outbox) is unchanged from 0.2.0. - Replay values come from optimistic snapshots. Pass
optimisticSnapshot:tomutate()(and configuredecodeOptimisticSnapshot:on the cache) and replay sends the exact pre-death value. Records stored without a snapshot fall back to re-derivingapply(currentCached)— computed only, never re-written to the store — which can double-apply base-dependent mutations when the cache retained the optimistic write. If a replayed record's cache entry is gone, the record is reported viaonOutboxReplayFailed. - Replayed records keep their original ids; the internal id/seq counters are seeded past them so new mutations can never collide with or order before replayed ones.
0.2.0 #
BREAKING CHANGES #
- Invalidation no longer closes watch streams.
invalidate,invalidateTag,invalidateTags,invalidatePattern, andclearused to close the affected watchers' streams (aCacheBuilderlanded inConnectionState.doneand never recovered). Invalidation now refetches each affected watcher with the fetch/policy/ttl it was created with: fresh data is emitted on success, the error is emitted as a stream error on failure (the stream stays open), andPolicy.cacheOnlywatchers receive aCacheMissExceptionwithout closing. Listeners keep the last value until the refetch completes (stale-while-invalidated).ConnectionState.donenow only occurs when every listener unsubscribes or the cache is disposed. Code that relied on a stream'sonDonefiring after invalidation must be updated. mutate()returnsFuture<MutationHandle<T>>(previouslyFuture<void>). Plainawait cache.mutate(...)call sites keep compiling; only code that explicitly typed the result is affected. The handle resolves with a sealedMutationOutcome:MutationSuccess(serverValue),MutationFailure(error, stackTrace, retried), orMutationDiscarded.clear()gains an optionalrefetchWatchersparameter (defaulttrue, so existing calls keep refetching — which is the new behavior instead of closing streams). Passclear(refetchWatchers: false)for silent bulk wipes. The flag is also available onclearScopeandScopedSyncache.clear.ReactiveNetworkadded to core. ANetworkthat also exposesonConnectivityChanged. CustomNetworkclasses are unaffected; see below.
Added #
- Automatic mutation flush on reconnect. When the provided
networkis aReactiveNetwork,Syncachesubscribes to its connectivity changes and processes pending mutations as soon as the device is back online — no newmutate()call required. Queued mutations are no longer only processed when the next mutation is enqueued. Syncache.processPendingMutations()— force a queue pass manually (idempotent while a pass is running or the network is offline).Syncache.removePendingMutation(String id)— remove a specific queued mutation by itsPendingMutationInfo.id; the mutation's handle settles withMutationDiscarded.removeFirstPendingMutation()is deprecated in favor of this.MutationHandle/ sealedMutationOutcome(MutationSuccess,MutationFailure,MutationDiscarded) — await a specific mutation's server confirmation, retry exhaustion, or removal.- Seeded queue streams:
pendingMutationsStreamandisSyncedStreamnow emit their current state to every new listener before live updates, so late subscribers (e.g. a sync indicator built after mutations were queued) see the present state immediately. Syncache.clearScope/ScopedSyncache.clearacceptrefetchWatchers.
Fixed #
- Removing an in-flight mutation (via queue control) no longer makes the processing loop drop the next queued mutation on completion — mutations are now removed by identity instead of by position.
0.1.2 #
Fixed #
mutateListItemwithupdateWherenow sends the item matching the predicate (previously it sent the first item of the list and never evaluated the predicate)mutateListItemwithremoveWherenow callssendonce per removed item, deduplicated byidSelector(previously the server call was silently skipped)- Plain
writenow preserves existing tags on allTaggableStoreimplementations — behavior was previously inconsistent betweenMemoryStore/SharedMemoryStore(preserve) andHiveStore(clear). The contract is now documented onTaggableStore: plainwritepreserves tags,writeWithTagsreplaces them CacheMissForConditionalExceptionis now exported from the public API and extendsCacheMissException, so conditional cache misses can be caught with a singleon CacheMissExceptionclauseScopedSyncache.prefetchnow passes throughPrefetchRequest.retry- Documentation: fixed the
Network,RetryConfig, andSyncacheObserverexamples; policy table now includescacheAndRefresh
Added #
ScopedSyncache.setandScopedSyncache.watchWithDependenciesScopedSyncache.prefetchOneaccepts aretryparameter- Shared
TaggableStoreconformance suite covering tag lifecycle semantics - Tests for
getConditional(conditional headers, 304 handling, cache-miss behavior)
0.1.1 #
- Add
Policy.cacheAndRefresh- returns cached data immediately and always refreshes in background (unlikestaleWhileRefreshwhich only refreshes when expired)
0.1.0 #
- Initial release
- Core caching with five policies: offlineFirst, cacheOnly, networkOnly, refresh, staleWhileRefresh
- Reactive streams via
watch()andwatchWithMeta() - Optimistic mutations with automatic background sync
- Tag-based cache invalidation
- Pattern-based cache invalidation (glob support)
- Request deduplication for concurrent fetches
- Retry with exponential backoff
- Cancellation token support
- Scoped caches with key prefixing
- Parallel and dependency-graph prefetching
- Observer pattern for logging and analytics
- MemoryStore and SharedMemoryStore implementations