Repository navigation
A directive for page-number pagination: lists paged by page/perPage with only hasNextPage to trust (AniList's schema has no cursors) #49
Description
Summary
We are building Yakitate, a native macOS client for AniList, on Baton. AniList's GraphQL API has no cursor connections at all: every list is Page(page: Int, perPage: Int) with a pageInfo whose only trustworthy field is hasNextPage, and nested connections borrow Relay's edges/nodes/pageInfo but page by number. Baton's @connection cannot be used, and the terminology's Page entry says such a list is "composed in the UI from plain operations until the watch list promotes a directive for it". Requested: that directive, for lists paged by number or offset, with hasNextPage as the only page-info field it trusts.
Current behavior
@connection(key:)reads through Relay's handle key and merges pages by cursor (docs/terminology.md, "Connection, edge, node";docs/decisions/connections-own-their-edges.md). A page without a cursor replaces, so two number-paged requests cannot merge.- The terminology's Page entry (
docs/terminology.md, "Lists"): "a list fetched by page number or offset, as the sample API does. Not a connection; composed in the UI from plain operations until the watch list promotes a directive for it." - So each page is its own operation value today (
SeasonQuery(page: 2)is a different root fromSeasonQuery(page: 1)), and the view concatenates the pages' lists and readshasNextPagefrom the last one.batonc0.16.0 validates and generates this without complaint.
What the schema looks like
Measured against https://graphql.anilist.co on 2026-10-18 (one unauthenticated introspection and a handful of small queries):
Page(page, perPage) { pageInfo { total perPage currentPage lastPage hasNextPage } media(...) ... }, with 18 list fields onPage(media,mediaList,airingSchedules,characters,staff,notifications,reviews,recommendations, ...). Only one data field may be used perPage.- Nested:
Media.characters(sort:, role:, page:, perPage:) { edges { role node { ... } } nodes pageInfo },Media.airingSchedule(notYetAired:, page:, perPage:), and so on. No cursor anywhere. perPageis capped at 50 silently (a request for 100 came back as 50).page * perPageabove 5,000 is HTTP 400.- The docs say
totalandlastPage"are not currently accurate. You should only rely onhasNextPage";totalcame back as exactly 5000 for two unrelated queries. The lead developer confirmed on 2026-03-19 that this still holds. Pagehas no id. The schema has no interfaces and noSubscriptiontype; three unions.
Requested behavior
A directive on a number-paged list field that gives a lens the same convenience a connection lens has: the merged elements, hasNext, isLoadingNext, and a loadNext() that runs the next page with the owner's variables. Design points, offered as questions rather than answers:
- The word. Relay has none for offset pages and neither does the GraphQL specification, so it should not be mistaken for
@connection. Something like@pagedis one candidate; the name is yours. - The key. A page's storage key already carries its arguments (
media(page:2,perPage:50,...)), so pages never share records. The merged client-side list needs a key of its own and a decision on whether it owns or references the pages' records, as the connections decision made for cursors. - The flags.
hasNextPageis the only field to trust;total,lastPageandcurrentPageare not. WhetherisLoadingNextis a client field as for connections. - The refresh. A refetch of page 1 should replace the merged list, as a cursorless connection fetch does; whether a page in the middle can be refetched alone.
- The nested case.
Media.characters(page:, perPage:)under a root resolved by a lookup.
Why this is needed
Number-paged lists are every list on this schema: the current season, search, browse with 71 filters, and on each media page its characters, staff, reviews, recommendations and airing schedule. Composing each in the UI is a page-holder per list, and a refresh batch (Page.media(id_in:), this schema's nodes(ids:)) lands in records the composed lists already reference, which works but is nothing the lens can describe. We will build the first milestones on the composition and report what it costs (lines per paged list, requests per page turn, re-renders on a page turn) on this issue.
Reproduction
- SDL: run the standard introspection query against
https://graphql.anilist.coand print it (graphql-js'sprintSchemaor a short script); 183 named types. baton.json:
{
"schema": "schema.graphql",
"identity": { "default": ["id"] },
"lookups": [{ "field": "Query.Media", "type": "Media", "argument": "id" }]
}- A paged operation:
query SeasonQuery($page: Int!, $season: MediaSeason, $seasonYear: Int) @cacheExpiration(seconds: 1800) {
Page(page: $page, perPage: 50) {
pageInfo { hasNextPage currentPage }
media(season: $season, seasonYear: $seasonYear, type: ANIME, sort: [POPULARITY_DESC]) {
id
...MediaCard_media
}
}
}batonc validate and generate pass; the lookup appears in the plan; root accessors come out lower-camel (page, media). We can attach the SDL and the full trial document set on request.
Activity
[-]A directive for page-number pagination: AniList's Page(page:, perPage:) lists are the second real schema without cursors[/-][+]A directive for page-number pagination: lists paged by page/perPage with only hasNextPage to trust (AniList's schema has no cursors)[/+]shergin commentedon Oct 18, 2026
Evidence from the first paged screens, 2026-10-18.
Two operations in the app page different list fields under the same container:
AiringWeekQueryselectsPage(page: $page, perPage: 50) { pageInfo { hasNextPage } airingSchedules(...) }andSeasonQueryselectsPage(page: $page, perPage: 50) { pageInfo { hasNextPage } media(...) }.Pagehas no identity, so the store keys it by its path and arguments, and both operations' page 1 resolve to one record,Query.Page(page:1,perPage:50). The two list fields sit in that record under their own storage keys, butpageInfois one slot: after the season's page 1 committedhasNextPage: true, the week query's empty page 1 readtruetoo and the app asked for week pages 2 and 3 it did not need (three requests against a budget of thirty a minute).The app now gives each list family its own
perPage(50, 48, 24) so their containers differ, which works and is a smell. For the directive this says the client-side page should be keyed by the list field and its arguments (airingSchedules(mediaId_in:..., page:1)), not by the container the server wraps it in, and its page info should live with that key rather than withPage. Relay's connection handler has the same shape: the client record hangs off the field, not off a wrapper.shergin commentedon Oct 19, 2026
Decided on 2026-10-18.
The answer: a directive of Baton's own, on the list field
@paged(key:, page:)goes on the list field, inside the field that takes the page, its container. Relay's@connectionmeans cursors and the GraphQL specification has no pagination, so the word is ours.The key
The pages merge into a client record of the container's type on the container's parent: the root for
Query.Page, theMediaentity forMedia.characters. It is keyed__<key>_paged(...)with the container's arguments except the page and the list field's arguments, so two lists under one wrapper, two seasons of one list and two page sizes never share it, and its page info is its own record. This answers the comment above:AiringWeekQueryandSeasonQueryshared oneQuery.Page(page:1,perPage:50)and its onepageInfoslot; under@pagedeach list has its own record and its own flag, whatever itsperPage.The merge
The first page replaces the merged list, the next appends, a page already merged rewrites its elements in place, and any other page is ignored. Elements are referenced, not copied, each record once, nulls dropped. The record's other fields and its page info follow the page that replaces or is the last.
The lens
The container's lens gains
hasNext, which readspageInfo.hasNextPageand nothing else (nottotal,lastPageorcurrentPage);isLoadingNext, a client field as for connections; andloadNext(), which runs the operation that owns the lens with its variables and the next page number. No fragment needs@refetchable, which matters here: Relay's front end could not grant one to a fragment onMediaon a schema withoutNode. A refetch of the owner replaces the list.Declared as Baton's
The directive is declared beside
@cacheExpirationin the compiler's schema extension, so Relay's front end checks its arguments and a server never sees it. It is the third directive that is not Relay's. The generated-code format rises to 21 in Swift and 5 in Kotlin.Not chosen
@connectionwith a page argument: a second meaning under Relay's word. If Relay or the GraphQL specification gains a word for a list paged by number, that word replaces ours.total,lastPageorcurrentPage, or a configurable page-info shape: the one field AniList trusts is Relay's word. The Rick and Morty API'sinfo { next }keeps reading one page per operation value until a real screen needs a second reading of the flag.offset:argument besidepage:.hasPreviousandloadPrevious(): a list paged by number starts at its first page.The record is
docs/decisions/a-paged-list-merges-its-pages-by-number.md. The compiler, both runtimes, the fixtures and the docs land in one pull request; after it merges, a second comment here will say what to write.shergin commentedon Oct 19, 2026
Landed on
masterin #56, for the next release.What to write
$pageis a variable of the query, declaredInt!(orIntwith a default that is not null), and it is not a fragment argument.pageInfo { hasNextPage }is selected beside the list; the compiler refuses the directive without it, and says so at the directive.keyends in_and the list field's name, as a connection's key does.characters(sort: [ROLE], page: $page, perPage: 25) { pageInfo { hasNextPage } edges @paged(key: "MediaCharacters_edges", page: $page) { role node { ... } } }in a fragment onMedia, with$pagea variable of the query that spreads it. No@refetchableis needed.What the view reads
The lens over
Pagereadsmediaas the merged list, and gainshasNext,isLoadingNextandloadNext()(Swiftasync throws, Kotlinsuspend).loadNext()runs the same query with the next page number and the query's other variables, soSeasonQuery(page: 1, season: WINTER, seasonYear: 2026)asks for page 2 of the same season. A refetch of the query replaces the list with its first page; an expiration does the same through revalidation.hasNextreads the merged page info'shasNextPageand nothing else.What the store holds
The pages merge under
client:root:__SeasonQuery_media_paged(perPage:50,season:"WINTER",seasonYear:2026,sort:["POPULARITY_DESC"],type:"ANIME"), with its own page info, apart from the server'sPage(page:1,perPage:50)record. The merge copies into it only the fields the operation selected of the container, so theAiringWeekQueryandSeasonQuerylists share nothing: the per-listperPageis no longer needed, and the two queries may use the same page size. Every list is one request per page turn, and a page already merged that is fetched again (a hand-runSeasonQuery(page: 2)) rewrites its own elements in place.Held by the cases
tests/paged-notes-1to-3,paged-characters-1(two lists under one server page),friends-1andfriends-2(a list on an entity), and the scriptscripts/paged.json(the second page merged, the shared page keeping each list's own flag, a page rewritten in place, a page past the end ignored, a refetch that replaces), inspec/; the merge is indocs/decisions/a-paged-list-merges-its-pages-by-number.mdand the terminology's "Paged list". Generated-code format 21 in Swift and 5 in Kotlin: rebuild with the release's compiler.One thing the build settled beyond the decision: Relay's generated refetch query of a
@refetchablefragment declares a global$pageasInt, by the schema's argument type; the compiler accepts that for the derived query alone, since the lens that refetches passes the page it read, so a@pagedlist inside a@refetchablefragment onQuerycompiles.