Baton

The guide

The twelve words

Schema, document, directive, lens, record, store, plan, operation value, connection, environment, transport, phase: the closed vocabulary, one plate each.

Baton's vocabulary is closed. A feature is a composition of these twelve concepts, or a Relay directive the compiler understands, or it does not ship. That is the rule that keeps a screen one request, a view one fragment, and a change one re-render, and it is why the whole thing fits on a page. Each word below has its contract in the terminology, where the Swift and Kotlin spellings are, and one plate here to make it concrete.

Schema#

The server's menu: every type, every field, whether each can be null. A checked-in SDL file the compiler reads at build time, named in baton.json, with identity configured beside it. The runtime never introspects; what it knows of the schema it knows from the compiler. One schema family per process. In the terminology.

spec/rickandmorty/schema.graphql

type Query {
  character(id: ID!): Character
  characters(page: Int, filter: FilterCharacter): Characters
  charactersByIds(ids: [ID!]!): [Character]
  location(id: ID!): Location
  locations(page: Int, filter: FilterLocation): Locations
  locationsByIds(ids: [ID!]!): [Location]
  episode(id: ID!): Episode
  episodes(page: Int, filter: FilterEpisode): Episodes
  episodesByIds(ids: [ID!]!): [Episode]
}

type Character {
  id: ID
  name: String
  status: String
  species: String
  type: String
  gender: String
  origin: Location
  location: Location
  image: String
  episode: [Episode]!
  created: String
}
Plate 1. The root type and one object type of the Rick and Morty schema. episode: [Episode]! is a non-null list of nullable episodes, and the lens types follow that exactly.

Document#

A query, a mutation, a subscription or a fragment, carried by a marker in source code or held in a .graphql file. A fragment is a view's needs with a name; an operation is assembled by the compiler from the fragments spread into it, one per screen, and compiles to a variables type, a root lens, a plan, its text and a persisted id. In the terminology.

IssueRow.swift

import Baton
import SwiftUI

struct IssueRow: View {
    @Fragment("""
    fragment IssueRow_issue on Issue {
      number
      title
      author { login }
    }
    """)
    var issue: IssueRow_issue

    var body: some View {
        VStack(alignment: .leading) {
            Text("#\(issue.number) \(issue.title)")
            Text(issue.author?.login ?? "ghost")
                .foregroundStyle(.secondary)
        }
    }
}

IssuesScreen.swift

import Baton
import SwiftUI

struct IssuesScreen: View {
    @Query("""
    query IssuesQuery($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) {
        nameWithOwner
        issues(first: 20, states: OPEN) {
          nodes { id ...IssueRow_issue }
        }
      }
    }
    """)
    var issues: IssuesQuery

    var body: some View {
        switch issues.phase {
        case .ready(let data):
            if let repo = data.repository {
                List {
                    ForEach(repo.issues.nodes ?? .empty) {
                        IssueRow(issue: $0.issueRow)
                    }
                }
                .navigationTitle(repo.nameWithOwner)
            }
        case .loading:
            ProgressView()
        case .failed(let error):
            ErrorView(error) { issues.retry() }
        }
    }
}
Plate 2. A fragment and a query, each in the marker that carries it. The Kotlin marker stands on the composable; the Swift marker on the stored property.

Directive#

The only way behaviour attaches to data. The set is Relay's: @connection, @refetchable, @argumentDefinitions, @arguments, @alias, @required, @catch, @throwOnFieldError, @defer, @inline, @include, @skip, and the edge directives @appendEdge, @prependEdge, @appendNode, @prependNode, @deleteEdge and @deleteRecord; the one exception, which Relay has no word for, is @cacheExpiration(seconds:). Nothing is configured by an object at run time. In the terminology.

CommentComposer.swift

import Baton
import SwiftUI

struct CommentComposer: View {
    let subjectID: String
    let connectionID: String
    @State private var text = ""

