Skip to content

Architecture

  • Directorypackages/sling_gql/lib/
    • sling_gql.dart app-facing exports
    • internal.dart NormalizedCache, Ref, missing, depKey, CacheWrite, MutationScope
    • Directorysrc/
      • selection.dart Selection tree, Arg, PrintedOperation
      • Directorycache/
        • cache.dart Cache interface, NormalizedCache, depKey
        • normalization.dart identity rule (__typename:id), lookups
        • ref.dart Ref, missing sentinel
      • accessor.dart Accessor base class, Recorder interface
      • client.dart SlingClient, QueryScope, MutationScope, SubscriptionScope, transports
      • widgets.dart SlingScope, QueryBuilder, MutationBuilder, SubscriptionBuilder

About 900 lines, one dependency (package:http). Generated code depends only on Accessor, Recorder, Selection and Arg. Everything except widgets.dart is pure Dart; cache/ imports only selection.dart, so it can be lifted into its own package without changes when a persistence adapter or a non-Flutter consumer appears.

build() end of frame response
│ │ │
│ query.company?.name │ │
▼ ▼ ▼
Accessor.scalar('name') client._doFlush() cache.writeResponse()
├─ selection.child() ├─ merge pending trees ├─ normalize + merge entities
├─ cache.read(path, deps) ├─ PrintedOperation.from ├─ prune errored paths
│ ├─ follow Refs ├─ POST {query, vars} └─ notify scopes whose deps
│ └─ missing? └─ mark scopes loading intersect touched keys
└─ recorder.onMiss(sel) └─ setState()
└─ scope.scheduler(flush)

A Selection node is a field name, its arguments, and children. Its alias is field when there are no arguments, otherwise field_<fnv1a64(json(args))> (64-bit FNV-1a of the canonical JSON — sorted keys, nulls dropped — in base 36; computed on 32-bit halves so web and native agree). The alias is used both as the GraphQL alias in the printed document and as the key in the cache, so the response can be merged without a lookup table (the one exception, fields repeated across fragments, is below).

PrintedOperation.from(root) walks the tree, emits alias: field(arg: $first) lines, adds __typename to every object node (and id to keyed ones), and collects arguments into variables with their declared GraphQL type (Arg('LaunchFilter', filter.toJson())). Variables are named after the argument ($first, $after, $filter); the same argument name used twice with a different value in one operation gets a numeric suffix ($first, $first2). Object nodes without children — a list that was read but whose rows were not built — still print { __typename } so the document is valid.

An inline fragment is a node too (Selection.fragment('Launch'), alias ... on Launch, which no field alias can spell): its children are fields of that concrete type, it prints as ... on Launch { id … } (id when the member is keyed), and it adds nothing to cache paths — aliasPath skips it, and fields inside it read at the enclosing object’s location.

A field read in two fragments of one object (or in a fragment and directly on it) is printed as <alias>__<Type>: field inside each fragment, so fields of different types never share a response name (GraphQL’s FieldsInSetCanMerge). The printer returns the renames with the document; PrintedOperation.toCacheKeys(data) maps a response back to the aliases (merging a field selected both ways) after partial errors are pruned — error paths use the printed names — and before writeResponse. The cache never sees a type-qualified key.

2. Cache — entities, keyed by __typename:id

Section titled “2. Cache — entities, keyed by __typename:id”

A flat Map<entityKey, fields> where the operation roots (ROOT_QUERY) are entities too. read(operation, path, deps:) walks Map/List nodes, derefs Refs transparently, records every entity.field dependency it crosses into deps, and returns the missing sentinel when a key is absent — distinct from a stored null. Generated getters go through readField(operation, path, field, deps:), the same walk without allocating the joined path; dependency keys are interned per (entity, field). Inside a list held by an entity field, a read through an inline element records entity.field[i] and Accessor.list of inline objects entity.field[length] (readListField), so writes touch the elements that changed rather than every reader of the field. writeResponse normalizes identifiable objects into entities and merges the rest inline; write sets a single path for optimistic updates. Both return the dependency keys they touched. Cache is an interface with one implementation, NormalizedCache. Apps see the interface through client.cache (entity, evict, gc, snapshot, onChange, changesSince, fetchedAt); the path-level read/write/remove/writeResponse are @internal, and NormalizedCache, Ref, missing, depKey come from package:sling_gql/internal.dart (no stability promise — for adapters and tests). Details in Caching.

