The guide
The environment
The one door: the transport with one verb, the session that ends at sign-out, the log of value-free events, and a store without a server for previews and tests.
One environment per session: the store, the transport that fills it, the image on disk, the log. It is the one door everything from outside comes through, a server's response, an optimistic layer, a payload committed by hand, and the one thing an app ends at sign-out. This chapter is what it is made of and what it does.
Making one#
let environment = Baton.Environment(
transport: URLSessionTransport(url: api, credentials: { ["Authorization": "Bearer \(token)"] }),
store: Store(persistence: Persistence(name: "App", version: Types.schemaDigest))
)val environment = Environment(
HttpTransport(api, credentials = { mapOf("Authorization" to "Bearer $token") }),
store = Store(persistence = Persistence.named("App", version = Types.schemaDigest, directory = cacheDir.path)),
)Injected once at the root, .environment(\.baton, environment) in SwiftUI and CompositionLocalProvider(LocalBaton provides environment) in Compose, and every @Query, @Mutation and @Subscription below finds it. A view outside every environment reads as failed with an error that says so; nothing stands in for a missing environment.
The transport has one verb#
A transport takes a request and yields a stream of payloads: one for a query or a mutation, the parts of a deferred response, the events of a subscription. A request says its kind, so a wrapper never retries a mutation a server may have received, and carries the operation's document, text or id. One function, the encoding, turns a request into the JSON a server receives; a server with another convention replaces the function and keeps the transport.
The built-in transports are the platform's: URLSessionTransport and GraphQLTransportWebSocket in Swift; HttpTransport over java.net and GraphQLTransportWebSocket over the JVM's socket in Kotlin, with OkHttpTransport and OkHttpWebSocketClient in the baton-okhttp edge for an app that has an OkHttp client. All read credentials per attempt from a function, so a rotated token reaches the next request without a new environment.
A view attaches a handle for TestPinnedCharacter(id: "1") under networkOnly, named pinned; the transport answers its fetch with tests/pinned-character.json.
tests/pinned-character.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"status": "Alive"
}
}
}Fetch {"handle":"pinned","fetch":"idle"}
Sent TestPinnedCharacter
{"operationName":"TestPinnedCharacter","query":"query TestPinnedCharacter($id:ID!){character(id:$id){id,name,status}}","variables":{"id":"1"}}A view attaches a handle for TestCharactersWithStatus(status: "DEAD", any: ["ALIVE","UNKNOWN"]) under networkOnly, named dead; the fetch fails with a transport failure.
Sent TestCharactersWithStatus
{"operationName":"TestCharactersWithStatus","query":"query TestCharactersWithStatus($status:Status!,$any:[Status!]){charactersWithStatus(status:$status,any:$any){name,id}}","variables":{"any":["ALIVE","UNKNOWN"],"status":"DEAD"}}A view attaches a handle for TestFilteredCharacters(filters: [{"status":"Alive"},{"name":"Rick","species":"Human"}]) under networkOnly, named filtered; the fetch fails with a transport failure.
Sent TestFilteredCharacters
{"operationName":"TestFilteredCharacters","query":"query TestFilteredCharacters($filters:[FilterCharacter!]!){charactersMatching(filters:$filters){id,name}}","variables":{"filters":[{"status":"Alive"},{"name":"Rick","species":"Human"}]}}A view attaches a handle for TestNotesPaginationQuery(id: "1") under networkOnly, named defaulted; the fetch fails with a transport failure.
Sent TestNotesPaginationQuery
{"operationName":"TestNotesPaginationQuery","query":"query TestNotesPaginationQuery($count:Int=2,$cursor:String,$id:ID!){node(id:$id){__typename,...TestNotes_character_1G22uz,id}}fragment TestNotes_character_1G22uz on Character{name,notes(first:$count,after:$cursor){totalCount,edges{node{id,text,__typename},cursor},pageInfo{endCursor,hasNextPage}},id}","variables":{"count":2,"id":"1"}}operationName, variables and the text, or documentId under persistConfig. An enum variable is sent as its text, an input object as its fields, a nullable variable left unset is absent.What a production endpoint needs beyond that, one replay of an authorization challenge, a bounded retry with backoff over the failures a retry can mend, a deadline across the attempts, is a transport that wraps a transport, written once around the one verb, and owned by the app. The exchange walks through the one the GitHub samples send through, in both languages.
The session#
An environment pairs a store with the transport that fills it and replaces neither. A sign-in makes one; a sign-out ends it; which one is current is the app's state. Ending it cancels every fetch and stream it started, drops the roots, clears every record and closes the image, giving its file back. A response that lands later reaches neither memory nor the image; a handle still held reads as gone; every later call fails with the same error.
A view attaches a handle for TestConditions(id: "1", withOrigin: true, hideStatus: false) under storeOrNetwork, named rick; the transport answers its fetch with tests/conditions-included.json.
tests/conditions-included.json
{
"data": {
"character": {
"name": "Rick Sanchez",
"origin": {
"name": "Earth (C-137)",
"dimension": "Dimension C-137",
"id": "1"
},
"species": "Human",
"status": "Alive",
"id": "1"
}
}
}A view attaches a handle for TestHeaderQuery(id: "11") under storeOrNetwork, named einstein; the fetch stays in flight.
Fetch {"handle":"einstein","fetch":"inFlight"}
The optimistic response tests/rename-1.json for TestRename(id: "1", name: "Rick Prime") is applied as a layer named rename, before any server answers.
tests/rename-1.json
{
"data": {
"rename": {
"character": {
"id": "1",
"name": "Rick Prime"
}
}
}
}| A lens reads | and finds |
|---|---|
rick · character.name | Rick Prime |
The environment ends.
Phase [{"handle":"rick","phase":{"failed":"gone"}},{"handle":"einstein","phase":{"failed":"gone"}}]
Fetch [{"handle":"rick","fetch":{"failed":"environment"}},{"handle":"einstein","fetch":{"failed":"environment"}}]
Records held client:root client:root:mutation client:root:subscription
Logged nothing
| A lens reads | and finds |
|---|---|
character.name | null |
The server's answer tests/rename-1.json replaces the layer rename in one batch.
tests/rename-1.json
{
"data": {
"rename": {
"character": {
"id": "1",
"name": "Rick Prime"
}
}
}
}Logged nothing
The store after the step, scripts/end-empty.store.json
A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named jerry; the fetch stays in flight.
Phase {"handle":"jerry","phase":{"failed":"gone"}}
Fetch {"handle":"jerry","fetch":{"failed":"environment"}}
Logged nothing
The server answers TestHeaderQuery(id: "5") with tests/character-header-5.json, and the environment commits it as a server batch.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}The step throws gone
Logged nothing
The store after the step, scripts/end-empty.store.json
A payload, tests/character-header-5.json, is committed by hand for TestHeaderQuery(id: "5"), as a preview or a test does.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}The step throws gone
The store after the step, scripts/end-empty.store.json
The order of a sign-out is the app's: end the environment, remove the image, forget the credential last.
await environment.end()
environment.store.persistence?.removeAll()
environment = makeEnvironment()environment.end()
environment.store.persistence?.removeAll()
token = nullActivity#
The environment has an active flag the app sets from its lifecycle: false when the app goes to the background, true on its return, with revalidate() beside it to refetch what went stale meanwhile. While inactive, every retained subscription is parked and resumed on the return. In Swift it follows the scene phase; on Android the sample's Activation observes the process lifecycle.
The log#
The environment calls its log with each event of its work: a fetch started, completed with its duration or failed with its failure's kind; a commit with its kind and the count of slots it changed; a field error no @catch handled, by operation and path; the image opened, written or failed; and the missing-data cases, each by type and field name. Every event is value-free, a set of names and counts, never a record, a value or a response body, so an app routes it to its logging and metrics without logging anything a user typed.
The server answers TestHeaderQuery(id: "5") with tests/character-header-5.json, and the environment commits it as a server batch.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Logged fetchStarted {"operation":"TestHeaderQuery"} committed {"kind":"server","changed":1} fetchCompleted {"operation":"TestHeaderQuery"}
A view attaches a handle for TestHeaderQuery(id: "11") under storeOrNetwork, named einstein; the fetch fails with a transport failure.
Logged fetchStarted {"operation":"TestHeaderQuery"} fetchFailed {"operation":"TestHeaderQuery","kind":"transport"}
einstein retries after its failure.
Logged fetchStarted {"operation":"TestHeaderQuery"} fetchFailed {"operation":"TestHeaderQuery","kind":"request"}
The server answers TestStrictQuery(id: "1") with tests/character-name-hidden.json, and the environment commits it as a server batch.
tests/character-name-hidden.json
{
"data": {
"character": {
"id": "1",
"name": null,
"species": "Human"
}
},
"errors": [
{
"message": "name hidden",
"path": [
"character",
"name"
]
}
]
}The step throws fieldErrors
Logged fetchStarted {"operation":"TestStrictQuery"} committed {"kind":"server","changed":2} fieldError {"operation":"TestStrictQuery","path":"character.name"} fetchCompleted {"operation":"TestStrictQuery"}
The optimistic response tests/rename-1.json for TestRename(id: "1", name: "Rick Prime") is applied as a layer named rename, before any server answers.
tests/rename-1.json
{
"data": {
"rename": {
"character": {
"id": "1",
"name": "Rick Prime"
}
}
}
}Logged committed {"kind":"optimistic","changed":2}
The server's answer tests/rename-1.json replaces the layer rename in one batch.
tests/rename-1.json
{
"data": {
"rename": {
"character": {
"id": "1",
"name": "Rick Prime"
}
}
}
}Logged committed {"kind":"server","changed":0}
@catch handles, and an optimistic layer applied and resolved.In Swift a debug build prints the missing-data events until a log is set; in Kotlin an environment made with debug = true does. A test sets none, or collects them and asserts on a name.
Failures say their kind#
A fetch or a stream that did not deliver fails in one of a closed set of kinds: the transport's, carrying what the transport threw unchanged; a request error, the server's errors with no data; a malformed response; the environment's, when nothing could send the request, because it is gone or has no subscription transport. A view branches on the kind, and an app's retry policy lives in its exchange, not in the view.
A store without a server#
A transport is a protocol with one verb, so a preview, a test and a benchmark each get a real store the way an app does, and never a mock of Baton's types: SilentTransport never answers, for a first body with no network behind it; RecordedTransport answers by operation name from recorded responses and keeps the requests it was sent; ScriptedTransport holds a mutation until the test replies or refuses, so the window between an optimistic apply and the server's answer is observable, and drives a subscription's events by hand.
let transport = ScriptedTransport([CharacterQuery.name: fixture("character-1")])
let environment = Environment(transport: transport, subscriptions: transport)
let task = Task { try await environment.mutate(SetFavorite(id: "1", favorite: true), optimistic: optimistic.payload) }
await wait(until: { transport.held.count == 1 })
transport.held[0].respond(fixture("set-favorite-1"))val transport = ScriptedTransport(mapOf(CharacterQuery.name to fixture("character-1")))
val environment = Environment(transport, subscriptions = transport)
val job = launch { environment.mutate(SetFavorite(id = "1", favorite = true), optimistic.payload) }
wait(until = { transport.held.size == 1 })
transport.held.single().respond(fixture("set-favorite-1"))They live in BatonTesting and baton-testing, which an app's tests import and its shipping binary does not. Previews and tests is the catalogue, and commitPayload is the way a preview's fixture becomes a store.
Outside a view#
A UIKit or AppKit controller, a Kotlin view model, a service: each asks the environment for a handle, holds the retention that keeps the handle's data alive, and reads what it shows. environment.handle(for:) in Swift and environment.handle(operation) in Kotlin resolve an operation value outside any view; environment.fetch, mutate, preload and commitPayload are the calls that need no view at all. UIKit and AppKit, Views and view models and Derived state are the three recipes.
Baton