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, Company here) stay inline in
their parent. The operation roots are entities like any other (ROOT_QUERY, ROOT_MUTATION):
{ "ROOT_QUERY": { "company": { "__typename": "Company", "name": "Sling Space", "ceo": "Alex Corren" }, "stats": { "__typename": "Stats", "totalLaunches": 181, "successRatePct": 89.8 }, "launches_1qouruf": { "__typename": "LaunchConnection", "nodes": [ { "__ref": "Launch:launch-181" }, { "__ref": "Launch:launch-180" } ], "pageInfo": { "__typename": "PageInfo", "hasNextPage": true, "endCursor": "Y3Vyc29yOjE5" }, "totalCount": 181 }, "launch_18di94z": { "__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" }}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_1qouruf", "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 }}Normalization(keyField: 'uuid') changes the field name (pass --key-field uuid to the
generator too); Normalization(identify: (obj) => …) replaces the whole rule;
Normalization.none turns it off and gives you the old path-addressed behaviour.
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_18di94z 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_18di94z: 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.
Entities that a replaced list no longer references are not removed automatically;
cache.gc() sweeps everything unreachable from the roots and returns the keys it dropped.
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. When mutations land, the pattern will be: write optimistically, send the
mutation, let the response overwrite, refetch on error.
Typed cache access: client.cacheScope
Section titled “Typed cache access: client.cacheScope”cache.read('query', ['launches_1qouruf', '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(first: 20) and its next page are separate lists, and the edit
applies to the one you name. 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.
Notification: per entity field
Section titled “Notification: per entity field”Every read records the dependency keys it traversed — ROOT_QUERY.launches_1qouruf,
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.
This is what replaced the coarse root-alias intersection of the first PoC iteration.
Persistence hooks
Section titled “Persistence hooks”Reads are synchronous — they happen inside build() — so the store is in memory and stays
in this package. What a persistence layer needs is exposed and deliberately small:
cache.snapshot— a JSON-able deep copy (refs as{"__ref": key}), andCache(initial: json)to hydrate it back;cache.onChange— aStream<Set<String>>of touched dependency keys after every write, for write-behind persistence (sqlite,hive, a file — as separate packages).
Not in the PoC
Section titled “Not in the PoC”- 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. - Type policies beyond identity (custom merge functions per field, pagination merging
à la Apollo
relayStylePagination).