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:
| key | what |
|---|---|
schema | the SDL file, relative to the configuration |
schemaExtensions | client schema extensions: files of .graphql that give server types client fields |
identity | the key fields per type, id by default; a default list and types entries, interfaces included |
lookups | root fields answered from the store by id before the network |
customScalarTypes | a scalar's host type per language, and the Kotlin converter |
transient | the types and root fields that never reach the image |
persistConfig | Relay's: the file and the hash algorithm, under which an operation is sent by id |
onError | the error behaviour sent with every operation |
kotlin.package | the 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 ^
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 } ^
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)) }
}
}IssueRow.baton.kt
// Generated by batonc. Do not edit.
@file:OptIn(baton.Generated::class)
package app
import androidx.compose.runtime.Stable
import baton.Anchor
import baton.Lens
/** Lens for `fragment IssueRow_issue on Issue`. */
@Stable
class IssueRow_issue(override val anchor: Anchor) : Lens {
val number: Int get() = anchor.requiredInt(Slots.Issue.number)
val title: String get() = anchor.requiredString(Slots.Issue.title)
val author: Author? get() = anchor.linked(Slots.Issue.author)?.let(::Author)
override fun equals(other: Any?): Boolean = other is IssueRow_issue && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
@Stable
class Author(override val anchor: Anchor) : Lens {
val login: String get() = anchor.requiredString(AbstractSlots.Actor.login.on(anchor.record.type))
override fun equals(other: Any?): Boolean = other is IssueRow_issue.Author && other.anchor == anchor
override fun hashCode(): Int = anchor.hashCode()
}
}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")
}
}Baton.baton.kt
// Generated by batonc. Do not edit.
@file:OptIn(baton.Generated::class)
package app
import baton.AbstractSlot
import baton.DynamicKey
import baton.KeyArgument
import baton.KeyPart
import baton.Registry
import baton.Slot
import baton.TypeID
/** Interned schema types used by this module's documents. */
object Types {
/** The schema's digest: pass it as the image's `version`, so an image written under another schema starts again. */
val 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. */
val format = baton.Format2
val Actor: TypeID = Registry.type("Actor")
val Bot: TypeID = Registry.type("Bot")
val EnterpriseUserAccount: TypeID = Registry.type("EnterpriseUserAccount")
val Issue: TypeID = Registry.type("Issue")
val IssueConnection: TypeID = Registry.type("IssueConnection")
val Mannequin: TypeID = Registry.type("Mannequin")
val Organization: TypeID = Registry.type("Organization")
val Query: TypeID = Registry.type("Query")
val Repository: TypeID = Registry.type("Repository")
val User: TypeID = Registry.type("User")
}
/** Interned storage keys used by this module's documents. */
object Slots {
object Actor {
val __typename: Slot = Registry.slot(Types.Actor, "__typename")
val id: Slot = Registry.slot(Types.Actor, "id")
val login: Slot = Registry.slot(Types.Actor, "login")
}
object Issue {
val author: Slot = Registry.slot(Types.Issue, "author")
val id: Slot = Registry.slot(Types.Issue, "id")
val number: Slot = Registry.slot(Types.Issue, "number")
val title: Slot = Registry.slot(Types.Issue, "title")
}
object IssueConnection {
val nodes: Slot = Registry.slot(Types.IssueConnection, "nodes")
}
object Query {
val repository_00c598 = DynamicKey(Types.Query, "repository", listOf(KeyArgument("name", listOf(KeyPart.Variable("name"))), KeyArgument("owner", listOf(KeyPart.Variable("owner")))))
}
object Repository {
val id: Slot = Registry.slot(Types.Repository, "id")
val issues_44b8f4: Slot = Registry.slot(Types.Repository, "issues(first:20,states:\"OPEN\")")
val nameWithOwner: Slot = Registry.slot(Types.Repository, "nameWithOwner")
}
}
/** Storage keys read on interfaces and unions, each resolved once per concrete type. */
object AbstractSlots {
object Actor {
@JvmField val login = AbstractSlot("login")
}
}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:
| accessor | reads | as |
|---|---|---|
id | id | a scalar |
comments | comments | a lens of IssueCommentConnection |
totalCount | totalCount | a scalar |
edges | edges | a lens of IssueCommentEdge, a list, optional |
node | node | a lens of IssueComment, optional |
id | id | a scalar |
bodyText | bodyText | a scalar |
author | author | a lens of Actor, optional |
login | login | a scalar |
cursor | cursor | a scalar |
pageInfo | pageInfo | a lens of PageInfo |
endCursor | endCursor | a scalar, optional |
hasNextPage | hasNextPage | a scalar |
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}}}
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, onegenerateper 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,validatefor an editor or a hook,printfor one operation's text, and--checkfor 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/*.swiftThe 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.
Baton