Baton

Start here

One screen, end to end

A screen followed from the fragment beside its view to the frame it renders: the operation the compiler assembles, the request, the response, the records, the reads, the one view that re-renders, and the image on disk.

A character's header: a name, a status, a species, a picture, and where the character is from. Small enough to follow every step, and every step on this page is program output. The compiler compiled the documents below when the page was built; the response is one the specification recorded from the API; the records are dumps the runtime wrote under the specification's fixtures, and the notifications and the phases are what the specification requires of both runtimes.

1. A fragment beside the view#

The view declares the fields it reads, next to its body, and takes a lens as its one input. It cannot read a field it did not declare, because the lens has no accessor for one.

CharacterHeader.swift

import Baton
import SwiftUI

struct CharacterHeader: View {
    @Fragment("""
    fragment CharacterHeader_character on Character {
      name
      status
      species
      image
      origin { name }
    }
    """)
    var character: CharacterHeader_character

    var body: some View {
        VStack {
            Avatar(url: character.image)
            Text(character.name ?? "Unknown").font(.largeTitle)
            Text("\(character.status ?? "?") · \(character.species ?? "?")")
            if let origin = character.origin {
                Text("From \(origin.name ?? "somewhere")")
            }
        }
    }
}
Plate 1. The header's fragment and its body. origin { name } reads through a link to another record, and the lens makes that a nested lens.

2. One query for the screen#

The screen declares one query and spreads the header's fragment into it. The parent never lists the header's fields; it hands the header its lens through the spread's accessor, characterHeader.

CharacterScreen.swift

import Baton
import SwiftUI

struct CharacterScreen: View {
    @Query("""
    query CharacterQuery($id: ID!) @cacheExpiration(seconds: 300) {
      character(id: $id) { ...CharacterHeader_character }
    }
    """)
    var character: CharacterQuery

    var body: some View {
        switch character.phase {
        case .ready(let data):
            if let character = data.character {
                CharacterHeader(character: character.characterHeader)
            }
        case .loading:
            ProgressView()
        case .failed(let error):
            ErrorView(error) { character.retry() }
        }
    }
}
Plate 2. The screen's query, with one variable, and the three cases of its phase. @cacheExpiration(seconds: 300) says data older than five minutes reads as stale.

3. What the compiler makes of them#

The compiler reads the schema and every document in the module, validates them, assembles the operation with the fragments it reaches, and adds the fields identity needs. This is the text the app will send, printed on one line as the compiler prints it:

query
CharacterQuery
variables
$id: ID!
spreads
CharacterHeader_character
the text
query CharacterQuery($id:ID!){character(id:$id){...CharacterHeader_character,id}}fragment CharacterHeader_character on Character{name,status,species,image,origin{name,id}}
Plate 3. The operation's text, the fragment printed after it. The compiler added id to the character and under origin, because both types are keyed by id and the store needs the keys to make the records; the directive on the query stays out of the text, since it is the client's business.

For each fragment and each operation it emits a lens: one accessor per declared field, typed as the schema types it.

The lens of CharacterHeader_character, a fragment, reads a record of type Character:

accessorreadsas
namenamea scalar, optional
statusstatusa scalar, optional
speciesspeciesa scalar, optional
imageimagea scalar, optional
originorigina lens of Location, optional
  namenamea scalar, optional
Plate 4. The header's lens. name is optional because the schema says so; origin is a nested lens; nothing else exists on the type.

And it writes the code. The generated Swift is one line per field plus the data tables the runtime walks a response by; there is no model, no decoder and no cache policy in it.

CharacterHeader.baton.swift

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

/// Lens for `fragment CharacterHeader_character on Character`.
nonisolated public struct CharacterHeader_character: Baton.Lens {
    @_spi(Generated) public let anchor: Baton.Anchor
    @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
    @MainActor public var name: String? { anchor.string(Slots.Character.name) }
    @MainActor public var status: String? { anchor.string(Slots.Character.status) }
    @MainActor public var species: String? { anchor.string(Slots.Character.species) }
    @MainActor public var image: String? { anchor.string(Slots.Character.image) }
    @MainActor public var origin: Origin? { anchor.linked(Slots.Character.origin).map(Origin.init(anchor:)) }

    nonisolated public struct Origin: Baton.Lens {
        @_spi(Generated) public let anchor: Baton.Anchor
        @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
        @MainActor public var name: String? { anchor.string(Slots.Location.name) }
    }
}

