Repository navigation
Releases: shergin/baton
Release list
0.12.0 Palianytsia
- 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 through
gradle :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,MutationTypeor
SubscriptionTypenaming the operation's class beside its data, so
val rename = rememberMutation(RenameMutation)infers its action's type.
OperationTypeand the three are application API, outside the
baton.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.Retentionhid
kotlin.annotation.Retentionfrom every file that writes
import 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 throughAndroidSQLiteDriver,HttpTransportand
Environment(url)shared with the JVM, and
Persistence.named(name, directory = cacheDir.path), since the runtime
holds noContext.baton-inspectorbuilds for Android too; the
desktop sample's screens moved tokotlin/samples/shared, which
kotlin/samples/android, a phone app, shows as well; and
IngestBenchmark, a device test, logs the Fixture's ingest and commit
medians with the device's model for the ingest budget.
0.11.0 Karavai
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 compiler
BATON_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, and
docs/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--schema
beside--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_64
and 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@inline
fragment as a data class read once; a connection's state,loadNext
andrefetch. A mutation gets itsOptimisticResponseand a
suspend 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 from
spec/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's
fieldErrorsandmissingRequiredField, 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 aSubscriptionHandlethat reconnects by the
fixed backoff and parks while the environment is inactive; and loads a
connection's pages and refetches a fragment.Storeis public, and
Environment(transport, subscriptions, store)takes it, with adebug
flag 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)or
Persistence.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.
HttpTransport
posts overHttpURLConnection, reading a deferred response's
multipart/mixedparts and a subscription'sgraphql-sseevents
through the commonMultipartParserandEventStreamParser, credentials
per attempt, a request error answered as
application/graphql-response+jsonas itsGraphQLErrors;
GraphQLTransportWebSocketspeaksgraphql-transport-wsover the JVM's
java.net.http.WebSocket, one connection per transport; and
Environment(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
- 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.kt
withTypes,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's
GraphQLErrors, the request kind of failure, where it threw a
TransportErrorwith 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 any
application/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
sent
expectation 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 asnull; 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
Payload, bytes in a response's shape, is what the door takes:
commitPayloadtakes aPayloadwhere it tookData, and
mutate(_:optimistic:)and a mutation's action take one where they took a
Variable; a mutation'sOptimisticResponsebuilder renders its
payload, 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 in
docs/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's
phaseis 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 in
docs/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 under
spec/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, and
spec/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 spelled
id. 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's
customScalarTypes, 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 in
docs/decisions/a-fragment-has-one-reading.md.
0.8.0 Back Straight
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 aspartDroppedwith 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 indocs/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 --counts
prints 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 in
docs/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; and
docs/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 each
LogEvent, 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 Bazel
genruleover 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 repor...
0.7.0 Split Time
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, solocalizedDescription
shows 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()and
OperationHandle.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, and
relay.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 adds
import 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, so1.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 an
IngestError(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'slabel: 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
emittedasNodebehind 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'snodes, 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 data
Character: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 w...
0.5.0 Baton Pass
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 eachpath
through 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 through
Environment.requiredFieldMissing. THROW makes the field's own accessor
get 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 throws
and 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/mixed
and 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,errorand
isActive. Every event is normalized atclient:root:subscriptionand
committed, so its entities merge and edge directives on a subscription
payload work.GraphQLTransportWebSocketspeaksgraphql-transport-wsover
URLSessionWebSocketTask;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.
Numbers for the error bench are in BENCHMARKS.md.
0.4.0 Hand-off
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.update
does: 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
@argumentDefinitions
into 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 with
binding(_:).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, soTestAddNoteFirst.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).
Numbers for the connection bench are in BENCHMARKS.md.
0.3.0 Exchange Zone
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 generates
callAsFunctionwith one labelled parameter per variable plus
optimistic:; it isasync throws, returns the mutation's data lens, and
isInFlightis observable. Mutation payloads root at
client: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
OptimisticResponse
builder 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, so
node(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 theSearchResultItem
union, 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'sformatStorageKeydoes (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).
Numbers for the write cycle are in BENCHMARKS.md.
0.2.0 First Leg
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. AstoreOnly
operation without data fails withMissingDataError. - Staleness.
Environment.invalidate()marks everything stale and refetches
retained handles while their data stays visible;queryCacheExpiration
does 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).
Numbers for the scroll bench are in BENCHMARKS.md.