Baton

The guide

The compiler

What batonc reads, what it checks, what it writes, and how the build runs it: SwiftPM, Gradle, Bazel, or the command line.

batonc finds the GraphQL in Swift and Kotlin sources and .graphql files, validates every document against the schema, assembles the operations, and writes the lenses and the plans. It decides at build time everything a runtime client decides at run time: identity, aggregation, masking, storage keys, persisted ids, nullability, diagnostics. The runtime interprets what it wrote and never parses GraphQL.

What it reads#

A target's configuration is baton.json, beside the target or at the package root:

keywhat
schemathe SDL file, relative to the configuration
schemaExtensionsclient schema extensions: files of .graphql that give server types client fields
identitythe key fields per type, id by default; a default list and types entries, interfaces included
lookupsroot fields answered from the store by id before the network
customScalarTypesa scalar's host type per language, and the Kotlin converter
transientthe types and root fields that never reach the image
persistConfigRelay's: the file and the hash algorithm, under which an operation is sent by id
onErrorthe error behaviour sent with every operation
kotlin.packagethe package of the Kotlin shared file

The hosts are every Swift or Kotlin file of the target and every .graphql beside them, compiled together, since a fragment spread in one file is declared in another.

What it checks#

Everything Relay's compiler checks, since the front end is Relay's, pinned to a release: unknown fields, wrong arguments, missing variables, a spread of an undefined fragment, a directive where it cannot go. The diagnostic names the file, the line and the column inside the GraphQL text in the host file, so the editor shows it on the line, and a document with an error writes nothing.

IssueRow.swift

import Baton
import SwiftUI

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

    var body: some View {
        Text("#\(issue.number) \(issue.title)")
    }
}
IssueRow.swift:8:7: error: The type `Issue` has no field `titel`. Did you mean `title` or `state`? See https://relay.dev/docs/error-reference/unknown-field/
    8 |       titel
              ^
Plate 1. A misspelled field, with the schema's suggestion.

IssuesScreen.swift

import Baton
import SwiftUI

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

    var body: some View { EmptyView() }
}
IssuesScreen.swift:9:25: error: Undefined fragment 'IssueRow_issue'.
    9 |           nodes { id ...IssueRow_issue }
                                ^
Plate 2. A query that spreads a fragment nobody declared.

Baton adds its own checks on top: a name generated code reserves, a fragment both a lens and a value, an operation of client fields alone, a Kotlin document with a template in a plain string. A fragment spread by no operation is a warning, since nothing could ever read it.

What it writes#

One output per source that declares GraphQL, named by the source's path, and one shared file per target with the types and the storage keys every output names. The generated code is one line per field plus the data tables the runtime walks a response by: no model, no decoder, no cache policy, no reflection.

IssueRow.baton.swift

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

/// Lens for `fragment IssueRow_issue on Issue`.
nonisolated public struct IssueRow_issue: Baton.Lens {
    @_spi(Generated) public let anchor: Baton.Anchor
    @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
    @MainActor public var number: Int { anchor.requiredInt(Slots.Issue.number) }
    @MainActor public var title: String { anchor.requiredString(Slots.Issue.title) }
    @MainActor public var author: Author? { anchor.linked(Slots.Issue.author).map(Author.init(anchor:)) }

    nonisolated public struct Author: Baton.Lens {
        @_spi(Generated) public let anchor: Baton.Anchor
        @_spi(Generated) public init(anchor: Baton.Anchor) { self.anchor = anchor }
        @MainActor public var login: String { anchor.requiredString(AbstractSlots.Actor.login.on(anchor.record.type)) }
    }
}
Plate 3. The lens of one fragment: a struct in Swift and a class in Kotlin, holding its anchor and one accessor per field. A linked field is a nested lens; an abstract field reads its slot on the concrete type.

Baton.baton.swift

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

/// Interned schema types used by this module's documents.
nonisolated enum Types {
    /// The schema's digest: pass it as the image's `version`, so an image
    /// written under another schema starts again.
    static let schemaDigest = "b1b04032a05beb69458454cc3c9ec9d0"
    /// The format of this generated code, which the runtime that reads it
    /// declares; a runtime of another format fails to compile this line.
    static let format = Baton.Format18.self
    static let Actor = Baton.Registry.type("Actor")
    static let Bot = Baton.Registry.type("Bot")
    static let EnterpriseUserAccount = Baton.Registry.type("EnterpriseUserAccount")
    static let Issue = Baton.Registry.type("Issue")
    static let IssueConnection = Baton.Registry.type("IssueConnection")
    static let Mannequin = Baton.Registry.type("Mannequin")
    static let Organization = Baton.Registry.type("Organization")
    static let Query = Baton.Registry.type("Query")
    static let Repository = Baton.Registry.type("Repository")
    static let User = Baton.Registry.type("User")
}

