Reference
Changelog
Every release, named for a bread, written for a person.
Notable changes, written so a person can read them. Pre-1.0, breaking changes are expected and listed without apology.
Unreleased#
- The documentation site, https://shergin.github.io/baton/, generated by
the crate under
site/and deployed frommasterby the pages workflow: the repository's docs rendered with plates under their headings, and the chapters the site writes for itself, a GraphQL tour for mobile engineers, one screen followed from the fragment to the next launch, a guide of ten chapters, and a gallery of the samples. No generated code, operation text, diagnostic or store on it is typed by hand:batonccompiles the figures undersite/figures/when a page is built, and every store is a dump the runtime wrote underspec/. The repository's rules on docs apply to it: a change that makes a page stale changes the page. - The README's examples are one screen over GitHub's GraphQL API, as the
GitHub samples and
docs/why-graphql.mdare: an issue row, an issues screen whose query spreads it into a repository's open issues, and the paging connection the list grows into. The Rick and Morty row is gone from the README; the benchmark page it names stays. The feel is the README's second section, and the numbers sit before the comparison with the other clients, which cites them.
0.15.0 (Sushka) — 2026-10-09#
- An image's open no longer holds a read of a file it is making.
Persistencenotes at construction whether its file exists; until the writer's first open, a read of a file that did not answers from memory alone, since the file holds nothing yet, while a file that did is waited for, or opened by the read, as before, so a warm start's first frame still has the list. On the Pixel 9 the sample's cold start with an empty store slept 23 ms of its first composition behind the file's creation, written through to flash; its first frame now comes 26 ms sooner, level with the Apollo Kotlin twin's, and the list 24 ms sooner, 255 ms from the launch against the twin's 327 (BENCHMARKS.md, 9 October). - A response is read where it is parsed. The Kotlin environment awaits the transport's payload on the ingest dispatcher and the Swift environment on a concurrent task, so the store's thread is entered once, for the commit, rather than first when the bytes arrive: a response that lands while the main thread draws a frame is parsed before the frame ends, not after it. On the Pixel 9 the sample's first page, 686 KB, is parsed and normalized in about 5 ms of a worker's time, inside the cold start's first frame.
- The Kotlin twin of the exchange,
kotlin/samples/exchange: anExchangeoverbaton.Transportin common Kotlin, the wrapper a production endpoint needs and the library does not ship. Credentials are the base transport's, read per attempt; a 401 is replayed once afterchallenged(); a query or a subscription refused with a 5xx or by a lost connection is sent again with a doubling, jittered wait, as many times asattemptsallows and never pastdeadline; a mutation is never sent twice; a stream that delivered is not sent again. The Kotlin GitHub sample sends through it, as the Swift one sends throughexamples/Exchange, anddocs/recipes/exchange.mdhas its Kotlin half, quoting the compiled loop.ExchangeTestsin the runtime's JVM tests prove it overbaton-testing's doubles and a loopback server: the replayed challenge carries the renewed token, a second challenge fails, a 503 then a 200 is sent twice, the attempts bound the sends, a deadline that expires during the backoff fails at once, a mutation refused with a 503 is sent once, a cancelled consumer ends the held attempt, an environment over an exchange reads ready after a retry, and a held mutation's optimistic layer stands through a retry beside it. - The Android sample tells the environment the process's lifecycle:
Activationinkotlin/samples/androidobservesProcessLifecycleOwner, setsisActivefalse when the last activity stops and true when the first starts, and callsrevalidate()on the return; the view model closes it before the environment ends.docs/recipes/views.mdquotes it as the Kotlin half of the lifecycle section ofdocs/recipes/uikit.md, which now links it. - Kotlin's
TransportErrorcarries the connection's own failure as itscausewhen there was no response:HttpTransport,OkHttpTransportand the socket clients pass the exception they caught, so a wrapper can tell a connection that was lost or timed out, which a retry may mend, from a request the transport could not make, which would repeat. A status 0 with no cause is still the latter. - A Baton app is a client of the server of fate's GraphQL template as it
ships: responses recorded from it under
spec/fate/prove its posts connection,node(id:),postAddwith@prependNodeand the graphql-sse stream offateLiveNode, whoseJSONpayload lands at the subscription root and updates no record. - The Kotlin runtime runs
spec/relay/andspec/fate/as the Swift runtime does, so a fixture under either directory now holds both runtimes, asdocs/principles/two-runtimes-one-compiler.mdasks.jvmRelayTestcompilesspec/relay/sourceswithspec/relay/baton.jsonthrough the Gradle plugin, which now names the Kotlin package, and runs every case and script but the unsupported ones under the same statuses asBatonRelayTests: the responses committed as payloads into a bare store, the dump compared with Relay's, acheckstep's answer compared, and eachreadsrow read by walking the generated lens, withthrowsandresultrows lifted as the Swift target's generated reads lift them; a case with a status runs as a known issue that fails once it passes.jvmFateTestproves the fate recordings through the Kotlin hostFateDocuments.kt, the twin ofFateDocuments.swift. Each harness runs in a JVM of its own, since the registry numbers types by name per process and Relay's test schema and fate's both declare aUser. CI runs both. - The Kotlin Rick and Morty sample has the episode screen, an episode's cast, and the location screen, a location's residents, the Swift documents verbatim; a character's episodes and its origin and last known location open them, and a cast member or a resident opens the character, through a stack of destinations the desktop's pane and the Android app keep, which Back pops and a recreation restores.
- The Kotlin GitHub sample has the triage screen: the viewer's assigned
and created searches over the
SearchResultItemunion with@aliasspreads, an issue row that carries its repository's name as the Swift row does, a pull request row, a refresh of the searches and a refresh of the rows throughnodes(ids:), behind a switch in the bar beside the repository. Its tests show both searches, the refresh asking for the rows' ids in order, and a row selecting its issue. - The Kotlin store's thread check says the violation, "the store is used off the thread that made it", where it stated the rule.
- The Kotlin runtime's floors are recorded,
docs/decisions/kotlin-floors.md: Android 6 (API 23), the lowest the Compose runtime and AndroidX SQLite allow; Java 21 class files on the JVM and on Android, the JDK the release builds with; and the current Kotlin, 2.4.20. The runtime's four published modules pin the JDK 21 toolchain in their builds, where the JVM target had followed whichever JDK ran the build: a local build on JDK 26 wrote Java 26 class files where the release wrote Java 21. - The recipes say Kotlin where they said Swift alone:
porting-from-relay.mdhas a Kotlin column beside the Swift one for every Relay word,discover-once.mdthe Kotlin twin of its refresh,derived-state.mdthesnapshotFlowshape and a link toviews.md, which links back. The Gradle recipe and the Kotlin README say that Baton needs no R8 or ProGuard rules and ships none, since the runtime, its modules and the generated Kotlin use no reflection, no serialization and no lookup by name. docs/terminology.mdhas its Kotlin part on every entry that has a Swift one, as its preamble promised: the thirty-four that lacked one, from Spread and Required to Pagination and the edge directives, say how the Kotlin runtime and its generated Kotlin spell the concept and what differs from Swift, a getter that throws, aResultfor@catch, aHold, aFlow, asuspendfunction.docs/recipes/testing.mdhas its Kotlin half: the test dispatcher an environment takes outside a composition, and each of the Swift snippets beside its Kotlin twin overbaton-testing.docs/comparison.mdis current at 0.14: the at-a-glance table carries the Kotlin runtime's numbers on the Pixel 9 beside the Swift runtime's on the Mac, the Apollo Kotlin head-to-head is a table of its own, the cons say what the Kotlin runtime is and is not rather than that there is none, the image con no longer names a row the writer replaced, which 0.8.0 fixed, and identity says whatbaton.jsonconfigures.kotlin/README.mdsaysbaton-testingbuilds for Android, as it has since 0.14.0.docs/recipes/agents.md, the page a coding agent reads first when it integrates Baton into an app: the reading order, the shape of a screen in Swift and Kotlin, what never to write, the compiler's diagnostics and what to run, and a review checklist. The README links it from "Try it on your app" and "Written by people, or by models".docs/why-graphql.md, for iOS and Android engineers who build on REST: the plumbing a REST screen needs, what GraphQL removes of it, what only Relay's fragments and store remove, what Baton adds, what stays the app's, and what it costs. The README's first paragraph links it.
0.14.0 (Kalach) — 2026-10-09#
-
The report
batonc generate --reportwrites,Baton.report.jsonunder the build plugin, lists each operation's and fragment's lens: every accessor's name, the response key it reads, and its shape, whether it reads absent, a@catchresult, a throwing read or a list, with the lens it nests. A renamed accessor or a field that starts throwing now shows in the report's diff. -
The UIKit half of
docs/recipes/uikit.md, compiled: aUIViewControllerover a handle, aUITableViewControllerwhose cells bind a lens, and the app's lifecycle on the application's background and foreground notifications, beside the AppKit ones in theControllerstarget. What both show is computed once, inContent.swift, and CI builds the target for the iOS Simulator. The README says that UIKit and AppKit are supported in Swift. -
docs/recipes/uikit.mdquotes compiled code: theControllerstarget underexamples/Controllers, anNSViewControllerover a handle, a table whose cells bind a lens, and the app's lifecycle told to the environment, proven byControllersTests. The recipe's controller observedhandle.phasealone and read the fields in the loop's body, which renders the first response and never a later change to a field; the closure now computes what the controller shows, and the first frame is read synchronously rather than one suspension later. A controller's operations live in a.graphqlfile, since@Queryis a view's storage. -
spec/relay/: Relay v21.0.1'sRelayResponseNormalizer-test.js,DataChecker-test.jsand its four reader suites (RelayReader,RequiredFields,CatchFields,RelayErrorHandling) harvested into cases, translated into Baton's keys byscripts/relay-harvest/and run by the Swift test targetBatonRelayTests. A hand-seeded store becomes the response it stands for, kept only when Relay answers the same from it; a reader test's answer becomesreadsrows read through the lenses the compiler reports, withthrowsandresultrows for@required(action: THROW),@throwOnFieldErrorand@catch. Relay's tests are measured against, not obeyed: of 227, 47 agree with Relay and must pass; 19 carry a status (possible-bug,unspecified-behaviour,invalid-input,by-design) and run with their results ignored until they pass; 51 use features Baton does not have and are kept out of the runs; 110 are not expressible yet, most of them a fragment read or checked at a record.docs/decisions/relays-tests-are-measured-not-obeyed.mdis the record. -
The compiler refuses
OptimisticResponseas the name of a Kotlin fragment or operation, with a message at the name, as it refusesData: the builder a mutation nests hid a mutation of that name inside its own body, and the Kotlin written for it did not compile. An enum or an input object of the name takes the suffix the package's kept names take. The Swift emitter accepts the name, as before. -
The Gradle recipe names Google's Maven repository beside Maven Central for dependencies, where the runtime's AndroidX pieces come from: a consumer built from the recipe alone could not resolve the Compose runtime. Its snippets apply the Kotlin plugin, include
.graphqldocuments among the hosts, and say wherebaton.json's entries are defined. -
The Android ingest benchmark measures what a store holds with the change set out of the measuring frame, which had kept it alive: the figures 0.13.0's notes give for a store after the commit, 1,100, 896 and 884 KB, were the store and the change set together; a store holds 328 KB (
BENCHMARKS.md). The benchmark app is profileable from the shell, sosimpleperfcan record it on a device. -
The change set finds an entity a response names again by the bytes of its key field, through a table in front of its string index, so the record's key is built once per record and not once per occurrence; the fixture names 6,533 entities for 899 records. A profile of the Kotlin ingest on the Pixel 9 put the decoding, the concatenation and the hashing of those keys at a fifth of its time. The Pixel 9 ingest takes 4.3 ms where it took 5.2, allocating 250 KB less, and the response is in the store in 5.3 ms against Apollo Kotlin's 44.7; on an M1 Pro the Swift ingest takes 1.73 ms where it took 2.06 (
BENCHMARKS.md). -
docs/runtimes.mdmaps the two runtimes' files to the vocabulary, a file per concept, and both trees now match it: the Swift runtime gainedAvailability.swift,Resolution.swift,Handle.swiftandSubscription.swiftout of the files that held them, the Kotlin oneConnections.ktandMembership.kt; moves, no logic. The boundary script checks the map and reads the Kotlin tree as it reads the Swift one: the common source set imports no platform, the testing module reaches the public surface alone, and every name generated Kotlin may spell is declared in the runtime. CI runs the Kotlin hostile-name sweep, over akotlincGradle writes from the embedded compiler. -
Subscriptions on Android, through
baton-okhttp: the Kotlin socket transport'sgraphql-transport-wsis the runtime's, in common code over aWebSocketClientthe platform or an edge supplies, the JVM's ownJdkWebSocketClientoverjava.net.httporOkHttpWebSocketClientover an app's OkHttp client, which Android has no socket without;GraphQLTransportWebSocket(url, client = OkHttpWebSocketClient(okHttp)). The same module bringsOkHttpTransport, HTTP over the app's OkHttp client with its interceptors and pool, reading a response as the built-in transport does; the pieces a transport over any client needs,Request.accept,requestErrorsandanswersInGraphQLResponse, are public beside the two parsers.baton-testingbuilds for Android too, and holds theSocketServerdouble a socket transport's tests run against. -
The availability check counts a scalar cell as present only when its value fits the field's kind, in both runtimes: a cell the image wrote under a schema that since gave the field another kind reads as absent, so the operation fetches and the response writes the cell again, where the check used to take the cell as present and the lens then read nothing from it. A schema change needs no new
versionon the persistence; the version is for what the app caches meaning something else under the same shape. -
The vocabulary and both READMEs say that a process holds one schema family: the registry numbers types and slots by name for the whole process, so two environments over one schema share them, and two schemas in one process must not give two types one name.
-
The Kotlin environment sends the snapshot system's apply notifications after the runtime's own writes, once per burst, posted behind the burst on its main dispatcher, so a model over
snapshotFlowsees a change without Compose UI's frame clock;docs/recipes/views.mdis the page, a handle held by a view model and read as a flow of what the screen shows.
0.13.0 (Bublik) — 2026-10-09#
- The Kotlin runtime on Maven Central:
com.shergin.baton:baton,baton-testingandbaton-inspector, Kotlin Multiplatform artifacts for the JVM and Android, versioned with the release; and the Gradle plugincom.shergin.baton,kotlin/baton-gradle, the command's contract spelled in Gradle: aBatonGeneratetask that runsbatonc generatewith the hosts, the configuration and the files it names declared as inputs and the generated Kotlin and the report as outputs, over the compilerBATON_COMPILERnames or the release's artifact bundle, fetched once into the Gradle user home and checked against the checksum the release stamps intokotlin/release.properties. The checkout's own modules generate through it. The release workflow publishes the artifacts and the plugin from the tag, anddocs/recipes/gradle.mdis the page. - The Kotlin store's keys live in a table under a lock, changed in place,
as the Swift store's do. The table was an immutable snapshot replaced by
compare-and-set: every new text a session rendered copied the type's
whole table and scanned it for a freed number first, quadratic in the
texts a type renders, a search field's terms for one. The image's locks
are the same
Locknow, inThreads.kt. - The Kotlin environment's suspending calls,
fetch,mutate,commitPayloadandend, run on its main dispatcher whatever thread calls them, as the Swift environment's run on the main actor; a call fromDispatchers.IOused to fail the store's thread check. The synchronous calls are still made on the store's thread. - The Kotlin record's cells are plain values, and a slot has a channel,
Compose snapshot state, only once something read it: a write to a slot
nobody read is a store into an array, where before every written slot
carried a state object and every write went through the snapshot
system. A read in composition registers the channel as it registered
the cell, and the notifications are the same, slot by slot, under the
spec's scripts. On a Pixel 9 the commit of the 899-record fixture takes
0.95 ms where it took 1.35 ms, allocates 448 KB where it allocated
672 KB, and a store holds 896 KB where it held 1,100 KB; the ingest does
not move. The availability check, which reads records the same way,
takes 0.74 ms where it took 1.2 ms, and the untracked field read pays
about 3 ns for the second array it loads, 47 ns where it was 43. Against
Apollo Kotlin on the same phone the response is in the store in 6.3 ms
against 44.6 ms (
BENCHMARKS.md). - A Kotlin record's array of values is sized by what the record holds: a commit reserves the highest slot its change set writes to the record, and a write past the end grows it. Before, every record allocated an array as long as the type's slot count in the whole process, every document's fields on the type, when it rendered a few of them. The benchmark's process holds one document, so there the commit allocates 24 KB less and a store holds 12 KB less, for a time within 0.02 ms; the gain is for a process with many documents on a type.
- A Kotlin query's state in a composition is a
QueryState:rememberQueryreturns one, holding the operation value and the handle it resolved the value to, with thephase,fetch,isRefreshing,isStale,retry()andrefetch()that read the handle;rememberSubscriptionreturns the subscription's handle, or null outside every provider. An operation value carries nothing of the live side any more:resolutionis gone from the generated classes, which are@Immutable, andResolutionfrom the runtime, so the value a composable passes is never written to, and a composable that gains or loses the provider, or passes another policy, resolves anew. The three derived failures,FieldErrors,RequiredFieldErrorandMissingDataError, are equal by what they hold, so a failed phase that did not change compares equal, and a handle makes its root's lens once. The Kotlin format is 2; code generated for format 1 fails to compile against this runtime with a message to rebuild. - A collection pass marks the records it reaches with the store's epoch, a
number on the record, where it kept a set of them; the sweep reads the
mark, and a root prunes its links to swept records by the record's own
flag, so a pass allocates nothing. On an M1 Pro the pass over one root
reaching 50,004 records takes 4.7 ms where it took 6.4 ms
(
BENCHMARKS.md, which also records that the pass is 2.4 times what the keys step measured, a regression in the walk since found). The Kotlin collector marks the same way; it has no collection bench yet. - A resolved variant is a reference. It had become a struct of ten lists
on 6 October, looked up by value once per record by every walk, so each
lookup retained and released ten buffers; a bisect of the benchmark
suite found it. With it a class, the collection pass over 50,004 records
takes 1.6 ms where it took 4.7 ms, the availability check 138 µs where it
took 605 µs, hydration 1.9 ms where it took 2.4 ms, and the ingest a
tenth less; the session's footprint is 8 MB smaller (
BENCHMARKS.md). - The ingest reads a matched field's members through the plan's list and
never copies the field, which a profile found copying its lists once
per JSON key; the record's value array and the store's table skip the
dynamic exclusivity checks that main-actor isolation makes redundant,
and the table is reserved for a change set's records before a commit
grows it. On an M1 Pro the response is in a change set in 2.06 ms where
it took 2.92, the commit into an empty store takes 470 µs where it took
600, and an untracked lens read 21 ns where it took 26 (
BENCHMARKS.md).
0.12.0 (Palianytsia) — 2026-10-08#
- Baton and Apollo Kotlin end to end on a phone:
kotlin/samples/apollo-android, the Android sample's twin over Apollo Kotlin 5.2.0 and its memory and SQL caches, andkotlin/benchmarks/macro, a Macrobenchmark module that drives both release builds against a fixed server in each app's process. On a Google Pixel 9, a cold start over the data on disk shows the list in 240 ms, in the first frame, against Apollo's 272 ms after a spinner; a tap to a detail takes 22 ms against 33 ms; scrolling is the same in both. From the response's first byte to the list's frame Apollo is faster, 42 ms against 58 ms. The Android sample's release build is signed with the debug key, its screens report their first draws, and a launch can ask for the fixed server. - Apollo Kotlin measured beside Baton,
kotlin/benchmarks/apollo-comparison: the same operation and graph through Apollo Kotlin 5.2.0 and its normalized cache 1.0.9, configured as documented, on the JVM and on a Google Pixel 9 in a release build. On the phone the response is in Baton's store in 6.5 ms and in Apollo's in 44.6 ms, and Apollo's read of the query back takes 25 ms where Baton's availability check takes 1.2 ms; a field of Apollo's read model costs 2 ns against 43 ns for a lens read.docs/comparison.mdquotes these in place of Apollo's own bench. - The Kotlin runtime's first numbers on a device, in
BENCHMARKS.md: on a Google Pixel 9 running Android 17, the 899-record fixture tokenizes in 19.0 ms off the main thread and commits in 6.7 ms on it, a debuggable device-test build, and in 5.1 ms and 1.3 ms from a release build that is not debuggable,kotlin/benchmarks/android, the medians of 300 runs; the native-runtimes record carries the number as the ingest budget's first evidence. - The desktop sample keeps its page bar through a failure, so a page the
public API refused (HTTP 429, Cloudflare's 1015 under a burst) can be
left or retried; caches avatars for the process; keys its rows by
recordID; keeps the store's image under the schema's digest, so a relaunch shows the characters before the network answers; and draws its screens to PNG files without a window throughgradle :samples:desktop:screenshot. - A second Kotlin sample,
kotlin/samples/github, the Compose for Desktop twin ofexamples/GitHubTriage: sign-in with a token kept in memory, a repository with a star toggle whose optimistic response flips the star and the count, its open issues as a connection that loads the next page at the list's end, an issue whose composer appends an optimistic comment by the viewer through@appendEdge, and sign-out that ends the environment and removes the image. Its tests run the screens overScriptedTransport. - A Kotlin operation's companion is a
QueryType,MutationTypeorSubscriptionTypenaming the operation's class beside its data, soval rename = rememberMutation(RenameMutation)infers its action's type.OperationTypeand the three are application API, outside thebaton.Generatedopt-in that an app's call torememberMutationfailed before; what generated code alone calls on them stays inside it. @Fragment,@Query,@Mutationand@Subscriptionrepeat in Kotlin, so one composable can host several documents, as a button that stars and unstars hosts two mutations.- The Kotlin
RetentionisHold(handle.retain(): Hold,hold.release()), sincebaton.Retentionhidkotlin.annotation.Retentionfrom every file that writesimport baton.*. Swift keepsRetention. Phase,FetchandMutationActionare@Stablein Kotlin, so a composable handed a failed phase skips like any other. The Compose compiler's reports, turned on with-PcomposeReports=true, show every generated lens andLensListstable and every composable of the desktop sample restartable and skippable.baton-inspector, the Kotlin store inspector:StoreInspector(environment), a live, searchable Compose view of the store's records by type with their fields, values and field errors, andStoreExport.text(store), the dump. The desktop sample shows it in a third pane from its View menu (Command-I), and follows the system's light or dark appearance.- The Kotlin runtime builds for Android (API 23 and up): the image on the
system's SQLite through
AndroidSQLiteDriver,HttpTransportandEnvironment(url)shared with the JVM, andPersistence.named(name, directory = cacheDir.path), since the runtime holds noContext.baton-inspectorbuilds for Android too; the desktop sample's screens moved tokotlin/samples/shared, whichkotlin/samples/android, a phone app, shows as well; andIngestBenchmark, a device test, logs the Fixture's ingest and commit medians with the device's model for the ingest budget.
0.11.0 (Karavai) — 2026-10-07#
rules_baton, a Bazel module underbazel/, versioned with Baton: a toolchain its extension fetches from the release's artifact bundle, selecting the variant for the execution platform, or from the compilerBATON_COMPILERnames;baton_generate, the command with every input and output declared, one output per source named as the SwiftPM plugin names it, whose files aswift_libraryor akt_jvm_librarylists in itssrcs, with the report and the persisted documents file as output groups; andbaton_check_test,--checkover committed output.docs/recipes/bazel.mdis its page, in place of thegenruleon the command's contract page, anddocs/decisions/a-build-integration-holds-no-logic.mdrecords why a shell holds no logic and wraps no library rule. Answers issue 40.batonc generate --persisted <file>names where the persisted documents file is written, as--sharedand--reportname theirs, so a build system that declares its outputs before it readsbaton.jsoncan declare this one; without it the file goes under--out, else beside the configuration, as before. The contract page now also says that--schemabeside--configoverrides the configuration's schema, and that any source an--emitnames gets a header-only output when it holds no GraphQL, both of which the compiler already did.- The compiler's artifact bundle carries a static Linux binary for
x86_64and one foraarch64beside the universal macOS one, listed under the gnu triples a Linux host reports and built with the musl target, so one binary runs on any distribution. SwiftPM on a Linux host and a Linux CI runner take the compiler from the same bundle a Mac downloads; the release workflow builds each on a host of its architecture, and CI builds and runs them so a release is never their first build. The Linux half of issue 40. - A subscription's stream that fails on its environment, as one with no
subscription transport does, ends with that failure as a request error
ends it, in both runtimes, instead of retrying on the backoff forever;
retry()opens it again. A transport failure still reconnects on the backoff. - The Kotlin host's marker is recorded in
docs/decisions/the-kotlin-host-marks-a-document-on-the-composable.md: a document is an annotation on the composable that renders, holds or acts, written in a$$raw string since$is a template in Kotlin and a variable in GraphQL; answers issue 6. batoncwrites Kotlin lenses: a@Stableclass per fragment and per operation'sData, equal by its anchor, with a property per field over the anchor's readers and a companion of its checks; an@inlinefragment as a data class read once; a connection's state,loadNextandrefetch. A mutation gets itsOptimisticResponseand asuspend operator fun invokeon itsMutationAction. A generated enum's case for an undeclared value isUndeclared, sinceUnknownand a schema valueUNKNOWNare one class file on a file system that ignores case. The Kotlin runtime's tests now run every case of the manifest, its records and its reads, through the codebatoncgenerates fromspec/sources.- The Kotlin runtime has its environment: the wire's
Requestand standard encoding, aTransportwhose one verb returns aFlow, the handle with its fetch policies, its derivedPhaseand itsFetch, roots with retention, ages, the verdict and collection, the availability check, the heal,mutate,commitPayload,end()and the value-free log;baton-testingholds the scripted, recorded and silent transports. Every script of the manifest runs through it, to the steps that wait for the image, the subscriptions and the optimistic layers. A Kotlin operation's companion forwards itsData'sfieldErrorsandmissingRequiredField, so the handle judges its data. - The Kotlin target refuses, with an error at the name, a field named
VariableorVariables, and a fragment or an operation named like what the generated Kotlin calls or declares where it names the class (Unit,OptIn,JvmName,listOf,anchor,equals,fieldErrors,name,text,data,Data,invokeamong them), for which it wrote Kotlin that did not compile; it writessuspend,outanddynamicin backticks. A corpus of Kotlin's hostile names,compiler/src/tests/hosts/HostileKotlinDocuments.kt, holds every keyword and every name the generated Kotlin declares in every position inside a document, andscripts/hostile-name-sweep-kotlin.pycompiles the names of fragments and operations withkotlinc. - The Kotlin runtime applies an optimistic response as a layer that a
commit rebases under, the server's answer replaces and a failure
reverts, notifying only what differs at the batch's end; holds a
subscription's stream in a
SubscriptionHandlethat reconnects by the fixed backoff and parks while the environment is inactive; and loads a connection's pages and refetches a fragment.Storeis public, andEnvironment(transport, subscriptions, store)takes it, with adebugflag that prints missing data until a log is set. Every script but the image'srelaunchruns whole, and every case'soverridereads under its layer. - The Kotlin runtime keeps the store's image:
Persistence(path)orPersistence.named(name), handed toStore(persistence), writes every server batch behind the commit on a thread of its own, and the availability check reads back what memory lacks, so a relaunch draws its first screen from the last launch's data and ages survive it. The schema, the versions, the eviction by launch, the names forgotten with their rows and what never reaches the file are the Swift runtime's; the file is reached through the AndroidX SQLite driver API, with the bundled engine on the JVM alone. All twelve scripts run whole. - The Kotlin runtime speaks to a server and to Compose.
HttpTransportposts overHttpURLConnection, reading a deferred response'smultipart/mixedparts and a subscription'sgraphql-sseevents through the commonMultipartParserandEventStreamParser, credentials per attempt, a request error answered asapplication/graphql-response+jsonas itsGraphQLErrors;GraphQLTransportWebSocketspeaksgraphql-transport-wsover the JVM'sjava.net.http.WebSocket, one connection per transport; andEnvironment(url)makes an environment over HTTP.LocalBaton,rememberQuery,rememberMutationandrememberSubscriptionresolve operation values in a composition, retained while the composable stays.kotlin/samples/desktopis a Compose for Desktop app over the Rick and Morty API. - Generated Kotlin compiles for two shapes it did not: a fragment or a
field named like a plan's selection,
selection0, which a lens read as the selection, since the selections are now members of a private object beside the operation's class,`HeroQuery-plan`, that no lens sees; and an@inlinefragment's value of some two hundred linked fields, whose constructor passed the 64 KiB the JVM allows a method, since the constructor now reads each field through a private function of the value's companion,`read-name`.
0.10.0 (Vatrushka) — 2026-10-07#
- The macros accept swift-syntax from 602 to 604, where they accepted 602
alone, so an app that pins swift-syntax to its compiler's release, 603
for Swift 6.3 or 604 for 6.4, resolves Baton, and gets swift-syntax's
prebuilt macro support with it. The range spans the floor's toolchain to
the newest release and CI builds both ends; recorded in
docs/decisions/swift-syntax-spans-the-floor-to-the-newest.md. - A list of scalars follows its element type in what it accepts, as the contract says and the Kotlin readers do: a list of strings reads a number's or a boolean's text, a list of floats reads an int, a list of ints reads a whole float, as the single-value readers always did. Before, a list element of another kind was dropped or read as nil where the scalar reader converted it.
- The Kotlin runtime has its store and its ingest: a response's bytes
become a change set by the plan, with no JSON tree between, and the
commit writes it into records whose cells are Compose snapshot state, so
a read in composition registers the field and a write tells its readers
alone. Connections merge, edge directives edit, errors land on their
fields and deferred parts on their records, and every case of
spec/manifest.jsonleaves its dump byte for byte on the JVM. The lenses, layers, retention and the image follow. batoncwrites Kotlin:generate --language kotlin, or a.kthost, writes each operation as a class of its variables, equal by them, whose companion holds its document and plan, and the sharedBaton.baton.ktwithTypes,Slots, the schema's enums and its input objects, in the packagebaton.jsonnames under"kotlin": {"package": …}or the host's own. The.ktscanner reads every Kotlin string form, and a document with a variable is written in a$$string. A mapped scalar names its Kotlin type and converter underkotlin. Lenses follow with the Kotlin readers.- The Kotlin runtime reads: the anchor's readers behind every accessor
generated code prints, the owner that settles a lens's keys, conditions
and
@argumentsonce, and the placeholder behind a non-null link with no record. A read registers the one cell it reads; a missing or wrong-kind value is reported to the store's log once and reads as null or a zero value;@required,@catch,@throwOnFieldErrorand@deferread by Relay's rules,@catchas akotlin.Result. Lenses written by hand after the Swift goldens read thereadsrows of ten operations' cases as the cases say. Pagination and refetch come with the environment. URLSessionTransportreads a request error answered with a 4xx or 5xx status asapplication/graphql-response+json, as GraphQL Yoga, Hive Gateway and Apollo Router do for a document that fails to parse or validate or an unknown persisted document: it throws the server'sGraphQLErrors, the request kind of failure, where it threw aTransportErrorwith the body as text. A query fails with the server's errors and theirextensions, and a subscription refused so ends rather than reconnecting by backoff. Another body outside 2xx, and anyapplication/jsonone, is still aTransportError.- The common-first record is amended: the JVM, through Compose for Desktop, is the first actual and the development target of the Kotlin runtime; Android is the first shipped target and the ingest budget's home. It names the two primitives a target supplies beside the image, the transports and the activity signal: a thread's identity and a double's shortest text.
- The authors' documents are under
spec/sources/, one.graphqlfile each, with the configuration they compile with inspec/tests/baton.json, so a second runtime's harness compiles its lenses from the specification alone. The compiler's tests check them against the Swift test target's markers and prove they plan what the markers plan. The manifest is format 3:sourcesnames the directory and the configuration. - The spec's scripts compare the requests a step sends: a
sentexpectation lists each request's operation and the exact body the standard encoding writes. Scripttransportholds the body's member order, the text without client fields and client directives, an enum and a mapped scalar sent as their text, and an input object's unset fields left out. - A nullable variable left unset is left out of the request, and one the
operation declares with a default is sent as that default, where both
were sent as
null; GraphQL applies an argument's default only to an absent variable, so the server and the store's keys now see one value.
0.9.0 (Krendel) — 2026-10-07#
Payload, bytes in a response's shape, is what the door takes:commitPayloadtakes aPayloadwhere it tookData, andmutate(_:optimistic:)and a mutation's action take one where they took aVariable; a mutation'sOptimisticResponsebuilder renders itspayload, and the JSON value it collected its fields in is generated code's alone.Variableis a variable's JSON value and nothing else. Format 18. Recorded indocs/decisions/a-payload-is-bytes-in-a-responses-shape.md.- A key the store holds at two slots, a rendering and the constant the
build named for it afterwards, is one field in every report: a commit's
changedcount counts the pair once, and the store's dump and the inspector list the key once, and a record a batch creates counts among nothing changed whether or not the store holds twins. Before, a late constant doubled the count, made created records count, and showed the key twice in the dump, so a count or a dump compared across test runs differed by what else the process had touched. - A handle stores no phase. The root, which is the store's, holds whether
the store has the operation's data and the verdict on it, what the data
deserves by
@throwOnFieldErrorand a bubbling@required, settled by the store at the end of a batch that changed a null, a link, an error or a deletion, and when the handle finds or fetches the data; the handle'sphaseis derived from the root and its ownfetchwhen it is read. The settling chain from the store through the environment to every retained handle is gone, and with it the store's last covert pointer to its environment. With data the phase reads the verdict and not the fetch, so a body that reads the phase is not woken by a fetch that changed nothing. Two readings change: astoreOnlyhandle that found no data reads ready once the operation's response is committed by any route or it is attached again over data the store now holds, and a handle that failed with nothing to show reads ready when it is attached again over such data; before, both stayed failed. Recorded indocs/decisions/the-verdict-is-the-roots.md. - The compiler's
decidestage spells nothing itself: it asks a naming the target supplies for every identifier, suffix, family and member name it needs, and the driver decides first and prints through the Swift target after; host files are found through a table of languages, with the Swift scanner and its checks behind it. The generated code does not change by a byte; the seams are those a Kotlin emitter needs. - Scripts, the second kind of fixture under
spec/: a file underspec/scripts/runs steps over time in one environment over one store, through a transport the steps answer, and after any step compares the dump, the reads, the fields notified, a handle's phase, fetch and stream, the check's answer, the records held and the log's events. Eleven scripts (notifications,optimistic,connections,lifetime,ages,phase,check,heal,end,events,subscriptions) hold the rules of the contract that no single commit could; the manifest goes to format 2, withscriptsbesidecases, andspec/README.mdsays what a script holds. The Swift runtime passes them all; loading a page through a lens and the transport's framings stay with the Swift tests for now. customScalarTypestakes, beside the Swift type as a string, an object by language,{"swift": "Foundation.Decimal", "kotlin": "..."}; a mapped scalar with noswiftentry is an error at the configuration. The plan the compiler lowers carries the scalar's name and theonErrorvalue, no longer a Swift type or a Swift case: the Swift writer resolves both, so a second emitter reads the same plan. The refetch descriptor names the@fetchablefield Relay's metadata names instead of a field spelledid. The generated Swift does not change by a byte.- A view outside every
.environment(\.baton, ...)reads.failed(EnvironmentError.notInjected)on its first body and makes no handle, and a mutation action in such a view throws the same; the shared placeholder environment, a real store that every view which forgot the injection fetched into, is gone. The absence of an environment is not a session. Format 17: an operation value'sresolutionis aResolution, unresolved, resolved to its handle, or not injected. - A lens is
Equatable, by its anchor: the same record, the same scope and the same origin, by identity, as the principle always said. A row view whose stored state is a lens conforms in one line and opts into.equatable(), so a parent's re-render skips it. - Five decisions recorded before the Kotlin lane, in
docs/decisions/: the verdict is the root's and the phase is derived from it (superseding in part The phase stays stored; built behind a gate once the phase scripts exist); a payload is bytes in a response's shape, Relay's word, which the door and the optimistic builders will take in place of aVariable; a mapped scalar's host type is named per language under Relay'scustomScalarTypes, so the plan carries the scalar's name; the Kotlin runtime is common first with a platform as an actual (issue 24); and a format is per emitter. spec/runtime.md, the runtime contract: what a runtime does with a plan, a response and a store, in no language's terms, one paragraph a rule, each ending with the fixture that holds it or the word unheld, so a second runtime is held to the rules and not to the Swift that spells them. The principle Two runtimes, one compiler names it, and no longer counts the image's bytes among what the fixtures specify: a second store over the image reading the same records is what is shared.@inlineis built: a fragment so marked compiles to aSendable,Hashablestruct of its fields in place of a lens, with a nested struct per link, an array per plural link and an initializer that takes the fields. The spread's accessor on the parent's lens builds the value from the record when it is called, on the main actor, through the readers a lens's accessors use, so what a read registers and reports is the same; a conditional or deferred spread yields an optional value and an aliased@catcharound one aResult. An inline fragment spreads only inline fragments and takes no@connection,@refetchableor@required; a non-null mapped scalar in it reads optional, since a stored property cannot throw. A value's field errors include those of the values it spreads, so a catch or a policy around it sees them, and@throwOnFieldErroron a value spread inside another value is refused. Format 16: generated code names the readers that build a plural link's values. A fragment spread by no operation is warned as one nothing can read, lens or value. Recorded indocs/decisions/a-fragment-has-one-reading.md.
0.8.0 (Back Straight) — 2026-10-06#
The spine: the architecture the decision records describe, built. A handle's fetch and a subscription's stream are values beside its phase, and a failure says its kind; every write is a batch through one door; the store owns the roots that keep records alive, stamps their ages at the commit and collects from them alone; an environment ends; and the keys a session renders are its store's. Around it, the shapes a production schema has read as Swift types (enums, input objects, mapped scalars, configured identity, client fields), the transport has one verb, the image evicts by launch and keeps the rows a partial response did not name, and the compiler validates, prints and reports what it compiled.
- The runtime reads a plural link out as values:
values,requiredValues,caughtValuesandcaughtRequiredValueson an anchor build one value per linked record at the read, for the@inlinefragment the compiler is learning to emit.FieldErrorsand everyMappedScalarareHashable, so a value holding a caught field or a mapped scalar can be. A mapped scalar type of the app's own that was notHashablemust become it. - A response with a few of a record's fields no longer empties the
record's row in the image of the rest. The writer replaced every row with
the commit's snapshot of the record, which for a record memory had not
read from the image held only what this launch's responses wrote: a
header fetched before a screen left the screen's next check a miss. The
snapshot of a record memory has not read is now merged into its row, the
response's cells over the row's; a record the check has read replaces
its row as before, and so does a deleted one. Recorded in
docs/decisions/the-image-is-sqlite.md. - An image's
close()is final. A commit or a read that reached a closed image opened its file again and took it back from the next environment's image, which then ran without one or, in a debug build, tripped the assertion that one image holds a file. A closed image drops what is queued after and misses every read;removeAll()still deletes the file. Recorded indocs/decisions/an-image-belongs-to-one-store.md. - An operation's text is printed compact, with Relay's printer's own
option: no newline, indentation or optional space, a comma between
items, strings as they are. The test target's 115 operations hold
61,797 bytes of text where they held 83,885, and the deepest realistic
one 47% of what it held; the 65 KB operation of issue 36 that a server
refused goes out at about half. Every persisted id changes with the
text, so a team with registered ids regenerates the file and registers
again;
batonc printandspec/documentsshow the compact text. Recorded indocs/decisions/operation-text-is-printed-compact.md. - A connection on the query root comes back from the image. The availability check hydrates the root a waited field at a time, and the connection's client link, Relay's handle key on the root, was never among them, so a reopened store read the connection as ready and empty while the same connection under a record came back whole; the check now reads the root's cell for the link before walking the connection. Issue 35.
- An operation's plan declares each distinct selection once, as a static
member with its type stated, where it was one nested expression that
copied a fragment at every spread. A union inside a union no longer
exhausts the Swift compiler: the case of issue 34 that was killed at
12 GB compiles in 1.7 s and 0.24 GB, and its plan holds 82 selections
where it held 2,258. Recorded in
docs/decisions/a-plan-declares-each-selection-once.md. - An object under an interface or union takes its type from its
__typenameby a byte comparison with the names the plan lists, where the ingest made a string and took the registry's lock for every object; an escaped or unlisted name still asks the registry. A page of 899 union results ingests in 508 µs against 541, inBENCHMARKS.md. - The compiler's tests fence the bytes of generated code per accessor line at 120 over the goldens, the budget 0.1.0 named and never enforced; the goldens stand at 106.1.
- A part of a deferred response that names a place no earlier part created,
or a label the plan does not know, is logged as
partDroppedwith its response path, where it was dropped without a word. - An image over its size limit evicts instead of starting over: the rows
of launches before the last go first, then the last launch's, and the
file shrinks; only a file still over the limit with nothing left to evict
starts again. Recency is the launch's, which the rows already record; no
rule per type. Recorded in
docs/decisions/the-image-evicts-by-launch.md. - CI compares the benchmark suite's deterministic counts, the notifications
a commit path fires and the events a commit logs, with
benchmarks/counts.txt:swift run -c release BatonBenchmarks --countsprints them, onecount <name> <value>a line, and a change to them is a diff to review where a timing would be noise. - A recipe,
docs/recipes/discover-once.md: a subject discovered once by its natural key and refreshed by id throughnodes(ids:), the pattern an app with external keys needs; the GitHub sample refreshes its rows that way from the toolbar. - A recipe for derived state outside views,
docs/recipes/derived-state.md: a model derives its value inside anObservationsclosure over the lenses it reads, and no commit signal is added; recorded indocs/decisions/derived-state-is-observed-not-signaled.md. List.empty, the value a view substitutes for a nullable list it reads as empty:fragment.reviewRequests?.nodes ?? .empty. A nullable list still reads asList?, since the server's null and its empty list differ.Record,Value,Slot,TypeID,OwnerandMembersare generated code's interface, behind@_spi(Generated), now that no hook hands them out; an app's own files see lenses, handles, the environment, the log, transports and persistence.- The GitHub sample keeps its store in an image across launches and signs out from the toolbar: the environment ends, the image's file is removed, and a new environment takes over, as the README describes.
- Two recipes:
docs/recipes/uikit.md, a handle held by a view controller and rendered throughObservations, with a cell over a lens; anddocs/recipes/porting-from-relay.md, Relay's words beside Baton's, what differs on purpose, and what is not ported with its reason. - A recipe for previews and tests,
docs/recipes/testing.md: a fixture committed as a response, recorded responses, a held mutation and a driven subscription, the log in tests, and a bug report's dump as a fixture. Environment.fetchhas one spelling, the operation value's. The one by type and variables, which returned the uncaught field errors as an array beside the other's throwing under@throwOnFieldError, is gone: a fetch's field errors are read where a view reads them, from the data, and each one no@catchhandled is afieldErrorevent of the log.- The environment logs.
Environment.logis one closure called with eachLogEvent, a value-free enum of names and counts: a fetch started, completed with its duration or failed with its failure's kind; a commit with its kind and the slots it changed in records that existed; a field error a fetch's response carried that no@catchhandled, by operation and response path, as Relay's field logger reports them; the image opened, unavailable, written with its batches or failed; a field read and never fetched, a value a reader's type cannot hold, an id naming records of several types, a@required(action: LOG)field that is null, each by type and field. It replaces the four hooks,reportMissing,reportUnexpected,reportAmbiguousIdentityandrequiredFieldMissing, which handed out records, slots and values. Debug builds print the missing-data cases untillogis set. - A query or subscription value's
resolution, the handle a view resolved it to, is the mechanism's: declared behind@_spi(Generated)on the protocols and in generated code, read throughphase,fetch,isStaleand the rest as before. Lens.typeNameis gone: a line of generated code per lens and a public requirement, read by nothing. Format 15.- A fragment no operation reaches, directly or through another fragment, is a warning at its definition: nothing can read its lens, and the code generated for it is dead. The test target's fragments now all reach an operation.
batonc validate, the same compilation with no output, for an editor or a hook;batonc print <Name>, one operation's text and id as the app sends them; andbatonc generate --check, which writes nothing and names every output on disk that differs from what it would write, for a team that commits its generated code. The command's contract, with a Bazelgenruleover it, isdocs/recipes/batonc.md.BatonInspector, a third product of the package for a debug menu:StoreInspector(environment)is a view over the store, its counts, its records by type searchable by key, each record's slots with their values and field errors, and a share button that exports the store in the dump formatspec/freezes, so a bug report can become a fixture. It reads and never writes.- The compiler writes a report of what it compiled for a target:
batonc generate --report <file>, andBaton.report.jsonin the build's output directory under the plugin. Every operation with its kind, source, id, variables, the fragments it reaches and its text; every fragment with its type, source, the operations that reach it and its printed definition; the schema's digest. Deterministic and by name, so a diff of two reports is the contract's change. The report and the persisted documents file sit beside the generated Swift in the plugin's output directory and are not outputs the build bundles, so neither reaches the app. - A recipe,
docs/recipes/exchange.md, and its sample,examples/Exchange, for what a production endpoint needs around the transport's one verb: one replay of an authorization challenge, a bounded retry with a jittered doubling backoff over a 5xx or a lost connection, a deadline across every attempt and its waits, and never a second send of a mutation or of a stream that delivered. The GitHub sample sends through it. BatonTestinggainsScriptedTransport, for an app's tests: answers from fixtures by operation name or through a responder, mutations held until the test replies or refuses, subscriptions driven by hand, and the requests sent listed by kind; andwait(until:), which waits on the main actor for a handle or a store to settle.- Subscriptions over HTTP, with
graphql-ssein its distinct-connections mode:URLSessionTransportasks a subscription fortext/event-streamand yields the payload of eachnextevent untilcomplete, so the environment'ssubscriptionsmay be the HTTP transport where a gateway holds no sockets.EventStreamParsersplits the events besideMultipartParser's parts, whatever the chunking. The single-connection mode waits for a gateway that cannot do HTTP/2. - A subscription reconnects in its handle. A stream that ends by a failure
while the handle is retained waits and opens again, by a fixed backoff: a
step doubling from one second to thirty, jittered, reset by an event; the
stream's value says
waiting(until:)meanwhile, andretry()skips the wait; a request error, the server's refusal of the operation, ends the stream instead. The handle countsresumptions, the times the stream was opened again, so an owner that observes it refetches its baseline. The environment'sisActive, set from the app's scene phase, parks every retained subscription while false and resumes them when true. - The transport has one verb.
Transport.sendtakes a request and yields a stream of payloads: one for a query, the parts of a deferred response, the events of a subscription, so a wrapper wraps one method;payloadreads the one payload of a request that answers once.execute,streamandSubscriptionTransportare gone, and the environment'ssubscriptionsis aTransport. ARequestsays itskindand carries the operation'sdocument, text or id and never both; the operation'stextandpersistedIDare itsdocument, withtextan optional convenience. OneEncodingturns a request into the JSON a server receives, for the HTTP body and the socket's payload alike; the standard one writesqueryordocumentId, and the built-in transports take another. They also readcredentialsper attempt, from a closure. Under Relay'spersistConfiginbaton.jsonthe artifact carries the id, hashed withMD5,SHA256orSHA1, and no text, andbatonc generatewrites the map from id to text; without it the text and no id. Generated code of this shape is format 14. - A concrete type's lens under an interface or union sees the conditions
on the interfaces and unions its type satisfies:
... on Characterreads the fields... on Namedselected beside it, under that condition's@includeand@skip, as Relay's generated types give each concrete variant every field a matching condition selected. The set condition keeps its own lens for the types the document does not name. - A variable of an input object type takes a Swift struct generated for
the type, declared once per module in the shared file: a property per
field typed as the schema types it, an initializer with a parameter per
field, nil for a field left absent, and
variable, the object the request carries. A field name is checked by the compiler where it was checked by the server before. Generated code of this shape is format 13. - What may reach the image is configured.
baton.json'stransientblock names types whose records are never written and root fields, asQuery.search, whose cells, storage keys and fetch stamps never are, since those carry the arguments and variables they were asked with. A slot linking to a transient record is left out of its row; the next launch misses on it and fetches. Memory is unaffected. The lists join the schema's digest, so an image written under another list starts again. Generated code of this shape is format 12. - Client schema extensions, under Relay's key
schemaExtensionsinbaton.json: files, or directories of.graphqlfiles, that give server types client fields or declare types the server does not have. A client field is written bycommitPayloadfor an operation that selects it, read by a lens like any field, left out of the text and the id a server receives, and neither waited for by the availability check nor healed. The plan marks each client field; the extensions fold into the schema's digest. A non-null client field is refused at its line, and an operation of client fields alone at its name. The SwiftPM plugin regenerates when an extension changes. Generated code of this shape is format 11. - A schema enum reads as a Swift enum generated for it, declared once per
module in the shared file: a case per value the build knows, spelled as
the schema spells it, and
unknown(String)for a value it does not, so a schema's growth never fails a read. The conversion cannot fail, so the accessor keeps the schema's nullability; a null on a non-null field reads asunknown("")and is reported. Variables and optimistic responses of an enum type take the enum. A schema enum named like a fragment or an operation is a name error at the document; one named like a shared enum or a standard library type takesEnumafter its name. Generated code of this shape is format 10. - A custom scalar reads as the Swift type
baton.jsonmaps it to, under Relay's keycustomScalarTypes:"Decimal": "Foundation.Decimal". The type conforms toMappedScalar, an initializer from the scalar's text and the text back;Decimal,Date,URLandUUIDconform, each with one format. The store keeps the text; the accessor converts at the read and says the conversion can fail: optional wherever the schema puts the field, non-optional and throwing under@requiredor@throwOnFieldError, aResultunder@catchwhose failure carries the conversion's error. A value that does not convert is reported as unexpected and never reads as a zero. A variable of a mapped type takes the Swift type and is sent as its text. Generated files import Foundation. Generated code of this shape is format 9. - A lookup takes several arguments.
baton.json'slookupsname the arguments that carry a composite key,"arguments": ["base", "quote"], one per field of the type's key in its order;argumentstays the spelling for a key of one field. The compiler refuses a lookup whose arguments do not match the type's key, and a lookup without a type probes only the types one value keys. The runtime composes the arguments' values into the record's key as the ingest composes the key fields, so a cachedQuotesatisfiesquote(base:, quote:)before it is fetched. Generated code of this shape is format 8. - Identity is configured.
baton.json'sidentitynames the fields that key a record of each type: adefaultlist,["id"]unless written, andtypesentries for a type or for an interface, whose implementers take it. A key is own scalar fields, in order, several of them for a composite key,Quote:base:quote, each value escaped so that no two lists of values meet; a key does not rename, since it is the values at the write. The compiler selects the key fields wherever the type is read, as it selectsid, refuses a configuration naming a field a type lacks or cannot key by, and the plan names each type's key; the ingest keys a record once every key field is read, looking ahead past a link as it did forid. A configuration other than the default joins the schema's digest, so an image keyed the old way is a miss and not a merge. What names a record by one value,@deleteRecord,@deleteEdge, a lookup without a type and the image's forget, reaches single-field keys only. Generated code of this shape is format 7. - An owner settles an
@includeor@skipcondition once, as it resolves a key with variables once. Generated code declares each condition as a constant,Guards.<variable>_<value>, that the plan's fields and the lenses' reads both name, and an accessor's read tests the owner's answer rather than looking the variable up by name. Generated code of this shape is format 6.Guardsjoins the names the compiler keeps: a fragment or operation of that name is an error when the module has a condition, a field of that name where its lens tests one, and a variable of that name where its operation tests one, as withSites; a nested lens that would take the name isGuardsLens. - A resolved variant carries the lists its walks need, made once at the
resolution: the fields a response is read by and the ones a complete
response must carry, the fields the availability check waits for, the
connections' client links the check walks for merged pages, and the
links the collector follows. A walk tests no field for what it is; a
field's origin, the server, a
@deferlabel or the client, is one attribute of it. - An anchor's third word is the record its fragment starts at. A fragment
spread enters its record as the anchor's origin, and a connection below
it, however many links down, paginates with that record's id, as its
fragment's query takes it; before, it passed the id of the record it
hung from, which a connection one link below its fragment's type got
wrong. Generated code of this shape is format 5. The test schema's
Notegainsauthor: Characterfor the fixture. - Membership comes from the response. Whether a type satisfies an
interface or union condition is a table by the type's number, filled
from the sets the build compiled and from what responses say in Relay's
__isXfields, which the plan now carries; a type condition in a lens is an array load where it was a hash per read. A record of a concrete type the build did not list, a type the schema gained after the build, takes its variant from the answers: the fields under each condition the response says it satisfies, with the ones every type reads, settled once per type and per set of conditions. A condition every compiled member satisfies is a condition still, since it says nothing of a type the build did not list. The image keeps what responses taught for the next launch. A lookup without a type probes the members the build compiled. Generated code of this shape is format 4; the image's format is 6. - The plan names the field that keys a record of a type,
id, and the ingest reads that key and knows no field by name; a refetch reads the owner's id from the slot its descriptor names. A storage key leaves a null argument out, as Relay's does:notes(after:null,first:2)isnotes(first:2), whether the null is a constant in the document or a variable given null; an argument that is an object or a list keeps its nulls inside. Generated code of this shape is format 3, and an image is format 5: both start again. The decision is A storage key leaves a null argument out. - A change set carries where each record's id starts in its key, so the
store makes the record from it and asks the registry nothing per record
it creates; an entity's key,
Type:id, is built in one place, and what names a record by a bare value, a deletion, a lookup or the image's forget, reads the value part the one way it was written. - The plan IR carries a field's and a variable's type as one recursive
shape, a named type or a list of a type with nullability at every level,
built once in the lowering for the reader side and the normalization
side alike. With it, a list whose elements the schema types nullable
reads as an array of optionals,
[String?], and a null element reads as nil where it was dropped before; a list of non-null elements reports an element it cannot hold, a null or a value of another type, once, as a scalar reports a value it cannot hold, and leaves it out. A variable typed as such a list is a property of the same shape. Generated code of this shape is format 2: code of format 1 fails to compile at its marker, with a message that says to rebuild. The decision is A list's null elements are typed as the schema says. spec/manifest.jsonlists the cases a runtime is held to: each a document, its variables, the responses in order, the store's dump after them, and what a generated lens reads at each of a set of paths, in a language-neutral formspec/README.mdspells out. The documents the manifest names are underspec/documents/, written from the generated code and checked against it. The oracle tests run the manifest's cases and read every row through the generated lens, beside the plan walk they ran before.- The keys a session renders from its variables, one per id looked up and
per cursor paged past, are numbered by the store that renders them, in
Keys, where the process's registry numbered them for its own life: the registry numbers the keys the build names, and nothing a session produces is kept in a table of the process. A text has one slot in a store: a rendering whose text the build names as a constant takes the constant's slot, and a constant the build names after the store rendered its text is adopted when the store next resolves, checks or commits: the two slots become twins, the records' values are copied across, and every write lands in both. A plan is resolved for a store,Plan.resolve(_:in:), and a change set made from the resolution is committed into that store; the image's writer names a row's slots through the committing store's keys.Slot.storageKeyis gone: a report names a slot throughStore.storageKey(of:). - The image sweeps its names with its rows: at a launch's first batch, after the rows that aged out go, the names no row uses any more are deleted and their ids used again, so the file's table of names is bounded by its rows, and the limit of 65,536 names, past which an image started again, is gone. The image's format is 4; a file of format 3 is a miss and starts again.
- The collector frees the keys a session rendered once nothing can name them: a resolution and a lens's scope hold the numbers they took while they live, an optimistic layer and a row waiting for the image keep theirs, and the rest go with the records the pass sweeps, their numbers used again for the next renderings, lowest first, and the image told to forget their names. A key a record's row was read under stays numbered while the image lives, as the record reads its row once; a root field's is freed with the rest and read from the image again. After a long session whose roots left, the store's table is the size it was at the start.
- The image's file is protected at creation:
Persistence(url:version:sizeLimit:protection:)and thename:form take aFileProtectionType, which Apple's SQLite gives the file and its write-ahead log; an image made under another class starts again. A file that cannot be taken, locked or full, is waited for, not discarded: the writer keeps its work for the next commit or read, where a lost batch marked the image behind memory and the next open emptied the cache, so every background refresh on a locked device would have emptied it. Work that outgrows 50,000 rows is dropped and the image starts again. A marker beside the file has the next open finish a removal a crash interrupted or a locked device put off; the log is deleted before the database file. - What the image was told to forget and has not yet dropped is kept behind the image's interface, as one value the store holds, where three sets of the store's tracked it.
- The image's row is one codec: the tags, the writing of a record's row and a root field's cell, and the reading back, in one type over bytes, where the writer was the disk's and the reader the hydration's. The disk keeps SQLite and knows nothing of the layout; a record's snapshot is the record's own type, so the record names the image no more.
- An image belongs to one store. It is made for the store and lives as long
as it: the environment's end closes it and gives the file back, and the
next environment makes its own, on that file or another. The count of
removals a store noted when it was made, which eight of the image's
functions took to fence a store from before a sign-out, goes: an ended
store commits nothing, which fences the same with no count.
removeAll()stays as the deletion of the file after the end, which is hygiene; what keeps one account's rows from the next is the image's identity, the account in its path or in itsversion. - An environment ends.
await environment.end()ends the session once and for good: it cancels every fetch and stream the environment started, drops the roots, clears every record and closes the image, giving its file back. An ended store commits nothing, checked at the one door a payload takes, so a response that lands after the end, a fetch awaited in a task of the app's own or a mutation the server applied, reaches neither memory nor the image; a handle still held reads.failed(EnvironmentError.gone)and tells its observers; every later call on the environment throws the same error. A store dropped without an end clears its records when it is deallocated, so records that link to each other are freed with it. - A list of links that only grew, a page appended or prepended to a
connection, drops no link, so it schedules no collection pass of its
own; a list that changed otherwise still does. The lifetime step's
numbers are in
BENCHMARKS.md: a pass over 50,000 records takes 2.8 ms, and a page costs about a millisecond more from the eleventh on, the pass that follows the root the release buffer pushes out. - The heal. A read that finds a slot the store never received, which a retargeted link leaves behind, marks the owning operation stale and refetches it if a holder allows the network, once per fetch of that operation; a field still missing after the heal's own refetch is reported as unexpected and healed no further. Missing data was reported and left to the next attach.
- The fetch policy is the holder's. An attach's policy stays with the
retention it makes, so a fetch the runtime starts later asks whether any
holder allows the network:
invalidate()no longer refetches an operation attachedstoreOnly.Environment.revalidate()refetches the retained operations that are stale or whose last fetch failed, where a holder allows the network, and marks nothing: for an app's return to the foreground or a connection regained. - The commit stamps an operation's age. Every server write dates its
operation, whoever asked for it: a handle's fetch, a refetch, a page,
Environment.fetchorcommitPayload, and a deferred response when its stream completes; the age is the root's, in the store, and a handle reads it asfetchTime. A query just written that nothing retains waits in the release buffer, as Relay's does. Data with no known age is stale wherever an expiration applies, in memory as it already was from the image: an operation never fetched whose data another operation brought refetches under astoreOrNetworkattach when a cache expiration applies. The store no longer names its environment: a lens fetches through its owner, and the phases a commit settles are told through a hook. - The store owns what keeps records alive. A root, an operation's selection
and the record it starts from, is the store's, with how many hold it;
retain()returns aRetention, a token whose end releases, andrelease()is gone:@Query's storage holds the token for the view's life, and a model or a view controller holds one in a property. The release buffer isStore(releaseBufferSize:), andcollect(),rootCountandcollectionsare the store's;Environmenttakes no buffer size. The collector marks from the roots, the optimistic layers and the image's write queue, and from nothing else, and runs when a root left or a commit dropped a link: a screen that stays up and refetches is collected without another view going away, and a release that only moves a root into the buffer runs no pass. - A query states how old its data may be in its document:
@cacheExpiration(seconds:), the first directive that is not Relay's, which the compiler emits as a constant of the operation and leaves out of the text a server receives. The handle reads it with the operation's age; an operation that states none takesStore(cacheExpiration:), the default given when the store is made. The settableEnvironment.queryCacheExpirationis gone: a handle has no one owner, so a number set at run time was whoever set it last. The directive is refused on a mutation or a subscription, and with a variable. - A response that omits a field the operation selected fails the fetch as
malformed, an
IngestErrornaming the field and its type, where before the handle settled ready and the first read of the field reported missing data: a server answers every field it was asked for, so the plan was not made for the response. A deferred field is expected in its part, and an optimistic response or a payload committed by hand may carry part of the selection, as before. Environment.commitPayload(operation, payload)commits a payload for an operation that some other road delivered, a REST response, a socket's tick, a preview's fixture or a test's seed, through the same door a fetch's response takes: the records merge, the connections and the edge directives apply, and the image is written. A payload may carry part of what the operation selects.- One door from a payload to slots. A query's fetch, a deferred stream, a mutation, a subscription's event and a page each normalized and committed on their own; every payload now passes through one function of the environment, which reads it by the plan off the main actor and commits it as a server batch, and each entrance keeps only its rule for a caller cancelled on the way: a query checks before the commit, a mutation does not, since the server applied it, a subscription commits until its task ends. An ended store will refuse at that door, the commit will stamp the operation's age there, and a report will be raised there.
- The ingest returns what it normalized and no more: a change set holds what the store writes, and the parts an incremental response announces, whether more follow, and the errors of a part the server could not deliver are read beside it rather than carried through the commit. The assembly of a stream of parts into change sets, by announced id or by path and label, leaves the environment's fetch for a delivery of its own; the socket's frame reader leaves the ingest for the transport.
- Nothing outside a batch writes a record. A commit is a batch of one of three kinds: server, written to the image; optimistic, whose undo stays with its layer; and local, the runtime's own writes, a page's loading flag, a lookup's link bound, a link repaired, a cell filled from the image, a deferred slot cleared, which were written one by one and notified as they happened. The availability check's writes are one local batch notified once the walk is over, so an observer that asks the store a question from inside a notification sees a whole walk, never one in progress; the store no longer keeps the walk's state to guard against that. The connection merge and the edge edits are functions over a batch in a file of their own.
- A subscription handle's stream is a value beside its events.
streamreads.idle,.connectinguntil the first event,.open, or.ended(failure), withnilfor the server's completion and aFailurefor an error that ended it;isActiveis connecting or open. A subscription has no loading, since it has no data of its own to wait for, so this is not a phase. - An error keeps its
extensions.FieldError.extensionsis the server'sextensionsfor the error as a JSON value (Variable), kept in memory and in the image, so an app branches on the server's code and never on its message;GraphQLErrors.errorscarries a request error's errors whole, withmessagesstill readable. The image's row format moves to 3, so an image written before starts again, as a cache does. - A handle's fetch is a value beside its phase.
fetchon a query value and onOperationHandlereads.idle,.inFlight, or.failed(failure, at:)with the failure and when it failed, so a fetch that fails behind data is seen while the phase stays ready, by every view of the handle; the next response replaces it.isRefreshingis data present and a fetch in flight. The failure is aFailure, one of a closed set of kinds: the transport's, carrying what the transport threw unchanged; a request error (GraphQLErrors); a malformed response (IngestError); the environment's (EnvironmentError). Field errors are not among them. Whatphase's failed case andrefetch()carry is the error as thrown, as before. Deriving the phase when it is read was decided behind a gate, and the gate failed on the bench, so the phase stays stored.
0.7.0 (Split Time) — 2026-10-05#
The ground before the spine: the module's boundaries held by a check, the compiler refusing what the runtime cannot hold, the plugin telling the truth about its inputs and outputs, and the numbers the next steps are measured against, taken before any of them moves anything. The first release that publishes the compiler's bundle, so a package can depend on Baton by its tag.
- The bench suite measures what the next steps move, so that each has its
number before it moves anything: a collection pass over one root that
reaches 50,000 records, over 300 roots, and the pass that clears a store
of 50,000 records because no root is left; the re-evaluation a commit
runs today for a retained
@throwOnFieldErrorhandle, against the verdict a phase read would compute in a body's own tracking scope; and a commit with the three report closures set. The numbers are inBENCHMARKS.md. No iPhone 12-class device was at hand for the suite, and the three decision records whose reopening lines wait for one now say so. - Small truths.
Persistence(name:)resolves under the app's bundle identifier, or the process's name when it has none, so two apps on a Mac that both name their image"Main"no longer share one file; an app upgrading finds an empty image at the new path, which is a cache's lot. The public error types areLocalizedErrors, solocalizedDescriptionshows the text they carry. Three names that never passed the terminology leave the public surface, to go with the steps that remove them:Environment.collect(),OperationHandle.settle()andOperationHandle.isComplete. The docs tell the truth again where they had not:onErrorisbaton.json's, not the environment's; a record's type-membership bits, configured identity and an introspection command are not built, and are marked so or dropped; the lookup entry links the decision that stands;baton.jsontakes Relay's key names, andrelay.config.jsonis not read. - The collector takes a root's entries with the records it sweeps. A root
field rendered from variables,
character(id:"7")or a page after a cursor, kept its entry on the root with a blank value after its record was collected, so a long session's root held one per id ever looked up; the entry now goes with the record, and writing a missing value to such a key takes its entry out the same way. The long-session bench looks up 50,000 ids, where it looked up 2,000, and reports what the process's table of keys grows by. - The hostile-name sweep is a check of the repository:
scripts/hostile-name-sweep.pytype-checks, one document at a time, the names of fragments, operations and refetch queries the corpus tests prove accepted but cannot compile, and every spread form of them, against the module just built. CI runs it in the Swift job; it is among the local checks. - The build plugin tells the truth about its inputs and outputs. The
compiler is among the build command's inputs, so a rebuilt compiler
regenerates; the compiler removes from its output directory the
generated files it did not write in this run, so a source renamed or
removed leaves none behind; and generated code and the runtime share a
format number,
Types.formatnamingBaton.Format1, so code of another format fails to compile at one line that says which side is behind, rather than at every line that names the runtime. - A field whose type is a list of lists,
[[Int!]!]!, is a compile error at the field. The plan says of a type that it is a list or not, so such a field was lowered to a flat list and read wrong; the refusal stands until the plan carries a type that can say the depth. A fragment spread that reaches the normalization program, which Relay inlines, is an internal error rather than a silent skip. RecordedTransportandSilentTransportmove toBatonTesting, a library product of the package for an app's tests and previews, so that neither ships in the app. A test or a preview that uses them addsimport BatonTesting.- The boundaries inside the runtime module are checked:
scripts/check-boundaries.sh, in CI and among the local checks, holds the module decision's rules (one file imports SwiftUI, one imports SQLite, the record, the plan and the ingest do not name the store, the store's files do not name the environment or a transport, and the runtime imports nothing else) as a ratchet whose list of tolerated violations can only shrink. SwiftUI's part of the runtime, the environment value, the storages behind the marker macros and theForEachinitializers, now sits in one file. - A custom scalar is its text: a string's contents, or the bytes of any
other token exactly as the server wrote it, so
1.50, an integer past 2^53 and an object or array all read back unchanged. Before, numbers were rounded throughDouble, and objects and arrays were stored as null. - Ingest errors instead of wrong values or traps: an
Intfield given a fraction, an exponent or a value outsideIntfails the response with anIngestError(it wrapped, rounded, or trapped), andInt.minreads. A null inside a list of scalars is stored as a null element; it failed the whole response. The generated readers, typed as lists of non-optional values, still leave such elements out. A\uescape cut short by the end of a string, or a high surrogate followed by an escape that is not a low surrogate, reads as U+FFFD; the first read past the string and the second trapped. - Storage keys are built from the arguments, not parsed from text. A
string argument holding
$(price(format: "$0.00")) was read as a variable, a lookup argument holding a comma was cut at it, and a list or input object with a variable inside was stored under its own text; an input object's keys were written unquoted and unsorted. Floats in keys are written as the runtime renders a variable. A lookup inbaton.jsonwhose argument the selection does not pass is a compile error. - Plans know types and conditions. The normalization plan was flat: every
field of every type condition was expected on every record, so the
availability check failed right after an operation's own response for any
selection on an interface or union, and the ingest bound a response key to
the first field of that name, storing a Location's
label: dimensionin itsname. The compiler now decides, for each abstract selection, the fields each group of concrete types reads, and turns@includeand@skipinto guards the plan settles once per set of variables; the ingest reads an object by its type's variant, and the check, collection and deferred parts follow the same variants. - Lenses follow types and conditions. A type condition on an interface
emitted
asNodebehind a test of the record's concrete type, so it was always nil; it is now tested against the set of types that satisfy it, emitted once in the shared file, and folds into the parent when every type the parent admits satisfies it. An accessor under@includeor@skipis optional and reads nil, reporting nothing missing, when its condition does not select;totalCount @include(if: $x)read 0 and reported missing data. A field selected twice, or a fragment spread twice, emits one accessor: the file did not compile. An aliased spread of a fragment on an interface or union is tested against the types that satisfy it; it was always nil. - A field no variables can select, such as one under
@include(if: $x)and@skip(if: $x)at once, is fetched and read under no variables; it was planned as always selected, so the check waited for a field the server never sends. A field the initial part and a deferred one both select is read from the initial payload by its own selection; the deferred copy could come first, and the initial fields under it were dropped. - One write path. Everything a batch does, field errors and deletion included, is in its undo log and its net notification: a failed optimistic write to a field no longer loses the server's error on it, an optimistic response that revived a deleted record no longer leaves it revived when it fails, and a server commit under a layer that deletes a record no longer fires every channel of it twice.
- A deletion is announced to the bodies that hold it. A body that read
only a list, or a connection's
nodes, kept a row for a record@deleteRecordremoved, because the slot holding the link did not change; the commit that changes whether a record is deleted now notifies every slot that links to it, in one pass over the store (2.2 ms for a commit that deletes one record from 8,965 on an M1 Pro, against 7 µs without the pass). A read of a deleted record's field reports nothing missing. - Identity is the key alone. The store indexed entities by bare id as well,
last created wins across types, and
@deleteRecord,@deleteEdgeand lookups without a type resolved through it: in the Rick and Morty dataCharacter:1,Location:1andEpisode:1coexist, and the index named the episode. The index is gone.@deleteRecorddeletes the one live record of any type with the id, and when several types have it deletes nothing and calls the newStore.reportAmbiguousIdentity(debug builds print);@deleteEdgedrops the edges whose node has the id; a lookup without a type probes the field's possible types with the same rule. A lookup the image cannot answer no longer leaves an empty record behind.Store.existing(id:)is removed. An interface is keyed by id when the types that implement it have one, as a union is. An object under an interface or union that is keyed by its path is a record per concrete type; before, a payload of another type at the same path wrote its fields into the first type's record. The image's format moved to 2, so an image an earlier version wrote is discarded at the next launch. baton.jsonis checked: each lookup's field, argument andtypeagainst the schema, and an unknown key is an error.- A superseded fetch does not commit. A response that arrived, or was still
being read, after a refetch replaced its fetch landed after the newer one
when the transport did not hear the cancellation. Ingest now runs in the
fetch's own task, so cancellation and priority reach it. A mutation's
request runs apart from its caller's task, and its payload commits
whoever stopped waiting, because the server applied it; with
URLSessionTransporta cancelled caller cancelled the request, and the payload, and an optimistic layer with it, was lost. refetch()on an operation value and on its handle isasync throws: a refetch that fails throws its error, and the data on screen stays. It was dropped before. Breaking: a call site needstry.phasechanges only when it changes. A fetch that changed nothing assigned an equal.readyand re-ran every body that read the phase; a ready handle now stays as it is, and so does a failure on the same field errors. A@throwOnFieldErroror bubbling operation's phase follows any commit that changes a field error, a null, a link or a deletion in the store, not only its own fetch, and a parked one is settled again when a view attaches it. Both read the same errors: the operation's own selection's, as Relay's reader of the operation does (an error inside a spread is the fragment's to weigh), and those its last response carried that no field holds..networkOnlyno longer sends a handle another view shows back to.loading, nor reads the store, and the image, to decide. A preload's fetch serves the first attach while its data is fresh; the attach made a second request when the preload had finished.- A field error under a parent the server nulled lands on that parent. The
walk that places an error left a
switchwhere it meant to stop, so an error atcharacter.origin.namewithoriginnull was stored oncharacter.name. An error keeps its whole path, and one with no path, or a path that names nothing selected, is kept inChangeSet.unplacedErrorsand counts as uncaught for@throwOnFieldError; before, it was dropped. - Generated code states its isolation:
Types,Slotsand every generated type arenonisolated, readers stay@MainActor, so a target built with the Xcode template's default of main-actor isolation compiles them (it failed onstatic let plan). - The compiler comes with the package. A package that depends on Baton
downloads the compiler bundle its release published, named by checksum in
Package.swift; a checkout that built its own withscripts/build-compiler.shruns that one (BATON_COMPILER=localorreleaseoverrides the choice). The release workflow builds the bundle for both Mac architectures and writes the release commit. - A handle outlives its environment: a view that releases its handle after the environment is gone no longer traps; the release does nothing.
- A damaged image is a miss, never a crash. A file damaged under the open connection could leave a read stepping a statement already finalized; a row with a name id past any table, a link that names no type, or lists nested in lists trapped or recursed without bound. Such a row is used as far as it reads.
- Each slot is its own invalidation channel. A record had sixteen, so a body woke for a change to a field sixteen slots from one it read; on the query root, where each field with arguments is a slot, a screen woke for root fields other screens fetched (four wakes of an unrelated root-field reader in the bench, now none). A tracked read costs about 620 ns against 560 on an M1 Pro; an untracked read is unchanged at 29 ns.
- A schema whose root types have other names, such as
QueryRootorquery_root, works: the compiler interns them by the names the store's root records have,Query,MutationandSubscription, as Relay's root record is a__Rootin any schema. Before, the root fields' slots belonged to the schema's type and were written into a record of another. A schema that renames a root and also has a type of that root's store name, or whose renamed root implements an interface or belongs to a union, is an error.Store(rootType:mutationType:subscriptionType:)is removed. - Reads never write. A lens read of a root field that was never fetched
resolved its lookup and wrote the link, notifying, inside the body that
read it. The availability check binds a lookup before a handle is ready,
as it did; a lens read of a missing link now reads nil and reports it.
The
lookup:parameters ofAnchor.linked,requiredLinkedandthrowingLinkedare removed. See the decision. - Readers say what they could not read. A
required*reader that finds a null reports it through the newStore.reportUnexpected, and one that finds no value reports the miss; both still return the zero value. A value of another kind than the reader's reads nil and is reported; it was nil silently. A@requiredfield the store never received is reported missing before its lens bubbles. A non-null link without a record reads one placeholder per type, so the fields below it report nothing a second time; a record was allocated per read. A@catchon a non-null list reports a null as the other readers do. - A field error inside a type condition,
... on Character { name }under an interface, counts for@throwOnFieldErrorand@catchwhen the record is of the type; the lens's error scan skipped the condition. - A
@requiredlink to a record@deleteRecordremoved is null, as every other read of the link is: the lens bubbles, aTHROWcollects the error, and an operation that bubbles to its root fails. The lens read a blank record and the operation stayed ready. - A connection's
nodesbuilds its lenses in one pass instead of an array of anchors mapped into a second one: 110 µs for 2,100 nodes against 124 µs on an M1 Pro. - A field selected on an interface or union reads through an
AbstractSlot, which resolves its key once per concrete type: 22 ns per untracked read against 56 ns, which took the registry's lock and hashed the key on every read. Thekey:readers ofAnchorare removed. - Keys with variables and fragment arguments are resolved once per owner,
Relay's fragment owner: the scope a lens reads in, which a handle makes
once and keeps. A root field with a variable argument rendered its key,
took the registry's lock and hashed it on every read, 232 ns; it reads
in 28 ns, against 25 ns for the untracked read of a field with a constant
key. A spread with
@argumentsbuilt two dictionaries per read and gave its child a scope that never compared equal to the last: 453 ns for the read and one variable of the child's scope, 45 ns now, and the spread alone makes its lens in 10 ns. AnAnchoris a record, an owner and the record it was reached from, and two are equal when those are the same objects. Breaking for code that builds anchors:Anchor(record:owner:parent:)replaces theparent:form, andbindingtakes the spread'sArgumentSite. - The ingest keeps the last value per field. An entity the response names at many paths was written once per appearance and the commit picked the winners on the main actor, taking the registry's lock per record; the ingest now groups the change set by record, one entry per slot, off the main actor. On an M1 Pro, a commit of the fixture's unchanged payload takes 152 µs against 182, a commit that changes one field 154 µs against 185, and the ingest 2.95 ms against 2.78. Placing field errors scans the record's entries instead of indexing every entry.
- A commit compares a list where it is stored before building the new one, so a list that did not change allocates nothing: the fixture's unchanged payload commits in 133 µs against 152.
- A plan resolves once where it can. A handle's fetch uses the resolution the handle holds, a selection with no variable below it resolves once and keeps the result, and each field's response key bytes and fixed key are taken when the static plan is built. Resolving the fixture's plan for another page takes 0.69 µs against 8.5 µs without the kept resolutions.
- A subscription frame, an incremental part's envelope and the
errorsarray are read by a scanner of the response bytes alone; each built the plan-driven cursor and its 48 scratch buffers. A 69-byte frame reads in 375 ns against 2.21 µs. - A small response costs what it is. The change set reserved room for 32,768 entries whatever the response, the cursor made 48 scratch buffers before reading a byte, and the change set the ingest returned was copied on its first append. Reservations now follow the response's size, a buffer is made when the walk first reaches its depth, and the change set is built where it is filled: a 64-byte mutation payload ingests in 2.4 µs against 4.5 µs.
- A floating-point number is read where it lies in the response; each one was copied into a new array first, and a number the plan skips was parsed.
- A mutation's root fields are keyed without their arguments. Each
distinct input numbered a permanent slot on
Mutation, named by the input's text; now a field is keyed by its name, or by its alias when it has one. The store dumps underspec/changed accordingly. The data amutatereturns is the latest payload of its field. - The multipart reader drops a preamble, which it returned as a first part, lets go of each part once its delimiter is read instead of keeping the whole response, and reads a part without headers. It scans the chunks it is given rather than a byte at a time: 978 KB of 20 parts in 16 KB chunks parse in 0.50 ms against 14.6 ms.
- The compiler emits
Types.schemaDigest, the MD5 of the schema's text, for an app to pass as its image'sversion: an image written under another schema is discarded. The version is the app's to pass, because generated constants are made on first use and nothing has made one when the file opens. - A deferred fragment the image holds only half reads absent, and its operation fetches. A record read from the image holds every cell of its row, a deferred fragment's link among them, while the records behind it may be gone, and the check passes over deferred fields: the fragment read present and empty. The deferred fields are now checked apart, in memory and then in the image; one whose records are not whole is cleared, unless the initial part selects the same field, whose data stays, and a store-or-network attach fetches while the initial part renders.
- An image that lost a batch, written in vain or dropped while the file could not open, is discarded at the next open; it served rows older than memory had known, a deleted record among them.
- An edit the store cannot make in memory makes the image forget what it
would have changed. An edge directive on a connection the store held
only in the image was dropped, and an insert into the empty record a
link had made wrote a connection of one edge over the image's; a
@deleteRecordof a record only the image held left it there, to come back at the next launch. The connection, or every record with the id, is dropped from the image and read as missing until a response gives it again, so the screen fetches. - A read of the image writes nothing first. The availability check wrote the writer's whole queue on the main actor before reading, and every launch's first reads queued stamps that the next check then wrote there. A batch being written lands before a read takes the file, and the records of a batch still queued, with those its root fields link to, are kept by the collector until written, so a read never meets a row older than memory held.
- Data read every launch keeps its age. A fetch time was kept only by the fetch that wrote it, so data an app read from the image at every launch without fetching went stale at every second launch; a launch that reads a fetch time now keeps it for the next.
Store.checksays where its answer came from:.memory,.imageor.miss. A handle took the image's part from a change in a global counter, which missed the root's fields and records an earlier check had filled, so data from the image without a fetch time could read as fresh.Store.hydratedRecordsis no longer public. Breaking:checkreturned aBool.removeAll()deletes the image's file. It queued deletes that a failed open dropped and a failed batch rolled back, and it kept the interned names, which hold argument values; work queued before it is dropped too. A sign-out releases the old environment's handles, removes the image and makes a new environment over it. A store made before the removal reads, writes and dates nothing in the image after it, so a response that lands late for the user who signed out reaches neither the file nor the next user.Persistence.close()writes what is queued and closes the file, so a new image can take it over. The new image counts as a launch, though the process is the same: the rows the closed one wrote that it does not read age out a launch sooner. A sign-out keeps one image: it removes it and hands it to the next environment.- Opening the image scans nothing. The rows no launch has read since the one before last were deleted at open, three scans the first frame waited for; a read now treats them as gone and the writer's first batch deletes them. A database of another kind at the image's path turns the image off for the process instead of being opened again every second. The generation moves once per image, not per connection, names are written with a plain insert, so a second connection that took an id fails its batch instead of renaming every row written with it, and an image past 65,536 names starts again.
- One
invalidate:Environment.invalidate().Store.invalidate(), which marked memory stale but left the image's fetch times, so data read back from the image counted as fresh, is internal and does both. - Diagnostics point where they are. An error in the schema is positioned
in the schema file; it printed
1:1. Related places print asnote:lines, and a message Relay writes over several lines prints on one. A state of Relay's programs the lowering relies on never meeting is an internal error at the place it was met, where it lowered into an empty plan. - A linked field named
type,self,protocoloranygets a nested lens withLensafter its name; it emittedstruct Typeorstruct Self. A selection named or aliasedanchororrecordID, which every lens has for itself, is a compile error at the name that asks to alias the field or to choose another alias. - Generated slots are nested per type,
Slots.Character.name; a type's name and a field's ran together, soA_b.candA.b_cwere bothSlots.A_b_c. - A variable named like a Swift keyword,
$whereor$in, is escaped in the operation value; it emittedpublic var where. - A directive Baton gives no meaning to is a compile error at the
directive, by place:
@inline,@relay(plural:),@relay(mask: false),@raw_response_type,@preloadableand@streamcompiled through Relay's transforms and did nothing, or, formask: false, read unmasked data that Baton has no word for. A marker holds exactly one definition of its own kind:@Fragment("query …")and two fragments under one@Querycompiled. - A persisted id is the MD5 of the operation's text as the app holds it.
The hash took the printed text with its trailing line break, which the
emitted
textdrops, so no text the app held matched its id. The ids change. onErroris decided at compile time:"onError"inbaton.json, sent with every operation the target compiles. UnderNULLthe fields the schema types non-null are typed by their semantic nullability, so an accessor no longer reads""or0for a field an error nulled.Environment.errorBehavior, a switch at run time that changed what the compiled types meant, is removed.- The default fetch policy is
FetchPolicy.default,.storeOrNetwork, as Relay's queries default to: the store answers when it can. It was.storeAndNetwork, spelled in five places, so every attach of every screen made a request. Breaking for code that relied on that default: name.storeAndNetwork.Environment.releaseBufferSizeis set at init and fixed after. - The data a mutation returns stays readable. Nothing kept its payload alive, so after the next collection it read nil; the environment now keeps the completed mutation as a root, and its data lives until later mutations push it out.
- Kinds are types. An operation value conforms to
Query,MutationorSubscription, each refiningOperation, and each API takes only its kind:handle(for:),preload,fetchand@Queryqueries,mutateand@Mutationmutations,subscriptionHandleand@Subscriptionsubscriptions. A@Queryholding a mutation compiled, ran on attach and wrote the mutation root's slots into the query root. A subscription value carries its handle as a query value does; it found it in a table of the whole process, keyed by the value, which equal values in two environments shared. Breaking:OperationKindandkindare removed, and code generic over operations names the kind it needs. - A subscription survives a bad event. An event with errors and no data
ended the subscription for good; it now sets
errorand the stream goes on, and the next good event clears it.retry()opens a stream the server or the socket ended. A stream that a newer one replaced no longer closes the newer one's state when it ends. The WebSocket transport reads anerrorframe's GraphQL errors as the messages, where it showed the frame's text, and closes the socket when its last subscription ends. - Incremental delivery reads all of the format. A part's
subPathplaces its data below the announced path, its ownerrorsland on the fields they name, and an announced part thatcompletedwith errors puts them on the fields it would have filled, where@catchreads them; all were skipped. A part withhasNext: falseends the stream, where the fetch waited for the connection to close. Later parts are parsed and normalized off the main actor, and the first part's announcements are read in the pass that ingests it rather than parsed a second time on the main actor. - A deferred response is fetched when its stream completes, and its fetch time is stamped then; it was stamped at the first part, as if the whole response had come. A stream that breaks after the first part leaves no fetch time, and the deferred parts it lacks make the next store-or-network attach fetch again; the first part still renders at once.
URLSessionTransportreads an incremental response from its data task's own delegate, in the chunks the loading system delivers; it iterated the body a byte at a time. The bench's 978 KB response reads in 1.2 ms against 6.1 ms.@deferin a mutation or a subscription is a compile error. It compiled, and the response was read as one part.- The ingest takes a type's name once per selection rather than from the registry, under its lock, for every entity and path key, and reads a list of links into a buffer kept per depth rather than a new array per list.
- The compiler reads a marker qualified by the module,
@Baton.Query, and raw string literals (#"""to"""#), in which only a backslash followed by the literal's hashes is an escape. A bare@Querywhose first argument is not a string literal, such as SwiftData's@Query(sort:)or@Query(FetchDescriptor<Item>()), is that other macro and is left alone; it was an error. The other markers and@Baton.Querystill want a literal. The plugin hands the compiler every file that names a marker. - A
.graphqlor.gqlfile in a target writes its own output; its documents compiled and their lenses were never written. Outputs are named by the source's path in the target, soThing.swiftandThing.graphql, or two files of one name in two directories, no longer write one file;batonc generate --outnames them the same way. ABaton.swiftat the target's root that declares GraphQL, whose output would be the sharedBaton.baton.swift, is an error naming it; the shared file overwrote its output, or the build failed on two producers of one file. A document with an error writes nothing, where every output was overwritten with a stub, and a file holding GraphQL that no output is named for is an error. - Each
batonccommand takes only its own options, and any other is an error naming the ones it takes:--schem xwas ignored, and the schema then came from wherever else it could. - The check that a marker's property is typed as its document's generated
type finds the definition by its file and its place among the file's
documents. It searched the document's text for the longest known name, so
a query that spread
HomeDetail_characterwas taken for the operationHomeDetail. A subscription's property typed.Actionis now warned about; only a mutation's may be. - A parked
@throwOnFieldErroror bubbling handle that was ready is settled again when a view attaches it. A commit made while it was parked that put a field error or a null into its selection left it ready; only a failed one was settled. - The first part of a deferred response settles the phase by the errors
with no path that part carried. It read the last response's, so a
@throwOnFieldErroroperation whose first part carried one rendered ready until the stream completed, and stayed ready when the stream broke after it; one whose last response carried one stayed failed until a clean response completed. - A
@throwOnFieldErroroperation failed by an error with no path fetches again when a view attaches it understoreOrNetwork. No record holds such an error, so no commit could clear it, and the failure stayed untilretry(). A failure on field errors or a@requirednull, whose data is in the store, goes stale as ready data does, soinvalidate()and the expiration refetch it, and a refetch that fails at the transport leaves it as it leaves ready data;isStalewas false for every failure. Environment.fetch(_:)of a@throwOnFieldErroroperation throws the field errors its handle fails on. It threw for every uncaught error the response placed, one inside a spread among them, so the fetch threw where the handle was ready.- A stream a retry replaced leaves the new one's
erroralone. A bad event the old stream was still reading whenretry()ran set its errors on the handle, and the new stream showed them until its first good event. - A subscription whose transport ends its stream with a
CancellationErrorof its own ends:isActiveis false and the nextretain()opens it again. The handle stayed active over a stream that had ended, and no retain could reopen it short ofretry()or a full release. - Completed mutations take no place in the release buffer. Each one
took a place of its own, so ten mutations pushed out the query of a
screen the user had left, and going back to it loaded and fetched again.
The environment keeps them apart, one per operation value and as many as
releaseBufferSize, and one pushed out is collected at once; its records stayed until some unrelated release scheduled a collection. - A subscription that opens the WebSocket just after the last one closed
it keeps its socket. The closed socket's read, failing as it closed,
ended whichever socket was current, and the new subscription failed with
a cancellation. Each socket's frames and end now concern that socket
alone, a socket is not closed while a subscription is opening it or
starting on it, and the one that opens it no longer waits for an
acknowledgement that came, or a socket that failed, while its
connection_initwas on the way. - A subscription whose reader goes away before the server acknowledges the connection leaves nothing behind: it stops waiting for the acknowledgement, and the socket closes when no other subscription is on it or starting. The socket stayed open with no subscription on it until a later one ended, and without an acknowledgement the subscription's start waited for good.
- A
@required(action: LOG)field below a placeholder logs nothing. Reads under a placeholder report nothing, since the non-null link above it was reported missing, but a LOG field there still toldEnvironment.requiredFieldMissing, naming the placeholder's key. - Floats read as JSON writes them whatever the locale. They were read by
the thread's locale, in place in the response, so under one with a
decimal comma
0.25read as 0,[1,5]as[1.5, 5], and a response cut off in digits was read past its end; they are read in the C locale now, and never past the number the scan found. - A deferred part the server could not deliver fails an operation the
same however many errors it sent. Each error after the first was
unplaced, so with two a part whose fields are all under
@catchfailed a@throwOnFieldErrorfetch and handle, and a spread's part failed a handle though the spread's fragment weighs its own errors. The errors count as uncaught now only when a field the part would have filled is under no@catch; a part with no field on its record's type leaves them all unplaced, where it dropped the first. - A fragment on the mutation type reads the payload. Its fields kept their arguments in their keys while the mutation wrote them without, so a mutation that spread it read nil; they are keyed as the mutation's own root fields are.
- Of two fields whose lenses would take one name, the second is checked
through its own lens. Its lens is numbered,
TypesLens2beside aTypesLens, butfieldErrorsandsatisfiednamed it again without the number and checked the first field's lens, so a field error or a missing@requiredfield in the second went unseen. - A linked field named
mainActor,doubleoroptionalgets a nested lens withLensafter its name, astypeandstringdo. Its lens hid the attribute on every accessor, theDoubleaFloatfield reads as, or theOptionala caught spread is wrapped in, and the generated code did not compile. The names held back are every type and attribute a lens spells unqualified, and Swift's own. - An edge directive's commit looks no key up by name. Each edit took the
registry's lock and hashed
edges,node,cursorand__connection_next_edge_indexseveral times; it now edits the connection by the slots its plans resolved, which the registry keeps under the connection's type. A directive that names a record no connection field made, or inserts an edge of another type than the connection's edges, leaves it alone; it wrote edges into the record, or an edge the connection's readers read by another type's slots. - The docs say what runs on the main actor: reads, commits, the availability check with its reads of the image, collection, and the normalization of an optimistic response. The vision, the store principle and the README said collection and all normalization ran off it; a decision record now says why they do not, with the numbers that would move the check and collection.
- A view's storage releases its handle as SwiftUI drops the view's state, on the main actor. It released it from a task started for each teardown, so the handle stayed retained, and a root, until the task ran.
- A request with nothing to send it fails with
EnvironmentError, which says what is missing: a view's environment, a lens's, the one that made a handle and is gone, or a subscription transport. It failed with aTransportErrorwhose status code was 0 and whose description read as an HTTP status. - An operation whose root a
@requiredfield bubbled to fails with aRequiredFieldErrorthat names it inoperationNameand says the root bubbled. Its path was the operation's name, so the error described a null field of that name. - A preloaded operation is settled on its first attach. When the preload's fetch had finished with fresh data, the attach returned before reading the phase again, so a commit that put a field error or a null into the selection while the handle waited for a view left it ready.
- A deferred part the server could not deliver lists each of its errors
once among the uncaught ones. Its first error is placed on every field
the part would have filled, and was counted once for each such field
under no
@catch, so a part of two of them listed it twice. - A spread alone under an aliased
@catch,... @alias(as: "x") @catch { ...F }, compiles whatever the fragment's error policy and reads the field errors in the fragment: a failure underRESULT, nil underNULL. It calledF.caught, which only a fragment with@throwOnFieldErrorhad, so the generated code did not compile for any other fragment, and under@catch(to: NULL)such a fragment's accessor threw instead of reading nil. - Every name the generated code declares comes from one allocator per
scope, which knows the names the scope spells unqualified and the
program's fragments and operations, and a name the document spells never
moves. A field aliased
asCharacterbeside... on CharacterdeclaredasCharacterandAsCharactertwice; the condition's accessor and lens take the next number,asCharacter2andAsCharacter2. A field named like a fragment or an operation the lens names, such astestNotes_character, got a lens that hid it; the lens takesLensafter the name. A spread's accessor named like a field takes the fragment's whole name. A connection'snodesreadEdges.Nodewhatever names those lenses took. An optimistic builder for a payload field namedtype,self,stringorsendablewasstruct Type, hidStringor conformed to itself; it takesResponseafter the name. A name a scope would still declare twice is an internal error naming both declarations, where the Swift did not compile. - A variable named
selfis a parameter, a property and a request variable of that name. The value's initializer,variablesandhashread the instance itself in its place, and the generated code did not compile. A variable namedhasher, orcommitin a mutation, compiles too:hash(into:)combined its own parameter in its place, and the action called the parameter for itscommit. - A plan's edge directive is an
Edit, as the change set'sChangeSet.Editit becomes, so "handle" means only the operation handle. Breaking for a plan built by hand:HandleisEdit,ResolvedHandleisResolvedEdit, thehandleproperties ofPlanFieldandResolvedFieldareedit, and.scalarand.linkedtakeedit:. - A mutation's action passes a variable named like a Swift keyword, such
as
$self, by its bare label: Swift warns about an escaped label at a call site, and a build that treats warnings as errors refused the generated code. - A key rendered from variables no longer widens the records of its type.
Each cursor and each id renders a key the process keeps, and keys were
numbered in one sequence per type, so a field first used after a long
session was numbered past all of them and every record given it made
room for each. In the bench's session of 2,000 lookups and 500 pages,
2,000 characters given such a field took 28.2 MB and 5.86 ms to commit;
they take 1.1 MB and 1.71 ms. A key the compiler emits as a constant,
with arguments or without, keeps its place among the type's slots; a
record keeps the rendered keys written to it in a list of its own
sorted by key, which a read of one searches by halves: a root field
with a variable argument reads in 31.6 ns against 28.8 ns, and the
newest of the session's 2,000 keys in 50.7 ns. A list of 5,000 rows,
each holding three fields with a variable, commits in 4.76 ms against
4.08 ms for three constants, and each row holds 96 bytes more. A key
keeps the kind it is first met as: a constant whose text was rendered
first, or that the image named first, is read through the search.
Slot.indexis negative for a rendered key, andRegistry.slotCountcounts both kinds. - One image in a process holds its file. A second
Persistencemade on a file another holds, under any spelling of its path (/tmpand/private/tmp, before the file exists and after), runs without the image, as over a database of another kind, and stops a debug build where it is made; it opened a second connection, which moved the generation again and could fail the first's batches on a name both interned.close()and the image's end hand the file over; a closed image whose file another has taken reads, writes and removes nothing there. RecordedTransport.requestsis read under the lockexecuteappends under. It was read without it, so reading it while a request arrived off the main actor was a data race.- A
TransportErrorwith no response behind it, from a WebSocket that closed under a subscription or aRecordedTransportwith nothing recorded for the operation, describes itself by what went wrong; it readHTTP 0: .... ItsstatusCodeis still 0, now documented as no response. isRefreshingis true while an operation that failed on field errors or a@requirednull, with its data in the store, fetches again, andretry()leaves such a failure in place, its data visible, asrefetch()does.isRefreshingwas set only behind ready data, so a view showing that data and the failure did not see the refetch;retry()showed loading over the data, and when its fetch failed at the transport the failure became the transport's, which no later commit could clear. A handle that shows loading is not refreshing: anetworkOnlyview that attached while a refetch behind ready data was in flight, and a fetch started behind it, keptisRefreshingtrue over a screen that showed nothing.- A name the document chose that the generated code needs is an error at
that name, which says what it clashes with and asks for an alias or a
rename: a variable named
variables, orresolutionin a query or a subscription; a mutation's payload field namedvariable, which its optimistic builder declares; an inline fragment@alias(as:)namesanchororrecordID, which every lens has; a field named or aliasedhasNext,hasPrevious,isLoadingNext,isLoadingPrevious,connectionIDornodesin a connection; and a fragment or operation namedTypes,SlotsorBaton, a refetch query among them. Each was an internal error without a position that asked to report it, and a fragment namedBatonhid the runtime's module from the generated code. A fragment or operation namedSwift,SelforAny, or like a standard library name the generated code spells (String,Int,Double,Bool,Optional,Result,MainActor,HasherorSendable), is such an error too: it hid that name from the whole module, and the generated code did not compile. So is a field namedTypes,Slots,AbstractSlotsorSiteswhere its lens, or a lens nested in it, reads through that shared enum, which the field's accessor hid; a variable namedBaton,TypesorSlots, orAbstractSlotsorSiteswhere the operation's lenses read through them, which the variable hid from the operation's code; and a mutation's variable namedoptimistic, which its action takes as a parameter of its own. A clash between two names the compiler chose stays an internal error. - A field named
Batoncompiles in any lens. A lens under@catchor@throwOnFieldError, a refetchable fragment's and a connection's named the runtime's module in expressions, which the field's accessor hid; a lens now names it only in types. - An operation's
textis a raw literal delimited by one#more than the longest run of them in the text. A document in a raw literal of two or more hashes can hold\#, as insearch(name: "\\#1"), which the literal of one hash read as an escape, and the generated code did not compile. - A schema type named
Baton,Type,ProtocolorAnyisBaton_,Type_,Protocol_orAny_inTypes, at its constant and at every reference, as inSlots, where a field namedAnyisAny_too.Batonreferred to itself in its own initializer and hid the runtime's module from the other constants, Swift readTypes.TypeandTypes.Protocolas metatypes, and Swift lets no member be namedAny, so the shared file did not compile. A field or a variable namedAnyis escaped where the generated code declares it, asTypeis. The shared file's sets of types areSwift.Set, which a type of the module namedSetno longer hides. - The
RequiredFieldErrorof a root that a@requiredfield bubbled to has the path of that field, the first required field that is null, ascharacter.origin, and says it: "TestRequiredOrigin: the @required field character.origin is null and bubbled to the root". The generated root lens, and each lens its check recurses into, hasmissingRequiredField, whichLensdeclares and which reads and reports whatsatisfieddoes;satisfiedis unchanged. - The store's writes, the ingest and the store's counters belong to the
package, not to an app:
Store.commit,commit(_:replacingOptimistic:),applyOptimistic,revertOptimistic, the availability checkcheck,optimisticLayers,existing,invalidationEpoch, the root records and their keys,ChangeSet,Ingest, the resolved plan (Plan.resolve,ResolvedSelectionand its parts),Store.count,Environment.collections,rootCount,unconfiguredandresolve, the handles'retainCount,MutationStateand the storage-key renderings ofVariableandVariablesarepackage. The tests and benchmarks reach them; an app writes through operations and reads through lenses. Breaking for code that committed or ingested by hand. - The interface generated code calls is SPI, and generated files open with
@_spi(Generated) import Baton.Anchor,Owner,Registry,AbstractSlot,DynamicKey,ArgumentSite, the plan types, the numbers insideTypeIDandSlot,Record.read,errorandis, andLens'sanchor,init(anchor:)and static checks are@_spi(Generated), as are an operation'splanand its flags; a lens'sanchor,init(anchor:),connectionandrefetchableare generated as SPI too. An app reads through accessors and cannot rebuild one fragment's lens as another's from another module. Theanchor,init(anchor:)andplanthat batonc generates for every lens and operation default to a trap, so Baton builds with library evolution, as a framework built for distribution builds it; a lens or an operation written by hand that leaves them out compiles and traps where it is read.Slot.storageKeyandTypeID.namestay public: the store's reports hand an app a slot and a record. Breaking for code that read a record, built an anchor or a lens, or named a plan: it needs@_spi(Generated) import Baton, as the tests and benchmarks have. - A mutation's payload field named or aliased
Batoncompiles. Its optimistic builder wrote each scalar asBaton.Variable(value), which the field's property hid in the builder and in every builder nested in it; a builder now names the runtime's module only in types and writes a scalar as.init(value). - A spread's accessor compiles where a member is named like its fragment:
a field aliased like the fragment it is spread beside, as
TestRow_character: name ...TestRow_character, a variable of the operation, or the accessor itself, which a fragment named in lower case, asfragment row, gives its name. The accessor spelled the fragment in expressions, asrow(anchor: anchor)androw.satisfied(anchor), which the member hid; it now builds the lens as.init(anchor:)and calls the fragment's checks through a local alias of its type. refetch(), and a connection'sloadNextandloadPrevious, compile where a field is named like the fragment or its refetch query, in the fragment's lens or in the connection's. They named both in expressions, asanchor.refetch(TestNotesPaginationQuery.self, TestNotes_character.refetchable), which the field hid;refetch()now reads the descriptor asSelf.refetchable, and the query, and in a connection the fragment, are named through local aliases of their types.- A mutation's payload field that is a list of floats or of booleans
compiles in its optimistic builder, and so does a variable of either
list type:
Variablehas initializers from[Double]?and[Bool]?. With those from[String]?, which lists of strings, IDs, enums and custom scalars take, and from[Int]?, every list of scalars a builder or a variable writes has one. - A variable that is a list of input objects, such as
$filters: [FilterCharacter!]!, compiles:Variablehas an initializer from a list of variables, which the operation value'svariablescalls. - A spread that binds sixteen or more of a fragment's arguments compiles, and so does the refetch query of a fragment that declares as many. The closure that binds them returned a dictionary whose type Swift inferred from the literal, which took twice as long with each argument, and Swift gave up on it at sixteen; the closure now states its type.
- A spread argument that is a list or an input object holding a variable
beside a constant, as
@arguments(ids: [$id, "2"]), compiles. The binding defaulted every item to null,.string("2") ?? .null, which Swift warns about, and a build that treats warnings as errors refused the generated code; only a variable, which the scope may lack, is defaulted. - A field, a selection, an argument or a variable named
rethrows,fallthrough,precedencegroupor_is escaped as Swift's other keywords are; the generated code declaredpublic var rethrowsandvar _, which Swift refuses. A mutation's action passes a variable named$_by an escaped label, since Swift reads a bare_:as no label. - A variable, or a field of a mutation's payload, named
awaitcompiles. Its value was read bare, as inself.await = awaitandif let await, where Swift reads the keyword; it is escaped wherever it is declared or read. - A mutation's variable named
$varor$letcompiles in a build that treats warnings as errors. Its action passed it by an escaped label, which Swift, 6.2 as much as 6.3, warns needs no backticks; at a call site onlyinoutand_take them. - A variable named
hashValue, orphase,isRefreshingorisStalein a query, a refetchable fragment's arguments among them, orsubscriptionin a subscription, is an error at the variable that asks to rename it. Each became a property of the value that took the place of the one the runtime gives every such value:isStaleread the variable where a view meant the handle's state, without a word when both wereBool, andhashValuestood besideHashable's, so the generated code did not compile. Swift tellsrefetch()andretry()from a property by the call, so variables of those names still compile. - A mutation's payload field named
fieldscompiles. Its optimistic builder collected the response in a localfields, whichif let fieldshid; the local takes a name none of the builder's fields binds,fields2. - A field or a variable named
Selfcompiles beside@throwOnFieldErrorand@catch. Thecaughtcheck they give a lens built the lens asSelf(anchor:), which the member hid from that lens and from every lens nested in it; it builds it as.init(anchor:). Where a body still reaches a lens's own static member throughSelf, as a refetchable fragment'srefetch()and a connection's members do, a field namedSelfin that lens or in one around it, or a variable of that name, is an error at the name. - A fragment, a query, a subscription or a refetch query named like a
Swift keyword, as
fragment classor@refetchable(queryName: "each"), compiles. Its name was written bare wherever it stood for a type,public struct classortypealias Query = each; a type a document names is escaped wherever the generated code spells it, as a property is. - A mutation named like a Swift keyword, or like what its action declares
or calls (
Op,callAsFunction,commitoroptimistic), compiles, and so does a mutation with a variable named like it, asmutation Favorite($Favorite: ID!). The action extendedMutationAction where Op == Favoriteand spelled the mutation in its signature and its call,Favorite(Favorite: Favorite), where the parameter, the action's own members or its type parameter took the name's place. It extendsFavorite.Action, so the mutation is named once, where only types are looked up, and its body writesOp.Dataandself.commit(.init(Favorite: Favorite)). - The spread of a fragment named from an underscore, as
..._hidden, compiles. Its accessor took the owner's prefix before the first underscore, which is empty, and declaredvar : _hidden; it takes the fragment's whole name,_hidden. - A fragment named
Datais an error at its name where an operation spreads it, and so is one namedActionorOptimisticResponsewhere a mutation does. Inside the operation value those names are its own types, so the spread's accessor read the fragment as the operation's root lens, which compiled and returned the wrong lens, or as the mutation's action or builder, which did not compile.
0.6.0 (Anchor Leg) — 2026-10-03#
The store outlives the process: an image on disk, written behind every commit, that a launch renders from before the network answers.
- Persistence.
Persistence(url:), orPersistence(name:)for a file in the caches directory, handed toStore(persistence:)or toEnvironment(url:headers:subscriptions:persistence:). The image is one SQLite file through the system's library: a row per record (flags, the type, then a cell per field: storage key, a tagged value, the field's error if it has one), the query root a row per field, and type names and storage keys interned by name, because slot numbers belong to a process. A commit of server data hands the records it changed to a writer, at the cost of an array retain each on the main actor; encoding and the transaction run off it. What a commit writes under optimistic layers is the server's values: a layer never reaches the image, nor do the mutation and subscription roots, a connection's loading flags, or the link a lookup wrote back. - Hydration. The availability check answers from memory first, as before, at
the cost it had. When memory cannot answer and the store has an image, the
same walk runs again holding the image's connection in one read
transaction: a record that lacks a field reads its row once and fills the
slots it lacks, a missing root field reads its own row, a link to a record
the collector swept is pointed at the live record of that key, a deleted
record comes back deleted, and a connection's client record comes back
with its merged pages. So an operation an earlier launch fetched is
.readywhen its handle is made, before the first body, and a screen the release buffer let go is read back instead of refetched. Memory wins wherever it holds a value. A lookup is satisfied by an entity only the image holds, when the lookup names its type.Store.hydratedRecordscounts the rows read. - Age survives a launch. The image stores when each operation last committed
a response; a handle whose data is complete and that has not fetched takes
its age from there, so
isStale,queryCacheExpirationandstoreOrNetworkmean after a launch what they meant before it. Data that had to be read from the image and has no fetch time is stale.Environment.invalidate()forgets the stored times, so an invalidation outlives the launch. - Unreadable is a miss. An image of another format, an image written under
another
version(the app's own cache version, for a release whose schema gives a field another type), a corrupt file and a file oversizeLimit(64 MB unless told otherwise) are deleted and started again; a row that does not decode is used as far as it reads. A database that is not an image is left alone and the store runs without one. A file that cannot be opened for any other reason, a device still locked among them, is tried again on the next use. - Lifetime by generation. Every row carries the launch that last wrote or read it; at launch, rows no launch has touched since the one before last are deleted. A record survives one whole launch unread, and no longer.
Persistence.removeAll()empties the image for a sign-out;flush()waits for what was committed to be written, for tests and for an app about to be suspended.- The Rick and Morty sample keeps its store on disk: quit and launch again, and the list is on screen before the request returns.
- A record's
type,keyandentityIDarenonisolated,entityIDis immutable, andValueisSendable: another thread may hold a record and read its identity. - The benchmark runs itself as a new process to time a launch, and the
release's numbers are in
BENCHMARKS.md: 898 records are in the store 1.8 ms after their handle asks when the file opened off the main actor, under 5 ms when the main actor had to wait for the open. A Mac, not a phone. - Known limits. A record a response writes before the image was asked for it
replaces its row, so fields only the image held are lost and fetched again
when a screen needs them. The image is bounded by generations and by
sizeLimitat launch, not by a size-ordered eviction. Reads from the image happen on the main actor, inside the check; a read that lands behind a write waits for it. One process uses an image. - Fixed: an entity whose
idarrives after a linked field is keyed by its id. Relay prints theidit adds to a selection last and a server answers in that order, so the ingest had settled such an object on a path key before theidcame: a second record for the entity, apart from the one every other operation writes. In the Rick and Morty sample the detail's episodes query moved the root link to that record, and the header read missing data until its own response arrived. The ingest now finds theidand the__typenameahead of the first child, in any order; the look ahead runs only for an object whose identity follows a link. Two recorded responses with theidlast joinspec/rickandmorty, and the tests read them through the detail's lenses over the list's records. - Fixed: a refetch or retry during a fetch no longer loses the handle's
fetch. The superseded fetch used to clear the handle's task and
isRefreshingas it ended, soisRefreshingread false with a request in flight,settle()returned early, the next attach started a duplicate request, and eviction could not cancel the live one. A fetch cancelled with an error of its own (URLSession reports the cancellation as one) could also put a loading handle in.failed. A cancelled fetch now leaves the handle to whoever cancelled it. - Fixed: a subscription value's handle is dropped when its last owner
releases it.
@Subscriptionstorage used to list the handle on every body and nothing took it off, so each subscription value an app ever showed kept its handle for the life of the process. The handle now lists itself onretain()and leaves on the lastrelease(), sosubscriptionalso answers for an owner that is not a view. - Fixed:
GraphQLTransportWebSocketgives every stream a subscription id of its own. It used to look up the subscription to end by operation name and variables, so with two equal subscriptions on one socket the end of one completed the other, which received nothing more and never finished. The transport has its first test over a real socket: the test target runs agraphql-transport-wsserver on the loopback interface. - The Rick and Morty sample's
baton.jsonsits beside the sample, like every other target's. The package root has none, so a target without its own gets the plugin's warning instead of the sample's schema. - The Swift emitter has golden tests.
cargo testcompiles the documents of the Swift test target as the build plugin does and compares the generated files byte for byte withcompiler/src/tests/goldens, and compiles them twice to hold the output deterministic. A change to the emitter or to a test document now arrives with its diff of generated code;BATON_BLESS=1 cargo testrewrites the goldens. - Continuous integration. A GitHub Actions workflow runs the gates on every
push to
mainand every pull request: the compiler'scargo fmt --check,cargo clippy -D warningsandcargo teston Linux; the package on macOS 26 with the current Xcode, every target with warnings as errors, the tests in debug and in release, and the library built for iOS; and the tests again on Xcode 26.0, the declared floor. The Swift jobs build the compiler from the checkout first.
0.5.0 (Baton Pass) — 2026-10-03#
Honest data on the wire: field errors stored beside their fields, Relay's
error directives in Swift's terms, @defer over the incremental formats, and
subscriptions.
- Field errors. The ingest reads a response's
errors, resolves eachpaththrough the plan to the record and slot it names, and the commit stores the error beside the field; a payload that answers the field clears it, and either change notifies the field. A plain accessor reads an errored field as null, as before;@catchreads the error; a cached read sees what the network read saw. A response withdata: nulland errors fails the fetch withGraphQLErrors; errors whose path leads nowhere in the plan are dropped. @required(action:). A required field reads non-optional. NONE and LOG bubble at the lens boundary, as Relay nulls the enclosing object: the accessor that produces a lens (a linked field, a spread, a list element, a connection node) produces nil when a required field in it is null, through a generatedsatisfied; LOG also reports the path throughEnvironment.requiredFieldMissing. THROW makes the field's own accessorget throws, raisingRequiredFieldError. A root whose required fields bubble fails the operation, since there is no null data.@catch(to:). RESULT makes the accessor aResult<T, FieldErrors>whose failure holds the field's error and every error below it, THROW-required nulls included; NULL keeps the optional type and reads errors as null.@throwOnFieldError. On a fragment, the spread accessor isget throwsand throwsFieldErrorsfor an uncaught error inside; on an operation, an uncaught field error puts the handle in.failed(FieldErrors)with the data in the store regardless. Under either, and inside@catch,@semanticNonNullfields read non-optional, as Relay types them.Environment.errorBehaviorsends theonErrorrequest parameter (PROPAGATE,NULL,ABORT) when set.@defer. An operation with a deferred spread asks formultipart/mixedand reads the parts as they arrive: the first commits and renders, each later part is normalized at the record its path names with the fields its label marks, and the availability check does not wait for deferred fields. Three shapes are read: the June 2023incremental[{data, path, label}], the 2024pending/incremental[{id, data}]/completed, and Relay's{data, path, label}per part. A deferred spread's accessor is nil until the fragment's fields are present. The schema has to declare@defer; a server without it gets Relay's "Unknown directive", which is the truth.- Subscriptions.
@Subscription("…") var live: NoteAddedSubscriptionexpands like@Query: the storage subscribes while the view lives and closes the stream when it goes; the handle exposesevents,latest,errorandisActive. Every event is normalized atclient:root:subscriptionand committed, so its entities merge and edge directives on a subscription payload work.GraphQLTransportWebSocketspeaksgraphql-transport-wsoverURLSessionWebSocketTask;SubscriptionTransportis the protocol behind it, passed asEnvironment(transport:subscriptions:). Transport.stream(_:), with a default that answers once;MultipartParsersplitsmultipart/mixedbodies however the bytes arrive.- The GitHub sample:
@catchon the repository lookup shows the server's reason for a missing repository; issue rows require an author with@required(action: LOG), so an issue without one is no row.
Tests: field errors land beside the field and a plain read, a @catch read, a
@catch(to: NULL) read and a cached read each see what they should; a
payload that answers an errored field clears the error and notifies; NONE
drops the list element, LOG reports the path, THROW throws at the read;
@throwOnFieldError throws at the spread and fails the operation while a
caught error does not; a semantic field reads non-optional; a response with
errors and no data fails with the messages; onError is sent; a deferred
fragment is absent after the first part and present after the second in both
incremental formats; a subscription's events commit and append through
@appendEdge and the stream closes on release; the multipart parser splits
parts at any chunking. The bench measures a payload with twenty field errors
and the cost of a @catch read and a satisfied check.
Deliberately not added: @stream; operation-level @catch (accepted, no
effect; @throwOnFieldError is the operation's policy); reconnection and
retry for the WebSocket transport beyond a clean error; extensions on
field errors; a sample screen for @defer or subscriptions, because neither
public API supports them (both ship on fixtures); the missing-data heal (a
refetch of the owning operation), still planned.
0.4.0 (Hand-off) — 2026-10-03#
Lists: Relay's connections with pagination, fragment arguments, @alias(as:),
and the declarative edge directives.
- Connections. A field with
@connection(key:)is read through Relay's handle key. The page lands under its server storage key as always; the commit then merges it into a connection record keyed by Relay's connection id (<parent>:__<key>_connection(filters)), asConnectionHandler.updatedoes: a page fetched without a cursor becomes the connection, one fetched after a cursor appends, one fetched before a cursor prepends, edges deduplicate by node,pageInfomerges per direction, and a page after a cursor that is no longer the end is ignored. The lens over the field exposesnodes,hasNext,hasPrevious,isLoadingNext,isLoadingPreviousandconnectionID; the loading flags are client fields on the connection record. Roots mark through the connection as well as through the page they fetched, so merged pages live as long as any root reaches the connection, whatever fetched them. - Pagination. A fragment with
@refetchable(queryName:)whose connection takesfirst/afterfrom@argumentDefinitionsgetsloadNext(_:)on the connection lens (loadPrevious(_:)forlast/before): the generated query runs with the lens's variables, the merged end cursor and the owner's id, as a fetch with no handle and no root; the count defaults to the argument's default. Every@refetchablefragment lens hasrefetch(). - Fragment arguments. A spread binds the target's
@argumentDefinitionsinto the child lens's scope over the parent's variables: the passed literal or variable, else the default, else null (Relay's fragment variables). A storage key with a fragment variable resolves against that scope; the normalization and the operation text have the arguments inlined, by Relay. - Edge directives.
@appendEdge/@prependEdge(connections:),@appendNode/@prependNode(connections:, edgeTypeName:),@deleteEdge(connections:)and@deleteRecordbecome edits the ingest records in the change set and the store applies after the entries, under the same transaction and undo log, so an optimistic insert shows at once, rebases under commits and reverts on failure.connectionsis a variable of connection ids (comments.connectionID). Inserted edges are copied into records the connection owns, numbered by Relay's__connection_next_edge_index, because a payload's edge record is keyed by its path and the next mutation would alias it. A deleted record reads as null through links, is skipped by lists andnodes, and tells every observer; a payload that names it again revives it. @alias(as:)names the accessor verbatim, for spreads and inline fragments; withoutas:the derived names stay.- Records size their values by what is written, not by the type's slot count: a cursor-paginated field registers a storage key per page on its parent type, and the other records of that type no longer pay for pages they never saw. The scroll bench's footprint fell from +6.5 MB to +4.4 MB.
Environment.fetch(_:variables:)fetches an operation by type without a handle.Anchorkeeps the record it was reached from and binds scopes withbinding(_:).ForEachtakes an array of lenses, fornodes.- The GitHub sample: open issues as an infinite-scroll connection on the
repository screen; comments as a connection with "load more"; the comment
composer appends through
@appendEdgeinstead of refetching the issue; the issue screen's spread is aliased. All four read operations were checked against the live API. - The compiler's property-type check takes the longest matching document
name, so
TestAddNoteFirst.Actionno longer warns aboutTestAddNote.
Tests: two pages merge in order with the last page's info and a stale page is
ignored; a refetch of the first page replaces the merged list and an equal
page notifies nothing; loadNext fetches after the end cursor, appends,
toggles isLoadingNext, is a no-op at the end and creates no root;
@appendEdge and @prependEdge insert once per node; an optimistic edge
shows at once, survives a page under it, is replaced by the server's edge and
reverts on failure; @deleteEdge removes the edge and @deleteRecord makes
the record read as null; @arguments binds the scope and defaults apply;
@alias(as:) renames. The bench merges 42 pages of 50 notes into one
connection.
Deliberately not added: @stream_connection and prefetchable_pagination;
refetch with new variables (replace the lens; watch list); null arguments in
storage keys (Relay omits them, Baton renders null; both sides agree, and
the format is internal until persistence); page-based lists (watch list);
@required, @catch and @defer (0.5).
0.3.0 (Exchange Zone) — 2026-10-03#
The write side: mutations as action values, optimistic responses as layers that rebase under every commit, abstract types, lookups by id, and a second sample against GitHub's API.
- Mutations.
@Mutation("…") var star: StarMutation.Actionexpands to an action value after SwiftUI'sdismissandopenURL: the compiler generatescallAsFunctionwith one labelled parameter per variable plusoptimistic:; it isasync throws, returns the mutation's data lens, andisInFlightis observable. Mutation payloads root atclient:root:mutation; the entities inside merge into their records as always, so a view reading a repository re-renders when a star mutation answers. - Optimistic responses. The compiler generates an
OptimisticResponsebuilder tree per mutation (every field optional, memberwise initializers). It renders to JSON and goes through the same ingest and plan as a server response, so optimistic data obeys the oracle rule and masking. The store keeps optimistic layers with undo logs; a commit while layers exist lifts them, applies the payload, re-applies them, and notifies only slots whose value differs in the end. The server's answer replaces its layer in one batch; a failure reverts it and the error is rethrown. - Abstract types. Selections on interfaces and unions key each object by
the payload's
__typename(the compiler adds it to every abstract selection; the ingest settles the key when the typename arrives, before or after the id). Lenses read such selections through the record's concrete type;asRepository-style accessors and conditional spreads work. Relay's rule applies unchanged: a spread inside an inline fragment on an abstract selection needs@alias. - Lookups by id across types. A lookup without a
type(Query.node) resolves through an id index the store keeps for every entity, sonode(id:)renders from the store for anything a list already fetched. - Per-target
baton.json: read from the target's directory first, then the package root, so one package holds the Rick and Morty sample, the GitHub sample, the tests and the benchmarks against three schemas. - The GitHub sample (
examples/GitHubTriage,GITHUB_TOKEN=$(gh auth token) swift run GitHubTriage): the viewer's assigned and authored issues and pull requests over theSearchResultItemunion, a repository screen with an optimistic star toggle, an issue screen with comments and reactions and a comment composer. Its schema has about 1,800 definitions and compiles in 35 ms. - The compiler renders constant arguments in storage keys as JSON, as
Relay's
formatStorageKeydoes (issues(states:"OPEN")), and escapes them in generated Swift; it accepts<Operation>.Actionand module-qualified property types. TransportErrorhas a public initializer, for transports and tests.
Tests: an optimistic response shows at once and is reverted when the server
fails; the server's answer replaces the layer in one batch and the mutation
returns its data; a server payload commits under a live layer and the layer
stays on top until it resolves; resolving or reverting a layer notifies only
the slots whose value differs in the end; objects behind a union are keyed by
their concrete type in either typename order; node(id:) finds a cached
entity by id across types.
Deliberately not added: the declarative edge directives (@appendEdge,
@prependEdge, @deleteEdge, @deleteRecord), which ship with
@connection in 0.4; imperative updaters; @alias(as:) renaming (the
directive is accepted, the accessor keeps its default name); typed input
objects (variables of input-object type are still Baton.Variable); a
mutation queue or offline retry (never, see the non-goals).
0.2.0 (First Leg) — 2026-10-02#
Lifetime: the store now forgets, on purpose and on Relay's terms.
- Retained roots. A
@Querystorage retains its handle while the view lives and releases it when SwiftUI drops the view's state. Released handles wait in a release buffer (Environment.releaseBufferSize, default 10, oldest out first); retained and buffered handles are the roots that keep records alive. - Collection. Mark and sweep over the roots' plans, coalesced into one pass
per batch of releases; swept records are cleared so cycles break, and the
root's links to them are dropped.
Environment.collect()runs it on demand. - Fetch policies, as
@Query("…", fetchPolicy:):storeOrNetwork,storeAndNetwork(the default),networkOnly,storeOnly. AstoreOnlyoperation without data fails withMissingDataError. - Staleness.
Environment.invalidate()marks everything stale and refetches retained handles while their data stays visible;queryCacheExpirationdoes the same by age;isStaleon every operation value. - Preload parks a fetching handle in the buffer; a view attaching within the
window finds the request in flight or the data present. Equal operation
values share one handle and never fetch twice at once;
settle()awaits the fetch in flight. RecordedTransporttakes a responder closure, for tests and benchmarks that serve many pages.- The compiler's scanner skips attribute arguments after the GraphQL literal.
Tests: re-entry within the buffer makes no request and eviction does; collection removes unreachable records and keeps shared ones; each policy's first-attach behaviour; invalidation and expiration refetch; preload. The bench scrolls 42 synthetic pages through a buffer of 10 and reports records, roots and footprint per page, plus the cost of a collection pass.
Deliberately not added: field-level invalidation, off-main marking, a
collection budget per pass, holdGC for optimistic updates (0.3).
0.1.0 (Starting Blocks) — 2026-10-02#
The vertical spine: one query through every layer, done properly, against the public Rick and Morty API.
- The compiler (
batonc, Rust): finds@Fragment,@Query,@Mutationand@Subscriptionliterals in Swift sources and accepts.graphqlfiles; validates against the schema with Relay's front end (graphql-syntax,schema,graphql-ir,relay-transformsat v21.0.1, pinned); reports errors at the exact character inside the Swift string literal; emits a lens struct per fragment and operation (one line per field), an operation value per query with its text, persisted id and normalization plan as static data, and a per-targetBaton.baton.swiftof interned types and slots. Lookups inbaton.jsonlet a root field such ascharacter(id:)be satisfied by a cached entity. - The runtime (
Baton, Swift): aRegistryof interned types and storage keys;Recordobjects that drive Observation through sixteen invalidation channels so a body re-runs only when a field it read changes; a main-actorStorewith synchronous reads, atomic commits that compare before they allocate or notify, and an availability check; a plan-drivenIngestthat decodes response bytes into an arena change set in one pass; aURLSessionTransport; anEnvironmentinjected as\.baton; operation values that resolve to anOperationHandlewith a synchronousPhase. - The SwiftUI surface:
@Fragmentand@Querymarker macros (@Queryis an accessor-plus-peer macro overOperationStorage, so a parent passes variables and a body reads the resolved handle),Baton.Listwith aForEachoverload keyed by record identity,phase,isRefreshing,refetch()andretry()on every operation value. - The build plugin: one
batonc generateper target with one declared output per Swift file that embeds GraphQL plus the shared slots file; outputs are rewritten only when their content changed. - The sample (
examples/RickAndMorty,swift run RickAndMorty): characters, character detail, episode and location screens over one store, with the list prefetching the detail header so a detail renders from the store in its first body. - Tests prove the two claims the release exists for: a character already in the store renders in the first body of its detail (through a lookup, with the network silent), and a commit that changes one field invalidates exactly one row. The response is the oracle: lens reads of the fixture agree with the raw JSON field for field.
- Benchmarks (
swift run -c release BatonBenchmarks) andBENCHMARKS.mdwith the first recorded numbers, including the comparison with Apollo iOS 2.4 on the same fixture.
Deliberately not added: mutations, pagination, retention and garbage
collection, persistence, @defer, abstract types, @required/@catch,
enums as Swift enums (they read as String for now), and lists of nullable
items (null items are dropped). Each has its release on the ladder.
Known shortcuts, honest about them: a detail screen that needs more than the
list prefetched is two operations rather than one with @defer; variables of
input-object type are passed as Baton.Variable; the fetch policy is
store-and-network only.
Baton