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
Cacheinterface,NormalizedCache,depKey - normalization.dart identity rule (
__typename:id), lookups - ref.dart
Ref,missingsentinel
- cache.dart
- accessor.dart Accessor base class, Recorder interface
- client.dart SlingClient, QueryScope, FlushScheduler
- widgets.dart SlingScope, QueryBuilder, QueryState, frameEndScheduler
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.
Life of a read
Section titled “Life of a read”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)1. Selection — the tree
Section titled “1. Selection — the tree”A Selection node is a field name, its arguments, and children. Its alias is
field when there are no arguments, otherwise field_<fnv1a(json(args))>. 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.
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.
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. 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, 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 |
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.
4. SlingClient and QueryScope
Section titled “4. SlingClient and QueryScope”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
errordoes not enqueue misses untilrefetch(). - A document identical to the last failed one is not re-sent; the waiting scopes are settled with the previous error immediately.
5. FlushScheduler
Section titled “5. FlushScheduler”_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 forresolve()and tests.frameEndScheduler—addPostFrameCallback(flush)+ensureVisualUpdate(). Used byQueryBuilder. Runs after layout, so children built by slivers during layout are in the same batch;ensureVisualUpdateschedules a frame if the read happened outside one (Flutter’s initialattachRootWidgetbuild).
The first scheduler of a batch wins; everything recorded before it fires goes in.
6. Widgets
Section titled “6. Widgets”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.
Generated code contract
Section titled “Generated code contract”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.