    @Mutation("""
    mutation CommentComposerAddComment($input: AddCommentInput!, $connections: [ID!]!) {
      addComment(input: $input) {
        commentEdge @appendEdge(connections: $connections) {
          node { id bodyText author { login } }
        }
      }
    }
    """)
    var addComment: CommentComposerAddComment.Action

    var body: some View {
        HStack {
            TextField("Leave a comment", text: $text)
            Button("Comment") {
                let body = text
                Task {
                    try await addComment(
                        input: AddCommentInput(body: body, subjectId: subjectID),
                        connections: [connectionID]
                    )
                    text = ""
                }
            }
            .disabled(text.isEmpty || addComment.isInFlight)
        }
    }
}

IssueComments.swift

import Baton
import SwiftUI

struct IssueComments: View {
    @Fragment("""
    fragment IssueComments_issue on Issue
    @refetchable(queryName: "IssueCommentsPaginationQuery")
    @argumentDefinitions(count: {type: "Int", defaultValue: 30}, cursor: {type: "String"}) {
      id
      comments(first: $count, after: $cursor) @connection(key: "IssueComments_comments") {
        totalCount
        edges { node { id bodyText author { login } } }
      }
    }
    """)
    var issue: IssueComments_issue

    var body: some View {
        Text("\(issue.comments.totalCount) comments")
        ForEach(issue.comments.nodes) { comment in
            Text("\(comment.author?.login ?? "ghost"): \(comment.bodyText)")
        }
        CommentComposer(subjectID: issue.id, connectionID: issue.comments.connectionID)
    }
}
Plate 3. Four directives in one figure: a refetchable connection with its argument definitions, and a mutation whose payload edge is appended to that connection.

Lens#

Baton's word for what Relay calls a fragment reference and fragment data, which are one value here: a typed, read-only view over one record, with one accessor per declared field. A parent passes a child its lens through a spread's accessor and cannot read through it. A lens is valid on the main thread, where the store lives; equal by its record, its owner and its origin. In the terminology.

The lens of IssueList_repository, a fragment, reads a record of type Repository:

accessorreadsas
issuesissuesa lens of IssueConnection
  edgesedgesa lens of IssueEdge, a list, optional
    nodenodea lens of Issue, optional
      idida scalar
      issueRowthe parent's recordthe lens of IssueRow_issue
    cursorcursora scalar
  pageInfopageInfoa lens of PageInfo
    endCursorendCursora scalar, optional
    hasNextPagehasNextPagea scalar
idida scalar
Plate 4. A lens over a connection: issues reads a nested lens whose edges are a list, each edge's node a lens that spreads the row's fragment, and cursor and pageInfo, which the compiler added for the connection. Nothing else appears.

Record#

An observable object identified by typename plus key, Character:9, holding interned slots and per-field errors. Each slot is an invalidation channel: a body that read it is invalidated when it changes, and at no other time. Objects without a key get a client id from their path, as in Relay. In the terminology.

Character:9Character
id"9"
image"https://rickandmortyapi.com/api/character/avatar/9.jpeg"
name"Agency Director"
originLocation:20
species"Human"
status"Dead"
Location:20Location
id"20"
name"Earth (Replacement Dimension)"
client:rootQuery
character(id:"9")Character:9
Plate 5. The three records one response made. A link is a slot holding another record's key.

Store#

All records, retained roots and lifetime state, owned by the main thread, read synchronously, written by atomic commits. A commit is a batch: the transaction that nets the notifications, an undo log, and a kind, server, optimistic or local. Only a server batch reaches the image on disk. In the terminology.

The server answers TestNotesQuery(id: "1") with tests/notes-page-1.json, and the environment commits it as a server batch.