CharacterScreen.baton.swift

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

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

    public init(id: String) {
        self.id = id
    }

    public static let name = "CharacterQuery"
    public static let document: Baton.Document = .text(#"query CharacterQuery($id:ID!){character(id:$id){...CharacterHeader_character,id}}fragment CharacterHeader_character on Character{name,status,species,image,origin{name,id}}"#)
    @_spi(Generated) public static let cacheExpiration: Swift.Duration? = .seconds(300)

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

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

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

    @_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("character", key: .dynamic(Slots.Query.character_bca4f9), plural: false, lookup: Baton.Lookup(type: Types.Character, key: [.variable("id")]), selection: selection1),
    ])
    private static let selection1: Baton.Selection = Baton.Selection(type: Types.Character, key: ["id"], abstract: false, fields: [
        .scalar("name", key: .fixed(Slots.Character.name), kind: .string, list: false),
        .scalar("status", key: .fixed(Slots.Character.status), kind: .string, list: false),
        .scalar("species", key: .fixed(Slots.Character.species), kind: .string, list: false),
        .scalar("image", key: .fixed(Slots.Character.image), kind: .string, list: false),
        .linked("origin", key: .fixed(Slots.Character.origin), plural: false, selection: selection2),
        .scalar("id", key: .fixed(Slots.Character.id), kind: .string, list: false),
    ])
    private static let selection2: Baton.Selection = Baton.Selection(type: Types.Location, key: ["id"], abstract: false, fields: [
        .scalar("name", key: .fixed(Slots.Location.name), kind: .string, list: false),
        .scalar("id", key: .fixed(Slots.Location.id), 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 character: Character? { anchor.linked(anchor.owner.slot(Slots.Query.character_bca4f9)).map(Character.init(anchor:)) }

        nonisolated public struct Character: Baton.Lens {
            @_spi(Generated) public let anchor: Baton.Anchor
            @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
            @MainActor public var characterHeader: CharacterHeader_character { .init(anchor: anchor.entering()) }
        }
    }
}
Plate 5. What the compiler wrote for the two files above. The lens reads a slot per field through the anchor it holds; the operation value carries its variables, its text and its plan.

4. The request#

The view appears, and its handle checks the store. Nothing is there yet, so the environment sends the operation through the transport: the text above, with {"id": "9"} as the variables, as a standard GraphQL-over-HTTP request. Under persistConfig it would send the id of the text instead, and the server would look the text up.

While the request is out, the view reads phase as loading. Here is what a handle shows at each moment, from the specification's script of phases:

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 6. A handle attached with nothing in the store is loading with a fetch in flight; the answer makes it ready. A second attach of the same operation is ready at once, with no fetch. One attached under storeAndNetwork is ready and refreshing; when its refresh fails, the data stays and the fetch records the failure.

5. The response#

The server answers in the document's shape:

spec/rickandmorty/character-header-9.json

{
  "data": {
    "character": {
      "name": "Agency Director",
      "status": "Dead",
      "species": "Human",
      "image": "https://rickandmortyapi.com/api/character/avatar/9.jpeg",
      "origin": {
        "name": "Earth (Replacement Dimension)",
        "id": "20"
      },
      "id": "9"
    }
  }
}
Plate 7. The recorded response for id: "9". Five values, two objects, and the two ids the compiler asked for.

6. The records#

The response's bytes are decoded straight into record slots, off the main actor, by the plan the compiler emitted, and committed on the main actor as one batch. Hover a field to see where it went:

The response

{
  "data": {
    "character": {
      "name": "Agency Director",
      "status": "Dead",
      "species": "Human",
      "image": "https://rickandmortyapi.com/api/character/avatar/9.jpeg",
      "origin": {
        "name": "Earth (Replacement Dimension)",
        "id": "20"
      },
      "id": "9"
    }
  }
}

The records

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 8. Three records: Character:9, Location:20, and the root that links to the character under its storage key, character(id:"9"). The origin slot holds a link, not a copy.

7. The reads#

The specification's manifest says what the lens must read at each path after that commit, and both runtimes are held to it:

After the response to TestHeaderQuery(id: "9") is committed, the lens the compiler generated must read:

pathvalue
character.name"Agency Director"
character.status"Dead"
character.species"Human"
character.origin.name"Earth (Replacement Dimension)"

and, under an optimistic response that overrides character.name, read "Director" there until the layer is replaced or reverted.

Plate 9. The reads the fixture requires, and the override below them: under an optimistic response that changes the name, the lens reads the new name until the server answers.

The view's body runs. character.name loads a slot; so do status, species and image; origin follows the link and origin.name loads a slot of the other record. Each read registers with Observation, so the body now depends on exactly six slots of those two records: five on the character, the origin link among them, and one on the location.

8. A field changes#

Later a refetch, another screen's query, a mutation's payload or a subscription's event changes a record. The commit compares each slot before it writes, and notifies only the slots that changed. The specification scripts this on a character with notes:

A view attaches a handle for TestNotesQuery(id: "1") under storeOrNetwork, named notes; the transport answers its fetch with tests/notes-page-1.json.

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

The store after the step, tests/notes-page-1.store.json

Character:1Character
__TestNotes_notes_connectionCharacter:1:__TestNotes_notes_connection
id"1"
name"Rick Sanchez"
notes(first:2)Character:1:notes(first:2)
Character:1:__TestNotes_notes_connectionNoteConnection
edges[ Character:1:notes(first:2):edges:0, Character:1:notes(first:2):edges:1 ]
pageInfoCharacter:1:__TestNotes_notes_connection:pageInfo
totalCount5
Character:1:__TestNotes_notes_connection:pageInfoPageInfo
endCursor"c2"
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"
client:rootQuery
character(id:"1")Character:1
Plate 10. The same response committed again notifies nothing. A response that renames the character notifies one slot, Character:1.name, and the bodies that read it run; a body that read only the notes does not.

That is the whole re-render story. No watcher re-executes the query, no snapshot is diffed; a body that read a field is invalidated when that field of that record changes, and at no other time.

9. A write, seen at once#

A mutation's optimistic response is a typed value that goes through the same ingest as a server's answer and is applied as a layer. The layer shows in the turn of the call; the server's answer replaces it; a failure reverts it, and the store notifies only what differs in the end.

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"
      }
    }
  }
}