/// Interned storage keys used by this module's documents.
nonisolated enum Slots {
    nonisolated enum Actor {
        static let __typename = Baton.Registry.slot(Types.Actor, "__typename")
        static let id = Baton.Registry.slot(Types.Actor, "id")
        static let login = Baton.Registry.slot(Types.Actor, "login")
    }
    nonisolated enum Issue {
        static let author = Baton.Registry.slot(Types.Issue, "author")
        static let id = Baton.Registry.slot(Types.Issue, "id")
        static let number = Baton.Registry.slot(Types.Issue, "number")
        static let title = Baton.Registry.slot(Types.Issue, "title")
    }
    nonisolated enum IssueConnection {
        static let nodes = Baton.Registry.slot(Types.IssueConnection, "nodes")
    }
    nonisolated enum Query {
        static let repository_00c598 = Baton.DynamicKey(Types.Query, "repository", [Baton.KeyArgument("name", [.variable("name")]), Baton.KeyArgument("owner", [.variable("owner")])])
    }
    nonisolated enum Repository {
        static let id = Baton.Registry.slot(Types.Repository, "id")
        static let issues_44b8f4 = Baton.Registry.slot(Types.Repository, "issues(first:20,states:\"OPEN\")")
        static let nameWithOwner = Baton.Registry.slot(Types.Repository, "nameWithOwner")
    }
}

/// Storage keys read on interfaces and unions, each resolved once per concrete type.
nonisolated enum AbstractSlots {
    nonisolated enum Actor {
        static let login = Baton.AbstractSlot("login")
    }
}
Plate 4. The shared file: the schema's digest, the format the runtime must declare, and every type and storage key the target's documents name, interned once per process.

The format is a number the shared file names and the runtime declares; generated code of another format fails to compile at that one line, with a message, so a runtime and a compiler of different releases never meet silently.

The report#

--report writes what the target compiled as JSON: each operation with its kind, its variables, the fragments it reaches and its text; each fragment with its type and the operations that spread it; and each lens with its accessors, what each reads and as what. The report is what a reviewer reads for a contract change, what a dependent target's compilation would read, and what every lens plate on this site is drawn from.

The lens of IssueComments_issue, a fragment, reads a record of type Issue:

accessorreadsas
idida scalar
commentscommentsa lens of IssueCommentConnection
  totalCounttotalCounta scalar
  edgesedgesa lens of IssueCommentEdge, a list, optional
    nodenodea lens of IssueComment, optional
      idida scalar
      bodyTextbodyTexta scalar
      authorauthora lens of Actor, optional
        loginlogina scalar
    cursorcursora scalar
  pageInfopageInfoa lens of PageInfo
    endCursorendCursora scalar, optional
    hasNextPagehasNextPagea scalar
Plate 5. A fragment's lens from the report: the response key each accessor reads under, whether it may read absent, and the nested lenses.

Persisted operations#

Under persistConfig the compiler hashes each operation's text, writes Relay's map from id to text for the server to register, and the artifact carries the id and no text. The request then sends documentId, and the server runs nothing it has not seen. Without persistConfig the artifact carries the text and no id; the build decides, and no transport has a mode or a fallback.

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 6. An operation under persistConfig: the id it is sent as, and the text the server registers it under. batonc print prints both.

How the build runs it#

  • SwiftPM runs it through BatonPlugin, a build-tool plugin over the release's artifact bundle, one generate per target. Nothing to install; the bundle is prebuilt for macOS and Linux.
  • Gradle runs it through com.shergin.baton, a task with every input and output declared, over the same bundle. Gradle has the setup.
  • Bazel runs it through rules_baton, the command as one sandboxed action. Bazel has the rule.
  • Anything else runs the command itself. The command line is the contract: generate, validate for an editor or a hook, print for one operation's text, and --check for a team that commits its generated code.
batonc validate --config baton.json Sources/App/*.swift
batonc generate --config baton.json --out Sources/App/Generated --check Sources/App/*.swift

The output is deterministic: the same schema, configuration and sources produce the same bytes, which goldens in the repository fix for both languages. In a checkout, scripts/build-compiler.sh builds the binary, and BATON_COMPILER points any of the integrations at it.

Why it is this way#

The compiler decides is the principle, and Relay's front end, pinned, behind our driver the decision that keeps the vocabulary Relay's while everything after the plan is ours.