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, 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.

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_<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.

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(). Used by QueryBuilder. 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.

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.