Caching
The cache is the API
Section titled “The cache is the API”There is no “response object” in sling_gql. Widgets read the cache, always. A fetch is just a way of filling it. This is what makes optimistic updates trivial and what makes normalization powerful — but it also means you should understand its shape.
Shape: entities, with the roots as entities too
Section titled “Shape: entities, with the roots as entities too”The cache is a flat map of entities. Every object the response contains that has a
__typename and an id is stored once, under Typename:id, and referenced from wherever
it appeared. Objects without an id (Stats, PageInfo here) stay inline in their
parent. The operation roots are entities like any other (ROOT_QUERY, ROOT_MUTATION):
{ "ROOT_QUERY": { "company": { "__ref": "Company:company-1" }, "stats": { "__typename": "Stats", "totalLaunches": 181, "successRatePct": 89.8 }, "launches_c0y93u18lig6v": { "__typename": "LaunchConnection", "nodes": [ { "__ref": "Launch:launch-181" }, { "__ref": "Launch:launch-180" } ], "pageInfo": { "__typename": "PageInfo", "hasNextPage": true, "endCursor": "Y3Vyc29yOjE5" }, "totalCount": 181 }, "launch_1ov936k1qddray": { "__ref": "Launch:launch-181" } }, "Launch:launch-181": { "__typename": "Launch", "id": "launch-181", "name": "Crew-5", "rocket": { "__ref": "Rocket:starship" }, "details": "…" }, "Rocket:starship": { "__typename": "Rocket", "id": "starship", "name": "Starship" }, "Company:company-1": { "__typename": "Company", "id": "company-1", "name": "Sling Space", "ceo": "Alex Corren" }}Field keys are still the selection alias (field name + hash of the arguments), so a
response merges without any lookup table. Every Accessor is a (selection node, cache path) pair — ["launches_c0y93u18lig6v", "nodes", 0, "rocket"] for the rocket of the first row.
read walks the path and derefs a Ref transparently whenever it meets one; the
generated code never sees references. A path may also start with a Ref to address an
entity directly: ["Launch:launch-181" as Ref, "name"].
Identity: where the id comes from
Section titled “Identity: where the id comes from”Normalization only works if id is actually in the response. The generator knows which
object types have an id field and emits keyed: true on every field returning one
(object('rocket', Rocket.new, keyed: true)). The runtime then prints id next to
__typename in every such selection — you never select it by hand:
launches(first: $first) { __typename nodes { __typename id # added automatically: Launch is keyed name }}The key field comes from the generator: --key-field uuid decides which types are keyed
and is recorded in the generated slingSchema, so SlingClient(schema: slingSchema)
builds its cache with Normalization(keyField: 'uuid') — nothing to keep in sync by hand.
Passing your own cache: next to schema:? Build it with
Cache(normalization: slingSchema.normalization); a mismatching key field fails an assert.
Normalization(identify: (obj) => …) replaces the whole rule;
Normalization.none turns it off: every object is then stored where it appears, by path.
An identify that reads more than the key field (an id only unique per owner, keyed
Item:<id>:<ownerId>) declares those fields on the type, so that every request selects
them, one fetching only the fields a screen lacks included:
SlingClient<Query>( schema: slingSchema, cache: Cache(normalization: normalization), typePolicies: const {Item: TypePolicy(keyFields: ['ownerId'])},);Lookups: launch(id:) is served from the entity
Section titled “Lookups: launch(id:) is served from the entity”A root field whose only argument is the key and which returns a keyed type
(launch(id: ID!): Launch) is emitted with lookup: 'Launch'. When launch_1ov936k1qddray is not
in ROOT_QUERY yet but Launch:launch-181 exists, the accessor is redirected to the
entity. Fields the entity already has are read from cache; fields it lacks miss
individually and are fetched — only those:
query ($id: ID!) { launch_1ov936k1qddray: launch(id: $id) { __typename id details # not in the list → fetched flightNumber # not in the list → fetched # name, date, status, rocket { name } came from the list's entities }}That is the “no double fetch across paths” property; example/test/app_test.dart asserts
it on the real request.
Writes: entities merge, lists are replaced
Section titled “Writes: entities merge, lists are replaced”Responses are merged, not replaced, at the entity level: widget A fetching
me { name } and widget B fetching me { age } end up with one User:1 holding both.
A list of references is replaced wholesale by the incoming list (there is nothing to merge
element-wise — the elements themselves merge as entities). Inline objects deep-merge as
before, and inline lists still merge by index.
Garbage collection
Section titled “Garbage collection”Entities a replaced list no longer references (or that only an evicted parent or a
mutation’s result pointed to) stay in the cache until a sweep. The client runs one by
itself every 100 server responses, the next time it is idle: client.gc() removes
everything unreachable from the roots except the entities a live widget reads — a
detail screen served from launch(id:) keeps its Launch even after the list that
brought it moved on. It returns the keys it dropped, and never runs while a mutation is in
flight (a rollback could bring references back).
SlingClient(schema: slingSchema, gcAfterWrites: 500); // sweep less oftenSlingClient(schema: slingSchema, gcAfterWrites: null); // only when you call client.gc()
// e.g. sweep when the app goes to the background:final listener = AppLifecycleListener(onPause: client.gc);cache.gc() is the raw version: it only keeps what the roots reach (plus any
retain: keys you pass), whatever is on screen.
Optimistic writes
Section titled “Optimistic writes”Every scalar field has a generated setter that writes to the cache:
launch.name = '${launch.name} ✨';Because there is one Launch:launch-181, setting name on the detail screen updates the
list row too. Inside a mutation’s optimistic: callback such writes are journaled and
rolled back if the mutation fails — see Mutations.
Typed cache access: client.cacheScope
Section titled “Typed cache access: client.cacheScope”cache.read('query', ['launches_c0y93u18lig6v', 'nodes', 0]) works, but nobody can type a hashed
alias. Outside a widget build — in an optimistic: callback, after await client.mutate(...),
in a push-notification handler — use the typed scope instead:
final cache = client.cacheScope; // SlingScope.of<Query>(context).cacheScopefinal launch = cache.launch('launch-181'); // Launch? — the cached entity, or nulllaunch?.favorite = true; // ordinary generated setterfinal name = cache.query.me?.name; // typed root fields, from the cache onlyclient.cacheScope returns a CacheScope<Query>, a recorder like the one behind every
QueryBuilder, with two differences: reads never fetch (a field that is not cached reads
as null, an object as a skeleton, and nothing is sent) and it never rebuilds (it records
no dependencies). Writes go through the normal write path: every scope that read the field
rebuilds, and inside a mutation’s optimistic callback they are journaled and rolled back on
failure, exactly like setters on accessors you got from a build.
The generator emits one method per keyed type (extension SlingCacheAccess on CacheScope<Query>):
extension SlingCacheAccess on CacheScope<Query> { Launch? launch(String id) => entity('Launch', id, Launch.new); Rocket? rocket(String id) => entity('Rocket', id, Rocket.new); // …}entity(typename, id, ctor) resolves the key through Normalization.lookup —
launch('launch-181') addresses Launch:launch-181 — and returns null when that entity is
not in the cache: never a skeleton, never a request. The accessor it returns is the ordinary
generated Launch, starting at the entity (path == [Ref('Launch:launch-181')]), so
everything reachable from it (launch.rocket?.name) reads the same way.
List membership and eviction
Section titled “List membership and eviction”A response replaces a list wholesale, but a mutation’s response rarely contains the list it
changed. cacheScope.list(selector) addresses one cached list field — by reading it, so the
arguments hash to the same alias as in the widget — and edits its refs:
final cache = client.cacheScope;final favorites = cache.list((q) => q.me?.favorites); // CacheList<Launch>favorites.prepend(cache.launch('launch-181')!); // also: append, remove, containscache.evict(cache.launch('launch-180')!); // gone from every listEdits write the whole new list back (Viewer:viewer-1.favorites is the touched key, exactly as
if a response had replaced it), are journaled like any CacheWrite inside a mutation’s
optimistic callback, and are set-like no-ops on a list that is not cached. Each argument
set is its own entry — launches(filter: …) lists are separate — and the edit applies to the
one you name; with RelayStylePagination the pages of one connection are
one list, edited as a whole. See Updating lists after a
mutation for the full rules.
cache.evict(entity) (or the untyped client.cache.evict('Launch:launch-181')) removes the
entity, drops it from every list that referenced it, and blanks object fields that pointed at
it so they read as missing and get re-fetched on the next build. The typed form also
notifies every scope that read one of those fields.
List rules: lists that follow their entities
Section titled “List rules: lists that follow their entities”cacheScope.list is imperative: you know which list changed. Often the list is a
function of the entity — launches(filter: {status}) holds a launch iff its status
matches, me.favorites holds a launch iff launch.favorite — and then a rule can do
it, whichever way the entity changed (a mutation response, an optimistic setter, a
subscription event):
SlingClient<Query>( listRules: [ ListRule<Launch>( field: 'launches', items: 'nodes', // a connection: the list is under `nodes` typename: 'Launch', ctor: Launch.new, belongs: (args, launch) { final status = (args['filter'] as Map?)?['status']; return status == null || launch.status?.graphqlName == status; }, position: ListPosition.prepend, ), ListRule<Launch>( field: 'favorites', typename: 'Launch', ctor: Launch.new, belongs: (_, launch) => launch.favorite == true, ), ],)Whenever a Launch entity is written, the client re-evaluates belongs for every cached
list under a launches / favorites node it has sent — it remembers each alias’s
arguments, so the “Success” segment you visited earlier counts even though nothing is
showing it — and adds or removes the reference. Lists that were never fetched are left
alone. Edits go through the write path: dependants rebuild, and inside optimistic they
are journaled and rolled back with the mutation.
Two rules of the rules:
- Query responses only remove. Without a merging policy each page is its own list, and
the launches of page two “belong” to
launches(first: 20)as much as page one’s do — yet must not be added to it. So a response can make an entity leave lists it no longer belongs to (the cached “Scheduled” segment loses a launch a fresh response shows asSUCCESS), but entities only join lists on what the app or the server pushes: mutation responses, subscription events, setters. A response’s own lists are the server’s word. - Decide on fields every reader selects.
belongsreads through a non-fetching accessor: a field that is not cached readsnull, and a rule on it would evict the entity.statusis on every row;upcomingis not.
Membership is set-like; the position of a joining entity is prepend/append, and
totalCount-style siblings are not maintained. The mission-control flow of the
example app runs on two such rules.
Notification: per entity field
Section titled “Notification: per entity field”Every read records the dependency keys it traversed — ROOT_QUERY.launches_c0y93u18lig6v,
Launch:launch-181.name — on the scope that made it (entity.field, see depKey). Every
write returns the keys it touched. A scope rebuilds when the two intersect, so:
- a mutation returning
Launch:launch-181 { status }rebuilds the list row and the detail screen, but not a widget that only read the launch’sname; - the detail screen’s fetch does not rebuild the header that reads
company.
A key is touched only when its value changed. That includes inline objects and lists
(stats, pageInfo, a connection without an id): they are compared structurally as
they are merged, so a refetch that returns the same pageInfo rebuilds nobody who reads
it. A changed field inside a referenced entity touches that entity’s key
(Launch:launch-181.name), not the list holding the reference.
Inline lists: per element
Section titled “Inline lists: per element”A list of objects without an id (Launch.links, say) held by an entity field is split
further. A read through element 2 (launch.links[2].url) records
Launch:launch-181.links[2], and launch.links itself records only the list’s length,
Launch:launch-181.links[length]. A response or a setter changing the url of one link
touches links and links[2], so only the readers of that element rebuild; a link added
or removed touches links[length] too (and the elements that went away or shifted).
- A list nested inside an element (
links[2].parts) is part of that element. - Lists of entities keep depending on the whole field: their elements change by
themselves (
Launch:launch-182.name), and a reordering rebuilds every reader of the list. - Lists of scalars (
tags) are read whole, so their readers depend on the whole field.
A scope that calls the list getter depends on the length either way; what gains is a
SlingRow per element, whose only key is its element’s.
Per-row rebuilds: SlingRow
Section titled “Per-row rebuilds: SlingRow”The unit of rebuilding is the scope, and a QueryBuilder is one scope. Rows that are
plain child widgets record their reads there too, so a write to one launch rebuilds the
whole list — every visible row included. Wrap the row in a SlingRow to give it its own
dependency set:
SliverList.builder( itemCount: launches.length, itemBuilder: (context, i) => SlingRow( launches[i], ctor: Launch.new, // the generated constructor tear-off builder: (context, launch) => LaunchTile( name: launch.name, favorite: launch.favorite ?? false, ), ),)The row still fetches through the list’s request (misses go to the enclosing
QueryBuilder, which also keeps the loading and error state), but the keys it reads
(Launch:launch-181.favorite) are its own: toggling a favourite, a subscription event or
an optimistic setter rebuilds that row only. The list keeps depending on the list itself,
so adding and removing rows works as before. Read the row’s fields through the accessor
the builder receives; reads through the parent’s copy still land on the parent. The
example’s LaunchRow is built this way.
SlingRow is worth it for long lists and busy entities (live status updates); a short
screen that rebuilds as a whole is cheaper to write and just as fast.
Try it
Section titled “Try it”Five launches, each row counting its builds, and a detail card for the first one:
class LaunchList extends StatelessWidget { const LaunchList({super.key, required this.slingRows, required this.builds}); final bool slingRows; final Map<String, int> builds;
@override Widget build(BuildContext context) { return QueryBuilder<Query>( builder: (context, query, state) { final launches = query.launches(first: 5)?.nodes ?? const <Launch>[]; return ListView( children: [ for (final launch in launches) slingRows // Its own dependencies: a write to this launch rebuilds // this row only. ? SlingRow( launch, ctor: Launch.new, builder: (context, launch) => LaunchTile(launch, builds), ) // Reads land on the list's scope: any write to any row // rebuilds the whole list. : LaunchTile(launch, builds), ], ); }, ); }}class DetailCard extends StatelessWidget { const DetailCard({super.key});
@override Widget build(BuildContext context) { return QueryBuilder<Query>( builder: (context, query, state) { // `launch(id:)` is a lookup: it finds the Launch:launch-181 the list // already stored, so reading its name costs no request. final name = query.launch(id: _firstId)?.name; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: kColorSurface, borderRadius: BorderRadius.circular(10), ), child: Text( 'Detail: ${name ?? '…'}', style: const TextStyle(fontWeight: FontWeight.w600), ), ); }, ); }}- Open the first launch: the detail shows its name with no request. The list
stored
Launch:launch-181, andlaunch(id:)found it. - Rename it writes
launch.nameinto the cache (a setter, as an optimistic update would). The row and the detail change together, still no request. - With SlingRow, only the renamed row’s count goes up. Switch to Plain rows and rename again: every row is rebuilt, because their reads belong to the list’s scope.
Type policies
Section titled “Type policies”By default every argument set of a field is its own cache entry and a response replaces
it. SlingClient(typePolicies:) changes that per field — app configuration, not generated
code — keyed by the generated accessor class the field is read through and the field
name:
SlingClient<Query>( typePolicies: { Query: TypePolicy(fields: { 'launches': RelayStylePagination(), // pages → one growing list 'search': FieldPolicy(keyArgs: ['text']), // `limit` shares the entry }), Rocket: TypePolicy(fields: {'launches': RelayStylePagination()}), },)keyArgsare the arguments that identify the entry; the others do not. Each request still prints the field under an alias of all its arguments (two pages in one document never clash) and the response is mapped back to the entry, as for fragment fields.RelayStylePaginationmerges cursor pages into one list (nodes,edges,pageInfo), withfirst/after/last/beforeout of the key; a page not merged yet reads as a miss, a refresh starts the list over. The pagination guide has the details.- The key is the exact class. A field read through an interface accessor (
Node) and through a member’sasTeamare two lookups: register the policy under each class the field is read through.
The merged value is an ordinary cache value: the merge runs per page, then the result is
merged into the field like any response, so only what changed touches its readers (an
identical refresh rebuilds nothing), fetchedAt, changesSince / compact, gc,
optimistic rollbacks, list rules and SQLite persistence
see one field. A relay entry also records which pages it holds (__pages, a key no
GraphQL field can have), which is how it tells a merged page from one to fetch, after a
restart too.
A custom merge
Section titled “A custom merge”FieldPolicy(merge:) gets the cached value (null when absent), one page of the response
and the arguments it was sent with — in the cache’s own format: entities are refs
(Ref from package:sling_gql/internal.dart, equal when their keys are), inline objects
maps. Return a new value; do not mutate existing:
'tags': FieldPolicy( keyArgs: const [], // every page in one entry merge: (existing, incoming, context) => [ ...?existing as List?, ...incoming! as List, ],),A merge alone never fetches a page: with every argument set sharing the entry, any read is
served from it. For pages of your own (offsets, say), subclass FieldPolicy and override
covers(existing, args) (does the entry hold what these arguments ask for? false is a
miss, fetched with them), pageArgs (dropped by a refresh, so it starts over) and
pages(existing) (what to refetch when a row reads a new field) — RelayStylePagination
is that class for cursors.
Persistence hooks
Section titled “Persistence hooks”Reads are synchronous — they happen inside build() — so the store is in memory and stays
in this package. To keep it across launches in SQLite, use
sling_gql_sqflite: it is built on the hooks below.
What a persistence layer (sqlite, hive, a file — as separate packages) needs is exposed
and deliberately small:
cache.snapshot— a JSON-able deep copy of every entity (refs as{"__ref": key}), andCache(initial: json)to hydrate it back;cache.onChange— aStream<Set<String>>of the dependency keys (entity.field) a change touched. It fires once per change: a response, a mutation, a subscription event or an optimistic callback is one change, list-rule edits and an automaticgcincluded. Several manual writes become one withcache.batch(() { ... }). Nothing fires when a refetch brings the same data;cache.versionandcache.changesSince(version)— a counter bumped once per change, and the entities changed or removed since an earlier value of it, as aCacheDelta: JSON copies of those entities only (changed), the keys to delete (removed), and theversionto ask from next time. The operation roots (ROOT_QUERY, …), which hold one field per query, come field by field instead (changedFields,removedFields), so a save does not grow with the session;delta.applyTo(json)applies all of it to a stored snapshot.cache.compact(upTo: version)tells the cache a store holds everything up toversion: it drops those change records (deltas from older versions are thenfull). A hydrated cache starts at version 0: what it was hydrated from is the persisted baseline.
So a write-behind adapter debounces on onChange and stores deltas, never the whole cache
(the sketch below is the shape of what sling_gql_sqflite does, minus its per-root-field
rows, bounds and invalidation).
Saves run one after another: a slow save must not land after a newer one and put older
copies back on disk.
final stored = await store.readAll(); // the JSON a previous run persistedfinal cache = Cache(initial: stored);var persisted = cache.version; // 0: `stored` is up to date
Future<void> save() async { final delta = cache.changesSince(persisted); if (delta.isEmpty) return; if (delta.full) await store.deleteAll(); await store.delete(delta.removed); await store.putAll(delta.changed); // Roots: merge the changed fields into the stored root, drop the removed ones. await store.patchAll(delta.changedFields, delta.removedFields); persisted = delta.version; // not reached if a write threw: retried next time cache.compact(upTo: persisted); // the only reader: forget what is stored}
Timer? debounce;var saving = Future<void>.value();cache.onChange.listen((_) { debounce?.cancel(); debounce = Timer(const Duration(milliseconds: 500), () { // Chained, never concurrent: each save starts from the previous one's version. saving = saving.then((_) => save()).catchError((Object e) => log(e)); });});delta.full is set when the delta replaces everything: after cache.clear(), or when
persisted is older than what the cache remembers: compacted away, or — in a cache never
compacted — removals forgotten once there are more than a thousand and more than live
entities (the delta then carries every entity).
A store kept as one JSON map can use delta.applyTo(json).
The copies are real copies: entities are merged in place by later writes (inline objects,
lists), so neither snapshot nor a delta can share maps with the store. snapshot copies
~190 entities in about 50 µs in the example’s benchmark, a one-entity delta in about 1.5 µs.
fetchedAt stamps are not persisted: hydrated data counts as stale for maxAge (see
Fetch policies & freshness and
Persistence).
Not supported (yet)
Section titled “Not supported (yet)”- Eviction of stale entries.
maxAgerevalidates stale data (see Fetch policies & freshness) but never drops it; Dart hasWeakReferenceandFinalizer, so stale entries could be held weakly and evicted by the GC rather than by timers. readfunctions (Apollo’s computed fields / redirects) andpossibleTypesinheritance for type policies: a policy keys on one accessor class.