3. Accessor — what generated classes extend

Section titled “3. Accessor — what generated classes extend”

An accessor is (recorder, selection node, cache path). The helpers do the work every generated getter needs:

Helper Records Returns when missing
scalar<T>(field) leaf null + onMiss
scalarList<T>(field) leaf [null] + onMiss
object(field, ctor, keyed:, lookup:) object node (+ id) entity via lookup, else skeleton accessor + onMiss
list(field, ctor, keyed:) object node (+ id) [skeleton] + onMiss
on(typename, ctor, keyed:) fragment node (+ id) skeleton accessor (every member type); null for a cached object of another type
whenType(cases, orElse:) via the branches runs every branch, returns the first’s result
write(field, value) — writes cache, onWrite(CacheWrite) (journaled during a mutation’s optimistic phase)

Recorder is the interface an accessor talks to: the current operation, its selection root, the cache, the deps set reads fill, onMiss, onWrite. QueryScope implements it.

A scope is one widget’s view: it runs the builder inside run() with a fresh selection root and a fresh dependency set, tracks whether the run missed anything, whether a fetch is in flight, and the last error. refetch() re-enqueues the whole last tree. After a write, _notify rebuilds the scopes whose deps intersect the touched keys.

The client owns the cache, the HTTP client and the batch: _pending (the merged tree waiting to be sent), _inflight (the tree currently on the wire, used to avoid re-requesting fields that are already coming), and the scopes waiting on each.

Two rules keep it from looping:

  • A scope with a sticky error does not enqueue misses until refetch().
  • A document identical to the last failed one is not re-sent; the waiting scopes are settled with the previous error immediately.

_schedule(scope) adds the scope to the pending set and — if no flush is scheduled — calls the scope’s scheduler with _doFlush. Two implementations ship:

  • microtaskScheduler — scheduleMicrotask(flush). Default for resolve() and tests.
  • frameEndScheduler — addPostFrameCallback(flush) + ensureVisualUpdate(). Default for QueryBuilder (override with QueryBuilder(scheduler:)). Runs after layout, so children built by slivers during layout are in the same batch; ensureVisualUpdate schedules a frame if the read happened outside one (Flutter’s initial attachRootWidget build).

The first scheduler of a batch wins; everything recorded before it fires goes in.

SlingScope<Q> provides the client (an untyped SlingScope.clientOf exists so MutationBuilder does not need to know the query root type). QueryBuilder<Q> is a StatefulWidget that creates a scope on first dependency resolution, runs prepare and builder inside scope.run, and calls setState when the scope’s onChanged fires. QueryState is a read-only façade over the scope.

MutationBuilder<M> hands its builder a mutate function and a MutationState (isLoading, error). mutate calls client.mutateWith(root, body, optimistic:), which runs body once against a MutationScope to record the selection, POSTs it alone, writes the response through the same writeResponse as queries, and runs body again to compute the return value. Optimistic writes made through generated setters are journaled as CacheWrites (path + previous value) while optimistic runs and undone on failure — on a partial GraphQL error, before the fields that resolved are written, so the server’s values win.

SubscriptionBuilder<S> runs select once against a SubscriptionScope (via client.subscribeWith) to record the document, listens to the resulting SlingSubscription.stream in a post-frame callback, and rebuilds with each value. The client sends one POST per subscription with accept: text/event-stream through SlingClient.subscriptionTransport (default sseSubscriptionTransport, a small SSE decoder over http.Client.send), and treats each event exactly like a response: prune partial errors, writeResponse under ROOT_SUBSCRIPTION, notify the scopes whose deps were touched, run the body again for the stream value. Cancelling the stream closes the connection and drops the root fields.

The generator emits exactly the calls above and nothing else — the hand-written types at the top of packages/sling_gql/test/core_test.dart are the reference implementation the generator must match. If the helper signatures change, that test and the generator change together.