From 85c38cc3bd93d753e014a79f57858627d288a7aa Mon Sep 17 00:00:00 2001 From: Valentin Shergin Date: Sun, 18 Oct 2026 19:22:37 -0700 Subject: [PATCH] Add four recipes for a public API's habits A budgeted transport, a Json scalar read as a value, fields a server nulls by path, and an unpaged collection into the image, as #52 asks. The code was compiled in both runtimes against AniList's schema in scratch hosts, and the budget run over a scripted base transport. --- CHANGELOG.md | 25 ++ README.md | 10 + docs/README.md | 11 +- docs/recipes/budgeted-transport.md | 485 +++++++++++++++++++++++++++++ docs/recipes/fields-by-path.md | 174 +++++++++++ docs/recipes/json-scalar.md | 186 +++++++++++ docs/recipes/unpaged-collection.md | 263 ++++++++++++++++ site/src/pages.rs | 24 ++ 8 files changed, 1176 insertions(+), 2 deletions(-) create mode 100644 docs/recipes/budgeted-transport.md create mode 100644 docs/recipes/fields-by-path.md create mode 100644 docs/recipes/json-scalar.md create mode 100644 docs/recipes/unpaged-collection.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1578cec..22905f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ Notable changes, written so a person can read them. Pre-1.0, breaking changes are expected and listed without apology. +## Unreleased + +- Four recipes for a public API with habits of its own, asked for on + [#52](https://github.com/shergin/baton/issues/52), each in Swift and + Kotlin and compiled against AniList's schema. None is a new concept. + `docs/recipes/budgeted-transport.md` is a wrapper around the one verb + that holds one request in flight, sends a write before a refresh before + a browse page, meters the sends under the server's limit, pauses every + request for the whole minute after a 429, and turns AniList's outage 403 + into an error of the app's, which a handle's fetch carries beside the + data while the view shows a calm line. The built-in transports do not + keep a refused response's headers, so `Retry-After` is not read. + `docs/recipes/json-scalar.md` reads `customLists(asArray: true)`, an + array of objects, as a mapped type, notes that `customScalarTypes` maps + a scalar and not a field, and keeps a map a type of the app's, never a + dictionary on the read path. `docs/recipes/fields-by-path.md` is what a + list fragment selects when a server answers a field differently by the + path that reached it, where path-dependent and viewer-dependent fields + go, and a probe that tells the two apart. `docs/recipes/unpaged-collection.md` + is `MediaListCollection` as one operation with a long + `@cacheExpiration`, grouped on the client from each entry's own fields so + a mutation's response moves the row with no updater, and carried by the + image to the next launch. The README, the docs index and the site link + them. + ## 0.17.0 (Kulich) — 2026-10-18 - `@deletes(type:id:)`, the second directive that is not Relay's: a mutation diff --git a/README.md b/README.md index 57f5818..a2c9673 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,16 @@ graphql-sse but carry a `JSON` scalar, so they update no record. A process holds one schema family: two environments may share it, and two schemas in one process must not give two types one name. +A public API's own habits are compositions too, each with a recipe in +both languages: a rate limit and an outage +([a budgeted transport](docs/recipes/budgeted-transport.md)), a JSON +scalar read as a type of the app's +([a `Json` scalar](docs/recipes/json-scalar.md)), a field answered +differently by the path that reached it +([fields a server nulls by path](docs/recipes/fields-by-path.md)), and a +whole list in one request +([an unpaged collection](docs/recipes/unpaged-collection.md)). + Baton strives to support each backend's own features wherever they fit the design and make sense for a client. If yours does something Baton does not speak yet, open an issue. diff --git a/docs/README.md b/docs/README.md index 86f5cbb..c263b99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,8 @@ What to read when. ships. [integrating Baton with a coding agent](recipes/agents.md) is the page an agent reads first. [the exchange](recipes/exchange.md) is a challenge, a retry and a deadline over the transport's one verb. + [a budgeted transport](recipes/budgeted-transport.md) is a queue, a + meter and an outage over the same verb. [`batonc`](recipes/batonc.md) is the compiler's command line for a build outside SwiftPM. [Bazel](recipes/bazel.md) is the compiler as a toolchain and one rule over its command. [Gradle](recipes/gradle.md) is the Kotlin @@ -49,7 +51,12 @@ What to read when. [derived state outside views](recipes/derived-state.md) is a model over `Observations` or `snapshotFlow`. [discover once, refresh through `nodes(ids:)`](recipes/discover-once.md) - is the pattern for external keys. The rest arrive with the releases that - make them true. + is the pattern for external keys. + [an unpaged collection](recipes/unpaged-collection.md) is a whole list in + one request, grouped on the client and carried by the image. + [fields a server nulls by path](recipes/fields-by-path.md) is what a list + fragment selects. [a `Json` scalar](recipes/json-scalar.md) is a value + with no type of its own, read as a type of the app's. The rest arrive + with the releases that make them true. - **What did a decision open up?** [openings/](openings/) is written as the project ships. There are none yet. diff --git a/docs/recipes/budgeted-transport.md b/docs/recipes/budgeted-transport.md new file mode 100644 index 0000000..f1fce2f --- /dev/null +++ b/docs/recipes/budgeted-transport.md @@ -0,0 +1,485 @@ +# A budgeted transport: a queue, a meter and an outage over one verb + +Some public APIs ration requests. AniList's allows 30 a minute per IP +address, shared with every other client on the user's network. Past that +it times the address out for a minute, and answers 429 with a +`Retry-After` header and nothing else, sometimes with an HTML body from +Cloudflare. During an outage it answers 403, with a body that says the API +"has been temporarily disabled". An app for such an API wants five things: +one request in flight, the user's write ahead of a refresh ahead of a page +being browsed to, a meter that stays under the server's limit, a pause +after a refusal, and an outage shown calmly rather than as an error. + +None of it is a new concept. It is a transport that wraps a transport, +around the one verb, as [the exchange](exchange.md) is. The app owns the +wrapper and the numbers, and the library ships neither: a composition is +an example, and shipped wrappers were refused on +[#19](https://github.com/shergin/baton/issues/19). The code below is the +whole budget in both runtimes. Copy the one for your runtime, and change +the numbers to the endpoint's. + +## Where it sits + +The queue and the meter sit in the wrapper, the one place every request +passes, whichever view, model or refresh sent it. The built-in transports +know an endpoint, not its budget. A view knows its own request, not the +others in flight. + +The budget sits closest to the endpoint, under the exchange, so that every +send the exchange makes, a retry included, waits its turn and counts +against the meter. The exchange retries neither a 429 nor a 403, since +neither is a 5xx or a lost connection, so the budget's own rules decide +both. + +Subscriptions do not pass through it. The environment sends a +subscription through its `subscriptions:` transport, and an open stream +would hold the one turn for as long as it is open. + +The budget lives as long as its environment. A sign-out ends both, and +the next account's budget starts with an empty meter. The pause after a +429 covers the minute that overlaps. + +## What it does, in order + +1. **One request in flight.** A request waits for its turn, and the turn + passes to the most urgent waiting request, the earliest among equals. +2. **Priorities the app decides.** A `Request` carries its kind and its + operation's name, and the app maps them to a write, a refresh or a + browse page. By default a mutation is a write and anything else is a + refresh. +3. **A meter under the server's limit.** No more sends in any sixty + seconds than `perMinute`. Twenty-five leaves AniList's thirty some room + for the other clients on the network. +4. **A refusal for the rate pauses everyone.** A 429 sets a pause that + every later send waits out, and the refused request is sent once more + after it. A refusal for the rate comes before execution, so a write is + sent again too. A second refusal fails the request with the 429. +5. **The whole minute, whatever the body says.** The built-in transports + report a refusal as a `TransportError`, its status and its body, without + its headers. So the wrapper cannot read `Retry-After` over them, and it + waits the full sixty seconds on every 429. That is the timeout AniList + applies past its limit, so the header could only shorten the wait. + Cloudflare's HTML body changes nothing, for the same reason. An app that + wants the header's value reads it in a base transport of its own, which + throws an error of its own carrying it, and `pause(after:)` reads that + error. +6. **An outage is a value of the app's.** A 403 whose body says the API is + temporarily disabled becomes `Outage`, an error type of the app's own. + A 403 for any other reason stays the transport's error. Cloudflare's + refusal of a client's signature, its code 1010, is one of those. Neither + is sent again. +7. **A caller that went away leaves.** A request whose caller went away + while it waited still takes its turn, and leaves without sending. + +## In Swift + +```swift +import Baton +import Foundation + +/// The budget an app keeps around a rate-limited endpoint: one request in +/// flight, the most urgent first, no more sends in any minute than the +/// meter allows, and a pause for everyone after the server refuses one for +/// its rate. Copy it, and change the numbers to the endpoint's. +public struct Budget: Transport { + /// How urgent a request is: a write the user made, then a refresh of + /// what is on screen, then a page the user is browsing to. + public enum Priority: Int, Comparable, Sendable { + case browse, refresh, write + + public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } + } + + /// The transport this wraps, which holds the endpoint and its credentials. + public let base: any Transport + /// How urgent a request is, as the app decides from its kind and name. + public let priority: @Sendable (Request) -> Priority + private let gate: Gate + + public init( + base: any Transport, + perMinute: Int = 25, + priority: @escaping @Sendable (Request) -> Priority = { $0.kind == .mutation ? .write : .refresh } + ) { + self.base = base + self.priority = priority + gate = Gate(perMinute: perMinute) + } + + /// How long every request waits after a refusal for the rate. The + /// built-in transports report a refusal as its status and its body, + /// without its headers, so `Retry-After` is not read here and the wait + /// is the server's whole window. + public static func pause(after error: any Error) -> Duration? { + guard let transport = error as? TransportError, transport.statusCode == 429 else { return nil } + return .seconds(60) + } + + public func send(_ request: Request) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + await gate.enter(priority(request)) + do { + try await deliver(request, to: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + await gate.leave() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Sends the request in its turn, after the meter, and once more after + /// the pause if the server refuses it for its rate. + private func deliver(_ request: Request, to continuation: AsyncThrowingStream.Continuation) async throws { + var refused = false + while true { + // A caller that went away while the request waited takes its + // turn and leaves without sending. + try Task.checkCancellation() + try await gate.meter() + do { + for try await payload in base.send(request) { continuation.yield(payload) } + return + } catch { + if let outage = Outage(error) { throw outage } + guard let pause = Self.pause(after: error) else { throw error } + await gate.pause(for: pause) + // A refusal for the rate comes before execution, so even a + // write is sent once more, after the pause. + if refused { throw error } + refused = true + } + } + } +} + +/// AniList switched off: a 403 whose body says the API is disabled. An +/// error of the app's own, which a handle's `fetch` carries as the +/// transport's kind of failure, beside the data the store still has. +public struct Outage: Error { + init?(_ error: any Error) { + guard let transport = error as? TransportError, transport.statusCode == 403, + transport.body.contains("temporarily disabled") else { return nil } + } +} + +/// The one turn, the requests waiting for it, and the times of the last +/// minute's sends. Only the request holding the turn reads the meter. +private actor Gate { + private let perMinute: Int + private var busy = false + private var waiting: [(priority: Budget.Priority, turn: CheckedContinuation)] = [] + private var sent: [ContinuousClock.Instant] = [] + private var resumes = ContinuousClock.now + + init(perMinute: Int) { self.perMinute = perMinute } + + /// Waits until no request is in flight and none more urgent is waiting. + func enter(_ priority: Budget.Priority) async { + guard busy else { + busy = true + return + } + await withCheckedContinuation { waiting.append((priority, $0)) } + } + + /// Hands the turn to the most urgent waiting request, the earliest + /// among equals. + func leave() { + guard let next = waiting.indices.max(by: { waiting[$0].priority < waiting[$1].priority }) else { + busy = false + return + } + waiting.remove(at: next).turn.resume() + } + + /// Waits until the pause is over and a send fits in the minute, then + /// counts the send. + func meter() async throws { + while true { + let now = ContinuousClock.now + sent.removeAll { $0 + .seconds(60) <= now } + var until = resumes + if sent.count >= perMinute, let oldest = sent.first { until = max(until, oldest + .seconds(60)) } + if until <= now { break } + try await Task.sleep(until: until) + } + sent.append(.now) + } + + func pause(for duration: Duration) { + resumes = max(resumes, .now + duration) + } +} +``` + +The wiring puts the budget under the exchange, over the base transport +that holds the endpoint and its credentials. Browse pages are the +operation the app names for them: + +```swift +@MainActor +func environment(token: @escaping @Sendable () async -> String) -> Environment { + let anilist = URLSessionTransport( + url: URL(string: "https://graphql.anilist.co")!, + credentials: { ["Authorization": "Bearer \(await token())"] } + ) + let budget = Budget(base: anilist, perMinute: 25) { request in + if request.kind == .mutation { return .write } + return request.operationName == "BrowseQuery" ? .browse : .refresh + } + return Environment( + transport: Exchange(base: budget), + store: Store(persistence: Persistence(name: "App", version: Types.schemaDigest)) + ) +} +``` + +## In Kotlin + +The Kotlin budget is one `flow` around the base transport's `send`, as +the Kotlin exchange is. The turn is a `Mutex` and a list of waiting +`CompletableDeferred`s. A request waits for its turn without being +cancelled, then checks whether its caller is still there, so the turn is +always handed on: + +```kotlin +import baton.OperationKind +import baton.Request +import baton.Transport +import baton.TransportError +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * The budget an app keeps around a rate-limited endpoint: one request in + * flight, the most urgent first, no more sends in any minute than the + * meter allows, and a pause for everyone after the server refuses one for + * its rate. Copy it, and change the numbers to the endpoint's. + */ +class Budget( + /** The transport this wraps, which holds the endpoint and its credentials. */ + val base: Transport, + perMinute: Int = 25, + /** How urgent a request is, as the app decides from its kind and name. */ + val priority: (Request) -> Priority = { if (it.kind == OperationKind.MUTATION) Priority.WRITE else Priority.REFRESH }, +) : Transport { + /** How urgent a request is: a page the user is browsing to, then a refresh of what is on screen, then a write the user made. */ + enum class Priority { BROWSE, REFRESH, WRITE } + + private val gate = Gate(perMinute) + + override fun send(request: Request): Flow = flow { + gate.enter(priority(request)) + try { + var refused = false + while (true) { + // A caller that went away while the request waited takes its + // turn and leaves without sending. + currentCoroutineContext().ensureActive() + gate.meter() + try { + base.send(request).collect { emit(it) } + return@flow + } catch (error: Throwable) { + if (error is CancellationException) throw error + Outage.of(error)?.let { throw it } + val pause = pause(error) ?: throw error + gate.pause(pause) + // A refusal for the rate comes before execution, so even + // a write is sent once more, after the pause. + if (refused) throw error + refused = true + } + } + } finally { + withContext(NonCancellable) { gate.leave() } + } + } + + companion object { + /** + * How long every request waits after [error], a refusal for the + * rate. The built-in transports report a refusal as its status and + * its body, without its headers, so `Retry-After` is not read here + * and the wait is the server's whole window. + */ + fun pause(error: Throwable): Duration? = + if (error is TransportError && error.statusCode == 429) 60.seconds else null + } +} + +/** + * AniList switched off: a 403 whose body says the API is disabled. An error + * of the app's own, which a handle's `fetch` carries as the transport's + * kind of failure, beside the data the store still has. + */ +class Outage : Exception("the API is temporarily disabled") { + companion object { + /** The outage [error] reports, or null for any other failure. */ + fun of(error: Throwable): Outage? = + if (error is TransportError && error.statusCode == 403 && "temporarily disabled" in error.body) Outage() else null + } +} + +/** + * The one turn, the requests waiting for it, and the times of the last + * minute's sends. Only the request holding the turn reads the meter, so + * the meter needs no lock. + */ +private class Gate(private val perMinute: Int) { + private val lock = Mutex() + private var busy = false + private val waiting = mutableListOf>>() + private val sent = ArrayDeque() + private var resumes = TimeSource.Monotonic.markNow() + + /** Waits until no request is in flight and none more urgent is waiting. */ + suspend fun enter(priority: Budget.Priority) = withContext(NonCancellable) { + val turn = lock.withLock { + if (!busy) { + busy = true + return@withContext + } + CompletableDeferred().also { waiting.add(priority to it) } + } + turn.await() + } + + /** Hands the turn to the most urgent waiting request, the earliest among equals. */ + suspend fun leave() = lock.withLock { + val next = waiting.maxByOrNull { it.first } + if (next == null) { + busy = false + return@withLock + } + waiting.remove(next) + next.second.complete(Unit) + } + + /** Waits until the pause is over and a send fits in the minute, then counts the send. */ + suspend fun meter() { + while (true) { + while (sent.isNotEmpty() && sent.first().elapsedNow() >= 1.minutes) sent.removeFirst() + var wait = -resumes.elapsedNow() + if (sent.size >= perMinute) wait = maxOf(wait, 1.minutes - sent.first().elapsedNow()) + if (wait <= Duration.ZERO) break + delay(wait) + } + sent.addLast(TimeSource.Monotonic.markNow()) + } + + fun pause(duration: Duration) { + val until = TimeSource.Monotonic.markNow() + duration + if (until > resumes) resumes = until + } +} +``` + +The wiring is the same shape. On Android, an `OkHttpTransport` from +`baton-okhttp` stands where the `HttpTransport` stands: + +```kotlin +fun environment(token: suspend () -> String): Environment { + val anilist = HttpTransport( + "https://graphql.anilist.co", + credentials = { mapOf("Authorization" to "Bearer ${token()}") }, + ) + val budget = Budget(anilist, perMinute = 25) { request -> + when { + request.kind == OperationKind.MUTATION -> Budget.Priority.WRITE + request.operationName == "BrowseQuery" -> Budget.Priority.BROWSE + else -> Budget.Priority.REFRESH + } + } + return Environment( + Exchange(budget), + store = Store(persistence = Persistence.named("App", version = Types.schemaDigest)), + ) +} +``` + +## The outage needs no new state + +A handle already has the place for an outage. Its `fetch` is the last +fetch as a value: idle, in flight, or failed, with a `Failure` and the +time it failed. The budget throws `Outage`, and the handle's fetch carries +it as the transport's kind of failure, as the transport threw it. A view +reads it beside the data: + +- With data in the store, from this launch or from the image, the phase + stays ready and the data stays on screen. The fetch says why it is not + fresher, and the view shows a calm line above it. +- Without data, the phase is failed, with the `Outage` itself, and the + view shows the same line in place of the list. + +That is [honest data](../principles/honest-data.md): a failed fetch is a +value beside the data, never a new phase and never a new kind of failure. +The list screen of +[the unpaged collection](unpaged-collection.md) reads it so: + +```swift +var body: some View { + switch list.phase { + case .loading: + ProgressView() + case .ready(let data): + Shelves(data: data) + .safeAreaInset(edge: .top) { + if list.fetch.failure?.error is Outage { OutageNote() } + } + case .failed(is Outage): + OutageNote() + case .failed(let error): + ContentUnavailableView("No list", systemImage: "list.bullet", description: Text(error.localizedDescription)) + } +} +``` + +```kotlin +when (val phase = list.phase) { + Phase.Loading -> CircularProgressIndicator() + is Phase.Ready -> Column { + if (list.fetch.failure?.error is Outage) OutageNote() + Shelves(phase.data) + } + is Phase.Failed -> if (phase.error is Outage) OutageNote() else Text(phase.error.message.orEmpty()) +} +``` + +Nothing in the runtime retries an outage, and nothing should guess when +it ends. The app calls `environment.revalidate()`, which refetches every +retained operation that is stale or whose last fetch failed, when it +returns to the foreground or from a timer of its own +([revalidation is the app's call](../decisions/revalidation-is-the-apps-call.md)). + +## What was checked + +The code on this page was compiled in both runtimes against AniList's +schema on 2026-10-18, beside the other recipes for the same API, and run +over a scripted base transport. Four requests sent while a first was in +flight, a browse page, a refresh, a write and another browse page, went +write, refresh, browse, browse. With `perMinute` at five, the sixth send +waited for the first to leave the minute. A body saying the API was temporarily disabled became +`Outage`, and Cloudflare's 403 with code 1010 stayed a `TransportError`. +A write refused once with a 429 was sent again sixty seconds later and +delivered. A query refused twice failed with the 429, and the next +request waited out the pause. The budget is not a sample of this +repository, and no test of the repository holds it: copy it, and test it +as your own. diff --git a/docs/recipes/fields-by-path.md b/docs/recipes/fields-by-path.md new file mode 100644 index 0000000..305a3ab --- /dev/null +++ b/docs/recipes/fields-by-path.md @@ -0,0 +1,174 @@ +# Fields a server nulls by path + +A normalized store keeps one slot per field of a record, for each set of +arguments, whichever operation wrote it. Baton's store writes what +arrives, because [the response is the oracle](../principles/response-is-the-oracle.md). +So when a server answers the same field differently by the path that +reached the record, the later response wins. Suppose a browse page answers +a media's `voiceActors` as null, and the detail screen fetched them a +minute before. The page writes null over them, and the detail shows none. +No error is raised. A null in a nullable field is data, and the store +cannot tell a null the path produced from a null the record has. Nothing +heals it either, because only missing data is healed, and a null is not +missing. + +This was reported for AniList on +[#52](https://github.com/shergin/baton/issues/52): `CharacterEdge.voiceActors` +null under `Page.media`, and filled under the root `Media(id:)`. On +2026-10-18 an anonymous request got it filled on both paths, for pages of +one to fifty media. Whether a server nulls a field by path can depend on +the viewer, on the size of the request, or on the server's release, and +the schema says nothing of it. So the convention below does not depend on +catching it. It lives in the documents. + +## The convention + +- **A list fragment selects the record's identity and the row's own + fields.** These are scalars of the record, and small objects with no list + beneath them, such as a title or a cover. They read the same on every + path that reaches the record. +- **Path-dependent fields are selected in the detail operation only.** + These are the lists and connections nested under a row (characters, + staff, relations, and the voice actors under the characters), and any + field the server fills only at its root. The detail's path fills them, + and no list response writes their slots. +- **Viewer-dependent fields are kept out of list fragments too.** On + AniList these are `mediaListEntry`, `isFavourite` and + `title.userPreferred`. One environment is one identity + ([the session](../decisions/the-environment-is-the-session.md)), so every + request in it asks as the same viewer. Such a field is path-dependent + only if the server makes it so. Each of these still has a better place: + - `title.userPreferred` follows a setting of the viewer's. When the + setting changes, the response to the settings mutation carries the + viewer's options and names no media. Every stored `userPreferred` keeps + the old language until each operation that selected it is fetched + again. Read `title { romaji english native }` in the row instead, and + choose on the client by the viewer's `options { titleLanguage }`. The + settings mutation's response then re-renders every row. + - `mediaListEntry` and `isFavourite` are the viewer's state, which the + viewer's own mutations change. Select them where the viewer changes + them, in the detail, and in the payload of the mutation that changes + them, so that its response writes the slot the detail reads. + +Arguments are part of a field's storage key. `voiceActors(language: +JAPANESE)` and `voiceActors(language: ENGLISH)` are two slots. Only the +same field, with the same arguments, on the same record, is written by +both paths. + +## The documents + +The row's fragment, and the browse page that spreads it beside the +viewer's title language: + +```graphql +fragment MediaRow_media on Media { + id + format + episodes + seasonYear + coverImage { medium } + title { romaji english native } +} + +query BrowseQuery($page: Int!) { + Viewer { id options { titleLanguage } } + Page(page: $page, perPage: 50) { + media(type: ANIME, sort: POPULARITY_DESC) { id ...MediaRow_media } + } +} +``` + +The detail spreads the same row, and adds what only its path should +write: + +```graphql +query MediaQuery($id: Int!) { + Media(id: $id) { + id + ...MediaRow_media + isFavourite + mediaListEntry { id status progress } + characters(sort: [ROLE, RELEVANCE], perPage: 25) { + edges { + role + node { id name { full } } + voiceActors(language: JAPANESE) { id name { full } } + } + } + } +} +``` + +In Swift the fragment sits in `@Fragment` on the row's view, and the +queries in `@Query` on the screens. In Kotlin they sit on the composables, +and the queries are written as `$$"""` strings because of their +variables. The text is the same. + +The title is chosen on the client, from the fields the row reads. In +Swift: + +```swift +extension UserTitleLanguage { + /// The media's title in this language, from the titles the row reads. + @MainActor + func title(of media: MediaRow_media) -> String { + let romaji = media.title?.romaji ?? "" + switch self { + case .ENGLISH, .ENGLISH_STYLISED: return media.title?.english ?? romaji + case .NATIVE, .NATIVE_STYLISED: return media.title?.native ?? romaji + default: return romaji + } + } +} +``` + +And in Kotlin: + +```kotlin +/** The media's title in this language, from the titles the row reads. */ +fun UserTitleLanguage.title(media: MediaRow_media): String { + val romaji = media.title?.romaji.orEmpty() + return when (this) { + UserTitleLanguage.ENGLISH, UserTitleLanguage.ENGLISH_STYLISED -> media.title?.english ?: romaji + UserTitleLanguage.NATIVE, UserTitleLanguage.NATIVE_STYLISED -> media.title?.native ?: romaji + else -> romaji + } +} +``` + +The browse screen passes `data.Viewer?.options?.titleLanguage` to each +row, so the screen's body reads the viewer's setting, and a response that +changes it re-renders the rows. + +## Telling the two apart + +The schema types a field the same way on every path, so it cannot say. +Three places can, in this order: + +1. **The field's description.** Some schemas say where a field is + filled. +2. **A probe.** Send one request, as the viewer, that reaches one record + by both paths, in the shape the list really uses, and compare the two + halves of the raw response. Send it with any HTTP client, not through + the store: through the store, both halves would write one record, and + the later would win. + + ```graphql + query ProbeVoiceActors($id: Int!) { + page: Page(perPage: 50) { + media(id: $id) { id characters(perPage: 3) { edges { voiceActors(language: JAPANESE) { id } } } } + } + root: Media(id: $id) { id characters(perPage: 3) { edges { voiceActors(language: JAPANESE) { id } } } } + } + ``` + +3. **The default.** Without either, treat a list nested under a page's + rows as path-dependent, and a record's own scalar as not. That is what + the convention does, so it is safe whichever way the server answers + today. + +A probe is evidence about one release of a server, on one date. Keep the +convention after a probe passes. + +The documents and the code on this page were compiled in both runtimes +against AniList's schema on 2026-10-18. diff --git a/docs/recipes/json-scalar.md b/docs/recipes/json-scalar.md new file mode 100644 index 0000000..ac07268 --- /dev/null +++ b/docs/recipes/json-scalar.md @@ -0,0 +1,186 @@ +# A `Json` scalar read as a value + +Some schemas declare a scalar for JSON and use it wherever a value has no +type of its own. AniList's is `Json`. It carries `MediaList.customLists`, +the user's custom lists and whether an entry is in each, and +`MediaList.advancedScores`, a map keyed by the names the user gave their +scoring sections. It also carries `ThreadComment.childComments`, a tree of +replies. + +A custom scalar is stored as its text, the bytes the server wrote, an +array or an object included. Unmapped, it reads as that text, a `String`. +Mapped under `customScalarTypes` to a type of the app's, it reads as that +type, converted from the text at every read. That is the whole mechanism. +This page is the type for `customLists`. + +## Ask for the array + +`customLists` without arguments is an object keyed by the user's list +names, `{"Up Next": true}`. With `asArray: true` it is an array of +objects, one per custom list of the user's, each with its name and +whether the entry is in it: + +```json +[{"name": "Up Next", "enabled": true}] +``` + +That is what AniList's API answered on 2026-10-18, and what its schema +describes: "an array of objects". The type that reads it needs no keys of +the user's, only a name and a flag in each element. + +## In Swift + +The type conforms to `MappedScalar`: an initializer from the text, which +returns nil when the text does not convert, and the text a server +receives for the value. The type is `public`, because the generated lens +is public and its accessor names the type: + +```swift +import Baton +import Foundation + +/// `MediaList.customLists(asArray: true)`: each of the user's custom lists, +/// and whether the entry is in it. +public struct CustomLists: MappedScalar { + public struct List: Hashable, Sendable, Codable { + public let name: String + public let enabled: Bool + } + + public let lists: [List] + + /// The names of the lists the entry is in. + public var names: [String] { lists.filter(\.enabled).map(\.name) } + + public init?(scalarText: String) { + guard let lists = try? JSONDecoder().decode([List].self, from: Data(scalarText.utf8)) else { return nil } + self.lists = lists + } + + public var scalarText: String { + String(decoding: (try? JSONEncoder().encode(lists)) ?? Data("[]".utf8), as: UTF8.self) + } +} +``` + +## In Kotlin + +A Kotlin type the runtime does not own cannot implement an interface of +the runtime's, so the type is a plain data class, and an `object` +implementing `ScalarConverter` parses and renders it. The parse here uses +kotlinx.serialization's JSON tree, `Json.parseToJsonElement`, from +`org.jetbrains.kotlinx:kotlinx-serialization-json`, which needs no +compiler plugin. That `Json` is the library's object; AniList's scalar of +the same name is not a type in the generated code. + +```kotlin +import baton.ScalarConverter +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +/** `MediaList.customLists(asArray: true)`: each of the user's custom lists, and whether the entry is in it. */ +data class CustomLists(val lists: List) { + data class CustomList(val name: String, val enabled: Boolean) + + /** The names of the lists the entry is in. */ + val names: List get() = lists.filter { it.enabled }.map { it.name } +} + +/** Reads AniList's `Json` as [CustomLists] at the read, and writes it back. */ +object CustomListsConverter : ScalarConverter { + override fun parse(text: String): CustomLists? = runCatching { + CustomLists( + Json.parseToJsonElement(text).jsonArray.map { + val list = it.jsonObject + CustomLists.CustomList(list.getValue("name").jsonPrimitive.content, list.getValue("enabled").jsonPrimitive.boolean) + }, + ) + }.getOrNull() + + override fun render(value: CustomLists): String = + JsonArray(value.lists.map { buildJsonObject { put("name", it.name); put("enabled", it.enabled) } }).toString() +} +``` + +## The configuration + +`baton.json` names the type for each language. Swift's entry is the type, +and Kotlin's is the type and its converter, each by its full name: + +```json +{ + "schema": "schema.graphql", + "customScalarTypes": { + "Json": { + "swift": "CustomLists", + "kotlin": { "type": "app.CustomLists", "converter": "app.CustomListsConverter" } + } + } +} +``` + +A fragment then selects the field as it always did, and the accessor +reads the type: + +```graphql +fragment EntryRow_mediaList on MediaList { + id + status + progress + hiddenFromStatusLists + customLists(asArray: true) + media { id episodes title { romaji } } +} +``` + +`entry.customLists?.names` is the lists the entry is in, in Swift and in +Kotlin. The accessor is optional wherever the schema puts the field, +because a mapped scalar is a fallible read +([the decision](../decisions/a-mapped-scalar-is-a-fallible-read.md)). A +text that does not convert reads nil and is logged as the `unexpected` +event. It is never treated as missing data, which would refetch it +forever. + +## One type for the scalar + +`customScalarTypes` maps a scalar, not a field, as Relay's does. Every +`Json` field the app's documents select reads as `CustomLists`. A field of +another shape, `advancedScores` say, then reads nil and is logged as +unexpected: the log says the type does not hold that shape. An app that +reads two shapes gives the type a case for each, an enum in Swift and a +sealed interface in Kotlin, and each place that reads a field takes the +case it expects. + +## A map stays a type of the app's + +`advancedScores` is a map keyed by the user's own words. It reads as a +type of the app's too, made from the text by the app's code, with reads +in the app's terms, such as a score for a section. Baton has no JSON value +type and never reads a `Json` field as a dictionary. Its read path holds +slots and values that are enums, and a dictionary keyed by a user's words +would be a record with no schema behind it. + +## What a read costs + +The conversion runs at every read, and nothing caches it +([the decision](../decisions/a-mapped-scalar-converts-at-the-read.md)). +A JSON decode is the app's code, and it costs what the decoder costs. A +body that reads `customLists` for five hundred rows decodes five hundred +times each time it runs. Read it where it is shown, or derive from it in +one place, as [the unpaged collection](unpaged-collection.md) does when +it groups a list. + +A variable of a mapped type is sent as its text. AniList takes no `Json` +argument where an app writes custom lists: `SaveMediaListEntry` takes +`customLists: [String]`, the names the entry should be in. So +`scalarText` renders the array for the protocol's sake, and no request +carries it. + +The code on this page was compiled in both runtimes against AniList's +schema on 2026-10-18, with the configuration above. diff --git a/docs/recipes/unpaged-collection.md b/docs/recipes/unpaged-collection.md new file mode 100644 index 0000000..a0953a3 --- /dev/null +++ b/docs/recipes/unpaged-collection.md @@ -0,0 +1,263 @@ +# An unpaged collection into the image + +Some servers answer a whole collection in one request rather than page by +page. AniList's `MediaListCollection` is one: a user's whole anime or +manga list, up to five hundred entries a chunk, in `lists` grouped by +status and by custom list. The groups have no ids. Each group's `entries` +are `MediaList` records, which do have ids. Against a rationed API (see +[the budgeted transport](budgeted-transport.md)), one request for the +list is far better than a page at a time. + +The pattern has three parts: one operation with a long expiration, a +grouping made on the client from each entry's own fields, and the image, +which carries the list to the next launch. It sits beside +[discover once](discover-once.md), the other pattern for data a screen +cannot page. + +## One operation, kept for an hour + +```graphql +query ListQuery($userId: Int!, $mediaType: MediaType!) @cacheExpiration(seconds: 3600) { + MediaListCollection(userId: $userId, type: $mediaType, perChunk: 500, chunk: 1, forceSingleCompletedList: true) { + hasNextChunk + lists { entries { id ...EntryRow_mediaList } } + } +} +``` + +`@cacheExpiration(seconds:)` states how old the list may be before it +reads as stale. A user's list changes when the user edits it, and those +edits are this app's own mutations, whose responses write the entries. +Only an edit made elsewhere, on the website or another device, waits +for the expiration, or for a `refetch()` when the user pulls to refresh, +or for `revalidate()` when the app returns to the foreground. + +A list past five hundred entries answers `hasNextChunk`. The next chunk is +another operation value, `chunk: 2`, with its own root, and the grouping +reads the entries of every chunk the app holds. + +## Group on the client, from each entry's own fields + +The groups the server sends are records without ids, stored under the +operation's root by their path. A mutation's response, +`SaveMediaListEntry`'s `MediaList`, writes the entry's record and leaves +every group's array as it was. A screen that read the server's groups +would keep a row under its old heading until the list is fetched again, +or it would need an updater to move the row from one array to another. + +Read each entry's own fields instead: its `status`, unless +`hiddenFromStatusLists` is set, and the custom lists it is in, which is +the `customLists` of [a `Json` scalar read as a value](json-scalar.md). +The body that groups reads those fields, so the commit that changes one +runs the body again, and the row is under its new heading in that same +turn. With an optimistic response, that is the turn of the call. No +updater is written. + +The row's fragment, with the mutation that moves it: + +```graphql +fragment EntryRow_mediaList on MediaList { + id + status + progress + hiddenFromStatusLists + customLists(asArray: true) + media { id episodes title { romaji } } +} + +mutation EntryRowSave($id: Int, $status: MediaListStatus) { + SaveMediaListEntry(id: $id, status: $status) { id ...EntryRow_mediaList } +} +``` + +The server repeats an entry under every group it is in: under its +status, and again under each custom list it is in. So the grouping reads +each entry once, and the groups themselves are read only as a bag of +entries. In Swift: + +```swift +/// The shelves of the list, from each entry's own fields: its status, +/// unless it is hidden from the status lists, and each custom list it is +/// in. The server repeats an entry under every group it is in, so an entry +/// is read once, and the groups themselves are not read. +@MainActor +func shelves(_ data: ListQuery.Data) -> [(name: String, entries: [EntryRow_mediaList])] { + var byStatus: [MediaListStatus: [EntryRow_mediaList]] = [:] + var byList: [String: [EntryRow_mediaList]] = [:] + var seen: Set = [] + for group in data.MediaListCollection?.lists ?? .empty { + for entry in group.entries ?? .empty where seen.insert(entry.id).inserted { + let row = entry.entryRow + if let status = row.status, row.hiddenFromStatusLists != true { + byStatus[status, default: []].append(row) + } + for name in row.customLists?.names ?? [] { + byList[name, default: []].append(row) + } + } + } + let order: [MediaListStatus] = [.CURRENT, .REPEATING, .PAUSED, .PLANNING, .COMPLETED, .DROPPED] + return order.compactMap { status in byStatus[status].map { (status.scalarText.capitalized, $0) } } + + byList.keys.sorted().map { ($0, byList[$0] ?? []) } +} + +/// The list under its shelves, each row once per shelf it sits on. +struct Shelves: View { + let data: ListQuery.Data + + var body: some View { + List { + ForEach(shelves(data), id: \.name) { shelf in + Section(shelf.name) { + ForEach(shelf.entries, id: \.id) { EntryRow(entry: $0) } + } + } + } + } +} +``` + +And in Kotlin, where a row's key names its shelf, since one entry may sit +on two: + +```kotlin +/** + * The shelves of the list, from each entry's own fields: its status, + * unless it is hidden from the status lists, and each custom list it is + * in. The server repeats an entry under every group it is in, so an entry + * is read once, and the groups themselves are not read. + */ +fun shelves(data: ListQuery.Data): List>> { + val byStatus = mutableMapOf>() + val byList = mutableMapOf>() + val seen = mutableSetOf() + for (group in data.MediaListCollection?.lists.orEmpty()) { + for (entry in group.entries.orEmpty()) { + if (!seen.add(entry.id)) continue + val row = entry.entryRow + val status = row.status + if (status != null && row.hiddenFromStatusLists != true) byStatus.getOrPut(status) { mutableListOf() }.add(row) + for (name in row.customLists?.names.orEmpty()) byList.getOrPut(name) { mutableListOf() }.add(row) + } + } + val order = listOf(MediaListStatus.CURRENT, MediaListStatus.REPEATING, MediaListStatus.PAUSED, MediaListStatus.PLANNING, MediaListStatus.COMPLETED, MediaListStatus.DROPPED) + return order.mapNotNull { status -> byStatus[status]?.let { status.scalarText.lowercase().replaceFirstChar(Char::titlecase) to it } } + + byList.keys.sorted().map { it to byList.getValue(it) } +} + +/** The list under its shelves, each row once per shelf it sits on. */ +@Composable +fun Shelves(data: ListQuery.Data) { + LazyColumn { + for ((name, entries) in shelves(data)) { + item(key = name) { Text(name, style = MaterialTheme.typography.titleSmall) } + items(entries, key = { "$name/${it.id}" }) { EntryRow(it) } + } + } +} +``` + +The grouping reads every entry's `customLists`, which a mapped scalar +converts at each read, so a list of five hundred costs five hundred small +decodes each time `shelves` runs. The body runs it when a field it read +changes, and also when the view is rendered again for reasons of its own. +A model that keeps the shelves, as +[derived state outside views](derived-state.md) keeps a count, runs it +only when a field it read changes, and the view reads the result. + +Two edits still change the server's arrays rather than an entry's fields: + +- **An entry the list does not hold yet**, added with + `SaveMediaListEntry(mediaId:)`, is in no group's array. Fetch the list + again after that mutation. It is one request. +- **A deleted entry** leaves every shelf at once. A mutation that names + the record it deletes, with `@deletes(type: "MediaList", id: $id)` over + `DeleteMediaListEntry(id: $id) { deleted }`, deletes it in the turn of + the call, and a list of records skips a deleted record. + +## The image carries it + +With persistence, the list's records and the time it was fetched are +written to the image. At the next launch the screen's `@Query` attaches +with the default policy, `storeOrNetwork`. The store answers from the +image, and the list is within its hour, so the attach sends no request. +Past the hour the list still shows from the image, and the handle fetches +behind it, with `isRefreshing` set until the response commits. The age +survives the launch because the image keeps when each operation was +fetched. + +The screen reads the phase and the fetch, as every screen does: + +```swift +/// The whole list in one request, kept for an hour, and carried by the +/// image to the next launch. +struct ListScreen: View { + @Query(""" + query ListQuery($userId: Int!, $mediaType: MediaType!) @cacheExpiration(seconds: 3600) { + MediaListCollection(userId: $userId, type: $mediaType, perChunk: 500, chunk: 1, forceSingleCompletedList: true) { + hasNextChunk + lists { entries { id ...EntryRow_mediaList } } + } + } + """) + var list: ListQuery + + var body: some View { + switch list.phase { + case .loading: + ProgressView() + case .ready(let data): + Shelves(data: data) + .safeAreaInset(edge: .top) { + if list.fetch.failure?.error is Outage { OutageNote() } + } + case .failed(is Outage): + OutageNote() + case .failed(let error): + ContentUnavailableView("No list", systemImage: "list.bullet", description: Text(error.localizedDescription)) + } + } +} +``` + +```kotlin +/** The whole list in one request, kept for an hour, and carried by the image to the next launch. */ +@Query( + $$""" + query ListQuery($userId: Int!, $mediaType: MediaType!) @cacheExpiration(seconds: 3600) { + MediaListCollection(userId: $userId, type: $mediaType, perChunk: 500, chunk: 1, forceSingleCompletedList: true) { + hasNextChunk + lists { entries { id ...EntryRow_mediaList } } + } + } + """, +) +@Composable +fun ListScreen(userId: Int) { + val list = rememberQuery(ListQuery(userId = userId, mediaType = MediaType.ANIME)) + when (val phase = list.phase) { + Phase.Loading -> CircularProgressIndicator() + is Phase.Ready -> Column { + if (list.fetch.failure?.error is Outage) OutageNote() + Shelves(phase.data) + } + is Phase.Failed -> if (phase.error is Outage) OutageNote() else Text(phase.error.message.orEmpty()) + } +} +``` + +`Outage` is the error of [the budgeted transport](budgeted-transport.md), +read beside the data. The variable is `$mediaType` and not `$type`, +because a variable named `type` clashes with the operation's own `type` in +the generated Kotlin, and the compiler says so. + +## Why not a connection + +A connection is the server's pages merged into one client record, and it +needs a server that pages. This one answers the whole list, so there is +nothing to merge, and the groups would be one more structure to keep in +step with the entries. The entries' own fields are the truth the server +keeps, and the grouping is a view of them. + +The documents and the code on this page were compiled in both runtimes +against AniList's schema on 2026-10-18. diff --git a/site/src/pages.rs b/site/src/pages.rs index 668ef92..f4259cc 100644 --- a/site/src/pages.rs +++ b/site/src/pages.rs @@ -255,6 +255,12 @@ pub fn sections(root: &Path) -> Vec
{ "A challenge, a retry and a deadline over the transport's one verb, compiled in both runtimes.", "docs/recipes/exchange.md", ), + repo( + "/recipes/budgeted-transport/", + "A budgeted transport", + "A queue, a meter and an outage over the transport's one verb, for an API that rations requests, in both runtimes.", + "docs/recipes/budgeted-transport.md", + ), repo( "/recipes/uikit/", "UIKit and AppKit", @@ -291,6 +297,24 @@ pub fn sections(root: &Path) -> Vec
{ "The pattern for external keys: look a subject up by its natural key once, then refresh any number of them through `nodes(ids:)`.", "docs/recipes/discover-once.md", ), + repo( + "/recipes/unpaged-collection/", + "An unpaged collection", + "A whole list in one request: a long expiration, a grouping from each entry's own fields, and the image for the next launch.", + "docs/recipes/unpaged-collection.md", + ), + repo( + "/recipes/fields-by-path/", + "Fields a server nulls by path", + "What a list fragment selects when a server answers a field differently by the path that reached it, and a probe that tells.", + "docs/recipes/fields-by-path.md", + ), + repo( + "/recipes/json-scalar/", + "A Json scalar", + "A JSON scalar read as a type of the app's, one type per scalar, and never a dictionary on the read path.", + "docs/recipes/json-scalar.md", + ), repo( "/recipes/porting-from-relay/", "Porting from Relay",