Notified Character:1.name

A lens readsand finds
notes · character.nameRick Prime
Plate 11. A rename applied optimistically notifies name; the server's answer resolves it with no further notification, since the value is the same; a second layer applied and reverted leaves the resolved name in place.

10. The image, and the next launch#

Every server batch is written behind to an image, one SQLite file, off the main actor. When the app launches again, the handle's availability check reads what memory lacks from the image before the first body runs, so the screen the user left is the screen they get back, before any request.

The server answers TestQuotesQuery() with tests/quote-list.json, and the environment commits it as a server batch.

tests/quote-list.json
{
  "data": {
    "quotes": [
      {
        "rate": 1.5,
        "base": "BTC",
        "quote": "USD"
      },
      {
        "rate": 0.9,
        "base": "EUR",
        "quote": "USD"
      },
      {
        "rate": 2,
        "base": "A:B",
        "quote": "C\\D"
      }
    ]
  }
}

The store after the step, tests/quote-list.store.json

Quote:A\:B:C\\DQuote
base"A:B"
quote"C\D"
rate2.0
Quote:BTC:USDQuote
base"BTC"
quote"USD"
rate1.5
Quote:EUR:USDQuote
base"EUR"
quote"USD"
rate0.9
client:rootQuery
quotes[ Quote:BTC:USD, Quote:EUR:USD, Quote:A\:B:C\\D ]
Plate 12. A commit writes records; the check for an operation whose data is in memory answers memory, and for one it lacks, miss; after a relaunch the first check answers image, and the lens reads the same value, while the miss is still a miss, which a handle would fetch.

The image is a cache, not a database. An image written under another schema, another format or another identity configuration starts again; a record that goes a whole launch unread is dropped at the next; optimistic layers never reach it.

11. Signing out#

The environment is the session. Ending it cancels what it started, closes the image, and leaves every handle failed as gone; a new environment makes its own store, on the same file or another.

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"
    }
  }
}
Plate 13. Two handles attached, one still fetching, and an optimistic rename applied; then the environment ends: every handle reads as gone, the records are cleared, and nothing is logged. Signing out is await environment.end() and a new environment.

Run it#

The screen on this page is a trimmed copy of the Rick and Morty sample, which is in the repository:

swift run RickAndMorty

Quit it, launch it again, and the characters are there before the network answers. Then change the header's fragment, add type, and build: the lens gains an accessor, the operation's text gains a field, and nothing else in the app moves. Or do the same without leaving this site, in the playground.