Baton

Recipes

Porting from Relay

Relay's words beside Baton's, in Swift and in Kotlin; what differs on purpose; what is not ported and why.

Baton keeps Relay's words and directives, so a Relay app ports by spelling, not by rethinking: a fragment stays a fragment, a connection a connection, @required and @catch mean what they mean. What differs is what Swift, Kotlin and a native runtime make better, and what Baton leaves out on purpose. The table gives each word in Swift and in Kotlin; each "not this" below links its reason.

The same words#

RelayBaton, in SwiftBaton, in Kotlin
graphql\fragment X on T {...}`anduseFragment`@Fragment("fragment X on T {...}") var x: X, a lens: a typed, read-only view over one record, synchronous on the main actor@Fragment("fragment X on T {...}") on the composable that takes x: X; the same lens, synchronous on the store's thread
useLazyLoadQuery@Query("query Q {...}") var q: Q in a view; q.phase is .loading, .ready(data) or .failed(error)@Query("query Q {...}") on the composable and val q = rememberQuery(Q(...)); q.phase is Phase.Loading, Phase.Ready(data) or Phase.Failed(error)
useMutation and commitMutation with optimisticResponse@Mutation("mutation M {...}") var m: M, called as an action, or environment.mutate(M(...), optimistic:)@Mutation("mutation M {...}") and val m = rememberMutation(M), called m(..., optimistic = ...), or environment.mutate(M(...), optimistic)
useSubscription@Subscription("subscription S {...}") var s: S; the handle reconnects by a fixed backoff and parks while the app is inactive@Subscription("subscription S {...}") and rememberSubscription(S(...)), the handle, which reconnects and parks the same way
usePaginationFragment, @connection, loadNext@connection on the fragment, @refetchable(queryName:), and loadNext(count) on the lensThe same directives; loadNext(count) on the lens, a suspend function
useRefetchableFragment@refetchable(queryName:) and refetch() on the lensThe same; refetch() is a suspend function
@required(action: NONE / LOG / THROW), @catch, @throwOnFieldErrorThe same directives; @catch reads as Result, @required(action: THROW) and @throwOnFieldError fail the phase, LOG logs through the environment's logThe same; @catch reads as kotlin.Result, THROW's getter throws RequiredFieldError, and the phase and the log as in Swift
@defer@defer; the first part renders, isPresent says whether the rest arrived@defer; the spread's getter is null until the rest arrived, which the companion's isPresent(anchor) says
@inline and readInlineData@inline; the fragment compiles to a Sendable, Hashable struct, and the spread's accessor on the parent's lens builds it when called, on the main actor@inline; the fragment compiles to a data class, built by the spread's getter when called, on the store's thread
@argumentDefinitions, @argumentsThe sameThe same
RelayEnvironmentProvider.environment(\.baton, environment)CompositionLocalProvider(LocalBaton provides environment)
Environment with Network.create(fetch, subscribe)Environment(transport:subscriptions:); a transport has one verb, send, yielding a stream of payloadsEnvironment(transport, subscriptions); send returns a Flow of payloads
persistConfig and persisted-queries.jsonpersistConfig in baton.json; an operation carries its id, the file is written by the buildThe same; the Gradle task's persisted property places the file
commitPayload(operationDescriptor, payload)environment.commitPayload(Q(...), Payload(data))environment.commitPayload(Q(...), Payload(bytes)), a suspend function
RecordSource persisted by handPersistence(name:version:): an image on disk the launch renders fromPersistence.named(name, version = ...) or Persistence(path), handed to Store(persistence); on Android under the app's cacheDir
environment.getStore().invalidateStore()environment.invalidate(); revalidate() refetches what is stale when the app returnsThe same
fetchPolicy: 'store-or-network' and friendsFetchPolicy on handle(for:fetchPolicy:): storeOrNetwork, networkOnly, storeOnly, storeAndNetworkFetchPolicy on rememberQuery(operation, fetchPolicy) and handle(operation, fetchPolicy): STORE_OR_NETWORK, NETWORK_ONLY, STORE_ONLY, STORE_AND_NETWORK
log: LogFunctionenvironment.log, a closure of value-free LogEventsenvironment.log, a function of value-free LogEvents
relay-compilerbatonc, Relay's front end behind a Swift emitter; a build plugin runs it, or the command line doesbatonc with its Kotlin emitter; the Gradle plugin com.shergin.baton runs it

Different on purpose#

  • Missing data heals itself. Relay suspends on missing data; Baton's lens reads nil, records the miss, logs it and refetches the operation that owns the record. Nothing in a view waits.
  • Identity is configured, not assumed. id keys a record by default; types keyed otherwise, including composite keys and interfaces, are named in baton.json, and the compiler asks the server for the key fields.
  • Custom scalars read as the app's types. customScalarTypes maps a scalar to Decimal, Date, URL, UUID or the app's own MappedScalar, as a string or under swift in an object by language, and under kotlin to a Kotlin type with the ScalarConverter object for it; the conversion happens at the read, and a value the type cannot hold is a field error.
  • Enums are generated, with a case for a value the build did not know: Swift's unknown, Kotlin's Undeclared.
  • Input objects are generated values, a struct in Swift and a data class in Kotlin, typed from the schema.
  • The session is the environment. Signing out is await environment.end(), a suspend call in Kotlin, and a new environment; there is no replaceStore.
  • Client fields are described and committed, through schemaExtensions and commitPayload; there are no resolvers and no commitLocalUpdate.

Not ported, and why#

RelayWhy notWhat reopens it
Resolvers, live resolvers, commitLocalUpdate, @updatable, @assignableA second way to write and logic in the store; a payload for an operation says every writeA write no payload for an operation can say
@module, @matchCode splitting is a bundler's concernnone
Reader snapshots, seenRecords, store subscriptionsObservation and Compose's snapshot state do what they do, per fieldnone
Suspense, the missed-update epochReads are synchronous; the heal refetchesnone
Generator-based GCA pass runs whole on the store's thread, once a turn after a root left or a commit dropped a link: 2.8 ms over 50,000 records (why)A pass that misses a frame
invalidateRecordAges belong to the operation, not the recordA product that must mark one entity stale
@stream, @stream_connectionNo server in use emits themA server in use that does
Imperative store updatersDirectives and payloads have expressed every write so farA real write neither can express
Automatic persisted queriesThey need text and id at run time; the working group dropped themnone

Relay's docs stay the reference for the directives' meaning. Baton's terminology is the contract for every word above.