tests/notes-page-1.json
{
  "data": {
    "character": {
      "id": "1",
      "name": "Rick Sanchez",
      "notes": {
        "totalCount": 5,
        "edges": [
          {
            "cursor": "c1",
            "node": {
              "__typename": "Note",
              "id": "n1",
              "text": "Wubba lubba dub dub"
            }
          },
          {
            "cursor": "c2",
            "node": {
              "__typename": "Note",
              "id": "n2",
              "text": "Portal gun needs charging"
            }
          }
        ],
        "pageInfo": {
          "endCursor": "c2",
          "hasNextPage": true
        }
      }
    }
  }
}

Notified nothing: no observed field changed, so no body runs

Plate 6. Two commits: one that changes nothing and notifies nothing, one that changes a name and notifies the one slot.

Plan#

How a response becomes records: the normalization artifact the compiler emits per operation, as data the runtime walks. The ingest follows the plan through the bytes, keys each object by the type's key fields, and writes slots by their numbers; it knows no field by name. Both runtimes interpret the same plans. In the terminology.

IssuesScreen.baton.swift

// Generated by batonc. Do not edit.
import Foundation
@_spi(Generated) import Baton

/// Operation value for `query IssuesQuery`.
nonisolated public struct IssuesQuery: Baton.Query {
    public var owner: String
    public var name: String
    @_spi(Generated) public var resolution: Baton.Resolution<Baton.OperationHandle<Self>> = .unresolved

    public init(owner: String, name: String) {
        self.owner = owner
        self.name = name
    }

    public static let name = "IssuesQuery"
    public static let document: Baton.Document = .id("d8e7121ec0e10dfd0dd7c8d126e75dd1")

    public var variables: Baton.Variables {
        Baton.Variables(["owner": Baton.Variable(self.owner), "name": Baton.Variable(self.name)])
    }

    public static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.owner == rhs.owner && lhs.name == rhs.name
    }

    public func hash(into hasher: inout Hasher) {
        hasher.combine(self.owner)
        hasher.combine(self.name)
    }

    @_spi(Generated) public static let plan = Baton.Plan(root: selection0)
    private static let selection0: Baton.Selection = Baton.Selection(type: Types.Query, key: [], abstract: false, fields: [
        .linked("repository", key: .dynamic(Slots.Query.repository_00c598), plural: false, selection: selection1),
    ])
    private static let selection1: Baton.Selection = Baton.Selection(type: Types.Repository, key: ["id"], abstract: false, fields: [
        .scalar("nameWithOwner", key: .fixed(Slots.Repository.nameWithOwner), kind: .string, list: false),
        .linked("issues", key: .fixed(Slots.Repository.issues_44b8f4), plural: false, selection: selection2),
        .scalar("id", key: .fixed(Slots.Repository.id), kind: .string, list: false),
    ])
    private static let selection2: Baton.Selection = Baton.Selection(type: Types.IssueConnection, key: [], abstract: false, fields: [
        .linked("nodes", key: .fixed(Slots.IssueConnection.nodes), plural: true, selection: selection3),
    ])
    private static let selection3: Baton.Selection = Baton.Selection(type: Types.Issue, key: ["id"], abstract: false, fields: [
        .scalar("id", key: .fixed(Slots.Issue.id), kind: .string, list: false),
        .scalar("number", key: .fixed(Slots.Issue.number), kind: .int, list: false),
        .scalar("title", key: .fixed(Slots.Issue.title), kind: .string, list: false),
        .linked("author", key: .fixed(Slots.Issue.author), plural: false, selection: selection4),
    ])
    private static let selection4: Baton.Selection = Baton.Selection(type: Types.Actor, key: ["id"], abstract: true, memberships: [.init("__isNode", Types.Node)], variants: [
        .init(types: [Types.Bot, Types.EnterpriseUserAccount, Types.Mannequin, Types.Organization, Types.User], key: ["id"], fields: [
            .scalar("__typename", key: .fixed(Slots.Actor.__typename), kind: .string, list: false),
            .scalar("login", key: .fixed(Slots.Actor.login), kind: .string, list: false),
            .scalar("id", key: .fixed(Slots.Actor.id), kind: .string, list: false),
        ]),
        .init(types: nil, condition: Types.Node, fields: [
            .scalar("__typename", key: .fixed(Slots.Actor.__typename), kind: .string, list: false),
            .scalar("login", key: .fixed(Slots.Actor.login), kind: .string, list: false),
            .scalar("id", key: .fixed(Slots.Actor.id), kind: .string, list: false),
        ]),
        .init(types: nil, fields: [
            .scalar("__typename", key: .fixed(Slots.Actor.__typename), kind: .string, list: false),
            .scalar("login", key: .fixed(Slots.Actor.login), kind: .string, list: false),
        ]),
    ])

    nonisolated public struct Data: Baton.Lens {
        @_spi(Generated) public let anchor: Baton.Anchor
        @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
        @MainActor public var repository: Repository? { anchor.linked(anchor.owner.slot(Slots.Query.repository_00c598)).map(Repository.init(anchor:)) }

        nonisolated public struct Repository: Baton.Lens {
            @_spi(Generated) public let anchor: Baton.Anchor
            @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
            @MainActor public var nameWithOwner: String { anchor.requiredString(Slots.Repository.nameWithOwner) }
            @MainActor public var issues: Issues { Issues(anchor: anchor.requiredLinked(Slots.Repository.issues_44b8f4, type: Types.IssueConnection)) }

            nonisolated public struct Issues: Baton.Lens {
                @_spi(Generated) public let anchor: Baton.Anchor
                @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
                @MainActor public var nodes: Baton.List<Nodes>? { anchor.list(Slots.IssueConnection.nodes) }

                nonisolated public struct Nodes: Baton.Lens {
                    @_spi(Generated) public let anchor: Baton.Anchor
                    @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
                    @MainActor public var id: String { anchor.requiredString(Slots.Issue.id) }
                    @MainActor public var issueRow: IssueRow_issue { .init(anchor: anchor.entering()) }
                }
            }
        }
    }
}
Plate 7. The plan is the data table in an operation's generated code, before its data lens: one selection per distinct selection set, each field with its storage key and kind.

