Skip to content

Recipes: a budgeted transport, a Json mapped scalar, fields a server nulls by path, and an unpaged collection into the image #52

Closed

Description

@shergin

Summary

Four compositions we had to work out for an AniList client on Baton, none of which is a new concept and none of which the recipes show yet. Requested as pages under docs/recipes/, or as sentences in existing ones.

1. A budgeted transport

AniList allows 30 requests a minute per IP, shared with every other client on the user's network; past that a one-minute timeout, a 429 whose only header is Retry-After (sometimes with an HTML body from Cloudflare), and during outages a 403 whose body says the API "has been temporarily disabled". The adopter-side pattern we arrived at: one request in flight, priorities (a user's write ahead of a refresh ahead of a browse page), a per-minute meter under the server's limit, Retry-After honored and a full 60 s when it is absent or the body is HTML, and the 403 mapped to a state the UI shows calmly rather than a failure. #19 covers challenge, retry and deadline; the exchange recipe shows the one verb. What is missing is where the meter and the queue sit (the transport's verb, a wrapper, the app) and how the 403-as-state reaches a handle's phase without being a failure.

2. A Json scalar read as a value

MediaList.customLists(asArray: true) is a JSON array of names, advancedScores a map keyed by the user's section names, ThreadComment.childComments a tree. An unmapped custom scalar reads as String; a mapped one converts at the read. The recipe we want: a small type conforming to MappedScalar that decodes the array at the read, the customScalarTypes entry for it, and the sentence that a map stays a type of the app's and never a dictionary on the read path.

3. Fields a server nulls by path

The same field (voiceActors) is null under Page.media and filled under root Media(id:). A normalized store that writes what arrives lets the list response erase the detail's data. We settled on a convention: which fields a list fragment may select (identity, the row's own fields), which belong only to a detail or viewer operation (viewer-dependent mediaListEntry, isFavourite, title.userPreferred; path-dependent nested connections), and how to tell the two apart when a schema does not say. A recipe would save the next adopter the week.

4. An unpaged collection into the image

MediaListCollection returns a whole list, grouped in records without ids, in one request. The recipe beside discover-once.md: fetch the collection as one operation with a long @cacheExpiration, group on the client from each entry's own fields (status, customLists) so a mutation's response moves the row with no updater, and let the image carry it so the next launch costs nothing. MAL-Sync adopted the same one-request pattern in 2026 and cut its calls by 90%.

Activity

  1. changed the title [-]Recipes Yakitate needs: a budgeted transport, a Json mapped scalar, fields a server nulls by path, and an unpaged collection into the image[/-] [+]Recipes: a budgeted transport, a Json mapped scalar, fields a server nulls by path, and an unpaged collection into the image[/+] on Oct 18, 2026
  2. shergin commented on Oct 19, 2026

    @shergin
    OwnerAuthor

    Written in #54, merged to master as 3d0c510. Four pages under docs/recipes/, each with its Swift and Kotlin halves. None adds a concept. Every code block was compiled in both runtimes against AniList's schema.

    A budgeted transport

    • The queue and the meter sit in a wrapper around the one verb. That is the one place every request passes, and the app owns it, as the A composed transport for production endpoints: per-attempt credentials, challenge replay, bounded retry, deadlines, and typed failures #19 answer has it for compositions. It sits under the exchange, so the exchange's retries wait their turn and count against the meter.
    • It keeps one request in flight and sends a write before a refresh before a browse page. The app decides the priority from the request's kind and its operation's name. The meter is 25 a minute against AniList's 30. A 429 pauses every request, and the refused one is sent once more, a write included, since a refusal for the rate comes before execution.
    • Retry-After is not read. The built-in transports report a refusal as a TransportError of its status and its body, without its headers. So the wait is always the full minute, which is AniList's own timeout. An HTML body changes nothing. An app that wants the header reads it in a base transport of its own.
    • The 403 needs no new state. The budget maps it to an Outage error of the app's, only when the body says the API is temporarily disabled. A Cloudflare 403, code 1010, stays a TransportError. With data in the store or the image, the handle's phase stays ready, its fetch carries the failure as the transport's kind, and the view shows a calm line. Without data, the phase fails with the Outage itself. revalidate() is how the app retries when it chooses.

    A Json scalar read as a value

    • customLists(asArray: true) is not an array of names. On the live API it is an array of objects, [{"name": "Up Next", "enabled": true}], one per custom list of the user's. The schema's own description says "an array of objects". The recipe's type reads that shape, and its names gives the lists the entry is in. A decoder that expects [String] reads every entry as in no custom list.
    • customScalarTypes maps a scalar, not a field. With Json mapped to CustomLists, a selected advancedScores reads nil and is logged as unexpected. Two shapes make the type an enum or a sealed interface.
    • The mapped Swift type must be public, because the generated lens is.

    Fields a server nulls by path

    • The convention is as proposed. A list fragment selects identity and the row's own fields. Nested lists and connections go in the detail.
    • One correction on viewer-dependent fields. One environment is one identity, so they read the same on every path unless the server makes them path-dependent. They still leave list fragments, for a different reason. title.userPreferred is better derived on the client from romaji, english and native and the viewer's titleLanguage, so the settings mutation re-renders every row. mediaListEntry and isFavourite belong where the viewer changes them, and in the payloads of the mutations that change them.
    • voiceActors did not reproduce. On 2026-10-18 an anonymous request got it filled under Page.media for pages of 1 to 50 media, and filled under Media(id:). The page cites the report, and gives a probe: one request that reaches the record by both paths, sent outside the store. The convention holds whichever way the server answers.

    An unpaged collection into the image

    • One MediaListCollection operation with @cacheExpiration(seconds: 3600). The shelves are grouped from each entry's status, hiddenFromStatusLists and customLists, and each entry is read once, since the server repeats an entry under its status and under each custom list. SaveMediaListEntry's response moves the row with no updater. A new entry needs a refetch, and @deletes removes a row in the turn of the call. The image carries the list, so a launch within the hour sends no request.
    • In Kotlin, a variable named $type is refused, because it clashes with the operation's type. The documents use $mediaType.
    • The MAL-Sync figure is not quoted, since it was not measured here.

    Linked from the README, docs/README.md and the site's Recipes section. CHANGELOG under Unreleased.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions