qora 1.2.0
qora: ^1.2.0 copied to clipboard
A powerful async state management library for Dart, inspired by TanStack Query
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.
Unreleased #
1.2.0 - 2026-06-15 #
Added #
structuralSharing: newQoraOptions.structuralSharingfield (defaulttrue). Preserves referential equality for unchanged nested data across fetches. When a fetch returns data that is deeply equal to the existing cache, Qora retains the previous data reference. This prevents unnecessary widget rebuilds in deeply nested UIs. Supported on all cache write paths:fetchQuery,setQueryData,setInfiniteQueryData,updateInfiniteQueryState, andhydrateQuery.structuralShare<T>(T a, T b): utility function exported frompackage:qora/qora.dart. Compares two values with deep equality (recursive for Map, List, Set) and returnsawhen they are equal, preserving referential identity.QoraClient.clearCache: clears the in-memory cache only, without affecting persistent storage. Useful for resetting memory state (e.g. on user logout) while keeping offline storage intact.MutationOptions.retryCondition: predicate to determine whether a failed mutation should be retried, matching the existingQoraOptions.retryCondition.MutationOptions.invalidates: declare a list of query keys to automatically invalidate after a successful mutation. Replaces the common manualclient.invalidate()call inonSuccess.MutationController.invalidateQuery: callback wired automatically byQoraMutationBuilderanduseMutationfrom the nearestQoraScope.- Tag-based invalidation: decouple cache invalidation from query keys using logical tags. Queries declare tags they provide via
QoraOptions.providesTags, and mutations declare tags they invalidate viaMutationOptions.invalidatesTags. Wildcard matching applies: invalidatingQueryTag('post')refetches ALL queries providing any tag of that type, regardless of id. Includes newQueryTagclass,QoraClient.invalidateTags()method, andMutationController.invalidateTagscallback. - Transformation pipeline:
QoraOptions.transformandQoraOptions.transformErrorlet you decouple data transformation from the fetcher.transformruns after a successful fetch (before the data enters the cache and before structural sharing).transformErrorruns after all retries fail (before the error enters the cache and beforeQoraClientConfig.errorMapper). Both the fetcher and the transform functions are independently testable. - keepPreviousData: new
QoraOptions.keepPreviousDatafield (defaultfalse). When enabled, the cache entry stays inSuccessstate during refetch instead of transitioning toLoading, suppressing the loading flash. On fetch failure, the entry stays inSuccesswith the previous data instead of transitioning toFailure. Useful for offset-based pagination where you want the current page visible while fetching the next one. Has no effect on initial fetches (no previous data to keep).
Changed #
- BREAKING:
PersistQoraClient.persistDurationandPersistQoraClient.persistQuery(ttl: ...)now accept a nullableDuration?.nullnow means "persist indefinitely" (never expire by TTL).Duration.zeronow means "disable persistence" (expire immediately).- Previously,
Duration.zeromeant "persist indefinitely", which was counter-intuitive.
Fixed #
QoraClient.watchQuery: fixed a timing bug where the initialLoadingstate was dropped before the stream subscriber was fully attached.PersistQoraClient.hydrate: orphaned storage entries with no registered serializer are now deleted immediately instead of accumulating indefinitely.
1.1.0 - 2026-06-04 #
Added #
QoraClientConfig.onBackgroundFetchError: callback invoked when a background fetch triggered bywatchQueryfails; the error is already stored in theFailurestate — use this for logging, telemetry, or crash reporting.pubspec.yaml: addedtopicsfield.
Fixed #
QoraClient.watchQuery: background fetches viaunawaited(_doFetch(...))re-threw errors that crashed the current asyncZone; errors are now safely intercepted and forwarded toonBackgroundFetchError.combine/combineList/combine2/combine3:updatedAtnow correctly preserves the oldest timestamp from the inputSuccessstates usingisBefore, ensuringstaleTimelogic remains accurate.debounceLoading: naiveFuture.delayedinsideawait forblocked the entire stream, a Success arriving during the delay was blocked behind the Loading yield. Rewritten with aStreamTransformer+Timerso non-Loading states cancel any pending Loading timer and pass through immediately, preventing flickering spinners on fast requests.registerSerializer:assert(name != null)only fired in debug builds, silently corrupting persisted data in release builds with obfuscation. Changed toArgumentErrorthat always throws._onAppResumed: iterated directly over_cache.entrieswithout snapshotting, risking concurrent modification if entries changed during iteration. Added.toList().
Changed #
mapSuccessrenamed tomapDatabecause it also transformsLoadingandFailurestates when they holdpreviousData.debounceLoadingrenamed todelayLoadingto better reflect its behaviour.QueryFunction,MutatorFunction,InfiniteQueryFunction: translated docstrings from French to English (legacy oversight).
1.0.0 - 2026-06-01 #
Fixed #
Failure.hashCode: fixed a bug whereFailurestates incorrectly computed their hash using theErrortype instead of their own type.QoraClient.watchFetchStatus: fixed a potential memory leak where the localStreamControllerwas not properly closed on cancel.QoraStatedocs: corrected outdated docstrings referencingErrorinstead ofFailure.
0.9.0 - 2026-06-01 #
Added #
QoraClient.hasActiveWatcher(Object key): returnstruewhen the cache entry forkeyhas at least one activewatchQuerysubscriber. DevTools gateways use this to distinguish "refetch will fire a real network call" from "entry is stale but no fetcher is in scope".QoraTracker.needsSerialization:boolgetter on the tracker interface;QoraClientskips_serializeForTrackerentirely whenfalse, eliminating JSON serialization overhead forNoOpTrackerin production builds.NoOpTracker.needsSerialization: returnsfalse; zero-cost guard so production apps never pay the serialization cost.QoraClient.attachLifecycleManager(LifecycleManager): wires a lifecycle manager post-construction;QoraScopenow calls this sorefetchOnWindowFocusactually triggers background revalidation on app resume.- Smart eviction timer: replaced
Timer.periodic(1 min)with a self-scheduling one-shotTimerthat fires exactly when the earliest inactive entry expires; reduces idle wakeups to zero for long cache-time configurations.
Changed #
MutationEvent→MutationUpdate(breaking): eliminates the name collision with theMutationEventwire-protocol type inqora_devtools_shared. Affected public APIs:QoraClient.mutationEvents → Stream<MutationUpdate>,QoraClient.activeMutations → Map<String, MutationUpdate>.QoraTracker.onQueryFetched: addedString? dependsOnKeynamed parameter (forwarded fromQoraOptions.dependsOn); allows DevTools to draw real query→query dependency edges. Existing custom trackers must add the nullable parameter (ignored byNoOpTracker).
Fixed #
QueryCache.get<T>: replaced silentas CacheEntry<T>?cast with anis!check that throws a descriptiveStateErrornaming both the registered and requested types; catches key/type conflicts at the misuse site, not deep in a stream listener.QoraClient.removeQuery: was leaking aStreamControllerin_fetchStatusBus; now calls_fetchStatusBus.remove(sk)?.close()on eviction.QoraClient.watchQuerypolling timer: two concurrent subscriptions on the same key could cancel each other'srefetchIntervaltimers via sharedentry.refetchTimer; timer is now local to each generator invocation, cancelled in its ownfinallyblock.QoraClient._toJsonSafe:on NoSuchMethodErrorwas incorrect (Error, notException); restructured to a singlecatch (e)with anis NoSuchMethodErrorguard inside.QoraScopelifecycle wiring:initStatecalledlifecycleManager.start()but never passed the manager toQoraClient;refetchOnWindowFocuswas dead code; fixed by callingclient.attachLifecycleManager().PersistQoraClient.registerSerializer<T>: addedassert(name != null)guard with a descriptive message for obfuscated builds whereT.toString()produces mangled identifiers._evictExpiredEntries: replaced unconditional.toList()with a lazyList?only allocated when entries need removal.
0.8.0 - 2026-03-12 #
Added #
CacheEntry.setError(Object error): transitions an entry toFailure<T>using the entry's own reifiedT; eliminates theFailure<dynamic>cast error that occurred whendebugSetQueryErrorused an untyped cache lookup.CacheEntry.markStale(): sets an internal_forcedStaleflag without pushing any state update to observers;isStale()now returnstruewhenever_forcedStaleis set, regardless ofstaleTime; the flag is cleared byupdateState().QoraClient.markStale(Object key): silently flags a cache entry stale without transitioning toLoadingor triggering an immediate refetch; active observers see no change; the nextfetchQuery/watchQuerymount will seeisStale() == trueand trigger an SWR background revalidation.QoraTracker.onQueryRemoved(String key): hook called whenremoveQueryevicts a cache entry;NoOpTrackerships an empty override.QoraTracker.onQueryMarkedStale(String key): hook called whenmarkStalesilently flags an entry; differs fromonQueryInvalidatedin that no state transition or timeline fetch entry is implied;NoOpTrackerships an empty override.
Fixed #
QoraClient.debugSetQueryError(): previously called_cache.get<dynamic>()and pushedFailure<dynamic>into aStreamController<QoraState<T>>, causing aTypeErrorat runtime; now uses_cache.peek()and delegates toCacheEntry.setError()so theFailureis instantiated with the correct reifiedT.QoraClient.removeQuery(): did not notify the tracker; now calls_tracker.onQueryRemoved(sk)so DevTools overlays remove the corresponding row immediately.
0.7.0 - 2026-03-03 #
Added #
CancelToken: cooperative cancellation forfetchQuery,watchQuery, andprefetch; state restored to pre-fetch snapshot on cancellationQoraCancelException: thrown to the caller when a fetch is cancelled viaCancelTokenQoraTracker.onQueryCancelled(String key): hook called when a fetch is cancelled;NoOpTrackerships an empty overrideQueryFiltertypedef:bool Function(String key, QoraState<dynamic> state, QoraOptions? lastOptions): richer invalidation predicateQoraClient.invalidateQueries({required QueryFilter filter}): bulk invalidation usingQueryFilterCacheEntry.lastOptions: records the options from the last successful fetch; used byinvalidateQueriesQoraOptions.dependsOn: declares a query dependency;watchQueryfires reactively when the dependency resolves,fetchQuerythrowsStateErrorif unresolved,prefetchsilently skipsQoraClient.queueHydration(key, data, {updatedAt}): enqueue a pre-deserialized value for lazy typed injection; shared hydration mechanism forPersistQoraClientandSsrHydratorQoraClient.removeHydrationEntry(key):@protected; removes a pending hydration entryQoraClient.clearHydrationQueue():@protected; clears all pending hydration entriesSsrHydrator: Flutter Web SSR hydrator; readswindow.__QORA_STATE__, validates strictly, and callsqueueHydration(); XSS-safe viadartify()and per-deserializer try/catch; no-op stub on non-web platformsQoraTracker.onQueryFetching(String key): hook called when a query transitions toLoading; pairs withonQueryFetchedfor fetch-duration tracking;NoOpTrackerships an empty overrideInfiniteData<TData, TPageParam>: immutable container for paginated pages;append(),prepend(),dropFirst(),dropLast(),flatten()InfiniteQueryState<TData, TPageParam>: sealed state machine:InfiniteInitial,InfiniteLoading,InfiniteSuccess,InfiniteFailureInfiniteQueryOptions<TData, TPageParam>: pagination config:initialPageParam,getNextPageParam,getPreviousPageParam,maxPagesInfiniteQueryObserver<TData, TPageParam>: pagination engine:fetch(),fetchNextPage(),fetchPreviousPage(),refetch()InfiniteQueryFunction<TData, TPageParam>typedef:Future<TData> Function(TPageParam pageParam)QoraClient.watchInfiniteState,getInfiniteQueryState,getInfiniteQueryData,setInfiniteQueryData,updateInfiniteQueryState,invalidateInfiniteQuery
Changed #
PersistQoraClienthydration delegated toQoraClient: hydration infrastructure lifted to the base class;PersistQoraClient.hydrate()now callsqueueHydration(); the six typed overrides removed
0.6.0 - 2026-03-02 #
Added #
NetworkMode: per-query enum (online/always/offlineFirst)FetchStatus: second-axis enum (fetching/paused/idle); observable viaQoraClient.watchFetchStatus(key)ReconnectStrategy: thundering-herd prevention on reconnect:maxConcurrent+jitter; named constructorsinstant()andconservative()OfflineMutationQueue: FIFO queue for offline writes; replays on reconnect;stopOnFirstErrorflag;OfflineReplayResultsurfacePendingMutation: type-erased queued-write containerQoraOfflineException: thrown byfetchQuerywhen offline with no cached dataQoraClient.attachConnectivityManager(): late-attach aConnectivityManager; called automatically byQoraScopeQoraClient.isOnline/networkStatus: real-time connectivity gettersQoraClient.watchFetchStatus(key): stream ofFetchStatustransitionsQoraClient.offlineMutationQueue: sharedOfflineMutationQueueinstanceMutationSuccess.isOptimistic:truewhen the mutation was queued offline with anoptimisticResponseMutationOptions.offlineQueue: opt a mutation into theOfflineMutationQueueMutationOptions.optimisticResponse: syntheticTDatafor immediate UI feedbackQoraClientConfig.reconnectStrategy: global reconnect strategy; defaults to 5 concurrent / 100 ms jitterQoraOptions.networkMode: per-queryNetworkMode; defaults toNetworkMode.online
0.5.0 - 2026-03-01 #
Added #
PersistQoraClient:QoraClientsubclass that persists query results to aStorageAdapterand restores them on startupStorageAdapter: abstract key/value interface; ships withInMemoryStorageAdapterQoraSerializer<T>:toJson/fromJsonpair for a typePersistQoraClient.registerSerializer<T>: register a serializer; accepts optionalnamefor obfuscation safetyPersistQoraClient.hydrate(): reads storage, validates TTL, queues valid entries for lazy hydrationPersistQoraClient.persistQuery<T>: force-persist the current cached value with an optional TTL overridePersistQoraClient.evictFromStorage/clearStorage: storage-only evictionQoraClient.hydrateQuery<T>: inject a typedSuccess<T>with a customupdatedAtinto anInitialentryQoraClient.onFetchSuccess<T>:@protectedhook called after every successful fetch; used byPersistQoraClientto auto-persist
Fixed #
QoraStateSerialization.toJsonwrote'type': 'error'forFailurewhilefromJsonmatched on'failure';Failurestates were never restored correctly
0.4.0 - 2026-02-28 #
Added #
QoraTracker: abstract observability interface with lifecycle hooks for queries, mutations, and cache eventsNoOpTracker: defaultconstimplementation with zero overheadQoraClient(tracker:): optional tracker injection; defaults toNoOpTracker
0.3.0 - 2026-02-25 #
Added #
MutationController<TData, TVariables, TContext>: manages the full mutation lifecycle:Idle → Pending → Success | FailureMutationState<TData, TVariables>: sealed class:MutationIdle,MutationPending,MutationSuccess,MutationFailure; each carries typedvariablesMutationOptions<TData, TVariables, TContext>: lifecycle callbacks:onMutate,onSuccess,onError,onSettled;retryCount/retryDelayMutatorFunction<TData, TVariables>typedefMutationTracker: abstract interface implemented byQoraClient; decouplesMutationControllerfrom the clientMutationEvent: type-erased event on every mutation state transition;mutatorId,status,data,error,variables,metadata,timestampQoraClientimplementsMutationTracker:mutationEventsstream,activeMutationssnapshot;debugInfo()now includesactive_mutationsMutationController.metadata:Map<String, Object?>?forwarded to everyMutationEventMutationController.id: uniquemutation_NidentifierMutationStateExtensions:fold<R>()andstatusgetterMutationStatusenum:idle | pending | success | errorMutationStateStreamExtensions:whereSuccess(),whereError(),dataOrNull()
Changed #
MutationFunctionrenamed toMutatorFunction
Fixed #
MutationController.streamrace condition: events emitted synchronously before the first microtask were lost; fixed with aStreamControllerwhoseonListenruns synchronously
0.2.0 - 2026-02-22 #
Added #
watchState<T>(key): observe-only stream; no fetch triggeredprefetch<T>(): pre-warm the cache before navigation; no-op if already freshrestoreQueryData<T>(key, snapshot): roll back an optimistic updateremoveQuery(key): evict a single query and cancel any in-flight requestclear(): evict all queries and cancel all in-flight requestscachedKeys: all currently cached normalised query keysdebugInfo(): cache and pending-request count snapshot
Changed #
QoraState<T>rewritten as a sealed class:Initial | Loading | Success | Failure;LoadingandFailurecarrypreviousData- Polymorphic key system: all APIs now accept
Object(plainList<dynamic>orQoraKey); deep structural equality KeyCacheMap: custom map with deep recursive equality and order-independent map-key comparisoninvalidate(key)replacesinvalidateQuery(key)invalidateWhere(predicate)replacesinvalidateQueries(predicate)- Package structure reorganised:
cache/,config/,client/,key/,state/,utils/
Fixed #
- Normalised key lists wrapped in
List.unmodifiable()to prevent accidental mutation