Operation value#

A value of an operation's variables, compared and hashed by them: the thing a parent constructs, IssuesQuery(owner:name:), and a navigation path carries. Inside a view it resolves to a handle, which exposes the phase, the data, a refetch and a retry; outside any view it reads as loading. In the terminology.

query
IssuesQuery
variables
$owner: String! $name: String!
spreads
IssueRow_issue
sent as
d8e7121ec0e10dfd0dd7c8d126e75dd1, the text's hash; the text stays on the server
the text
query IssuesQuery($owner:String!,$name:String!){repository(owner:$owner,name:$name){nameWithOwner,issues(first:20,states:OPEN){nodes{id,...IssueRow_issue}},id}}fragment IssueRow_issue on Issue{number,title,author{__typename,login,...on Node{__isNode:__typename,id}}}
Plate 8. What one operation value stands for on the wire: the text, its two variables, and the id it is registered under.

Connection#

A list that pages: Relay's connection specification, on a fragment marked @refetchable, under @connection(key:). The store merges each page into one list under the key; the lens reads nodes, hasNext and isLoadingNext, pages with loadNext, and names the list with connectionID for an edge directive. In the terminology.

The server answers TestNotesPaginationQuery(count: 2, cursor: "c2", id: "1") with tests/notes-page-2.json, and the environment commits it as a server batch.

tests/notes-page-2.json
{
  "data": {
    "node": {
      "__typename": "Character",
      "id": "1",
      "name": "Rick Sanchez",
      "notes": {
        "totalCount": 5,
        "edges": [
          {
            "cursor": "c3",
            "node": {
              "__typename": "Note",
              "id": "n3",
              "text": "Get Schwifty"
            }
          },
          {
            "cursor": "c4",
            "node": {
              "__typename": "Note",
              "id": "n4",
              "text": "Avoid the Citadel"
            }
          }
        ],
        "pageInfo": {
          "endCursor": "c4",
          "hasNextPage": true
        }
      }
    }
  }
}
A lens readsand finds
notes · character.notes.edges.1.node.textPortal gun needs charging
notes · character.notes.edges.2.node.textGet Schwifty
notes · character.notes.edges.3.node.textAvoid the Citadel
notes · character.notes.pageInfo.endCursorc4
notes · character.notes.pageInfo.hasNextPagetrue

The store after the step, scripts/connections-merged.store.json

Character:1Character
__TestNotes_notes_connectionCharacter:1:__TestNotes_notes_connection
id"1"
name"Rick Sanchez"
notes(after:"c2",first:2)Character:1:notes(after:"c2",first:2)
notes(first:2)Character:1:notes(first:2)
Character:1:__TestNotes_notes_connection:pageInfoPageInfo
endCursor"c4"
hasNextPagetrue
Character:1:notes(after:"c2",first:2):edges:0NoteEdge
cursor"c3"
nodeNote:n3
Character:1:notes(after:"c2",first:2):edges:1NoteEdge
cursor"c4"
nodeNote:n4
Character:1:notes(after:"c2",first:2):pageInfoPageInfo
endCursor"c4"
hasNextPagetrue
Character:1:notes(first:2)NoteConnection
edges[ Character:1:notes(first:2):edges:0, Character:1:notes(first:2):edges:1 ]
pageInfoCharacter:1:notes(first:2):pageInfo
totalCount5
Character:1:notes(first:2):edges:0NoteEdge
cursor"c1"
nodeNote:n1
Character:1:notes(first:2):edges:1NoteEdge
cursor"c2"
nodeNote:n2
Character:1:notes(first:2):pageInfoPageInfo
endCursor"c2"
hasNextPagetrue
Note:n1Note
id"n1"
text"Wubba lubba dub dub"
Note:n2Note
id"n2"
text"Portal gun needs charging"
Note:n3Note
id"n3"
text"Get Schwifty"
Note:n4Note
id"n4"
text"Avoid the Citadel"
client:rootQuery
character(id:"1")Character:1
node(id:"1")Character:1
Plate 9. The second page committed: the connection record under the key now links both pages' edges, and the lens reads four notes in order.

Environment#

The session: one store, one transport, and the door everything from outside comes through. It fetches, commits, applies optimistic layers, hydrates from the image, logs, and ends. Signing out is ending it and making another. Injected once at the root of the app. In the terminology.

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 readsand finds
character.namenull
Plate 10. The environment ends: every handle reads as gone, the records are cleared, and nothing is logged.

Transport#

A protocol with one verb, send, which takes a request and yields a stream of payloads: one for a query, several for a deferred response, many for a subscription. The built-in transports are the platform's HTTP and sockets; everything a production endpoint needs beyond that, a retry, a challenge, a deadline, is a transport that wraps a transport, which the app owns. In the terminology.

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"}}
Plate 11. What the transport is handed: the standard encoding of one request, the operation's text and its variables, compared byte for byte.

Phase#

The state of a resolved operation, named after AsyncImagePhase: loading, ready, or failed, always synchronously readable, with isRefreshing and isStale beside it. Previous data stays visible while a refresh runs; a fetch that changed nothing wakes no body. Derived from the root's verdict and the fetch, stored nowhere. In the terminology.

A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named jerry; the fetch stays in flight.

Phase {"handle":"jerry","phase":"loading","isRefreshing":false}

Fetch {"handle":"jerry","fetch":"inFlight"}

Plate 12. A handle loading, then ready; a handle whose fetch fails reads as failed; a retry reads as loading; a malformed answer fails it again.

What is not a word#

A view model, a cache policy, a type policy, a merge function, a query file for a screen, a cache updater, a resolver, a runtime GraphQL parser. Each was asked for by some screen somewhere, and each is answered by a composition of the twelve above or refused with a reason in the decisions. What earns a concept is the test a thirteenth word would have to pass.