Page plus next-token, as one repository contract
SkillAI & modelsModel an endpoint that returns a page plus a next-token as Flow<Resource<Pair<items, token?>>> — a null token means the end, the caller stores only the token, and a bounded prefetch primes the first pages before anything is shown. Use when wiring an opaque-cursor API into a repository, or when a list stops loading after one failed request and never recovers, or when paging fires twice for one trigger.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Page plus next-token, as one repository contract skill
What this skill tells your AI
The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/continuation-token-pagination-contract/SKILL.md and read by ahel’s review.
The API answers with items and an opaque token for the next page. Everything downstream follows from stating that once, in the return type:
// adapted: parameter names generalized; signatures otherwise as written
fun getRelatedData(id: String): Flow<Resource<Pair<List<Track>, String?>>>
fun getRadioFromEndpoint(endpoint: Endpoint): Flow<Resource<Pair<List<Track>, String?>>>
Pair(items, token)— the page and the way to ask for the next one.nulltoken means end of pages. Nothing else may mean that.- The envelope (
Resource, seerepository-resource-flow-pattern) carries failure, so a failure is never expressed as an empty page or a null token.
The consumer stores exactly one thing — the token — and never a page number:
// adapted: names generalized; structure as written
override fun loadMore() {
if (queueData.value.queueState == StateSource.STATE_INITIALIZING) return // re-entrancy guard
val token = queueData.value.data.continuation ?: return
scope.launch {
_queueData.update { it.copy(queueState = StateSource.STATE_INITIALIZING) }
songRepository.getContinueTrack(playlistId, token).lastOrNull().let { response ->
val list = response?.first
if (list != null) {
loadMoreCatalog(list) // restores the flag
_queueData.update { it.copy(data = it.data.copy(continuation = response.second)) }
} else {
_queueData.update { it.copy(data = it.data.copy(continuation = null)) }
// …end-of-pages handling, which must also restore the flag
}
}
}
}
The in-flight flag that makes loadMore() safe to call from a scroll listener and from an
end-of-queue event belongs to queue-rebuild-state-machine; what matters here is that the paging
path is one of its users and has to honour it on every exit.
Traps
A paged read that cannot report failure makes a network hiccup indistinguishable from the end of
the list. One read in this interface returns a bare Flow<Pair<List<T>?, String?>> instead of the
envelope, and its implementation has nowhere to put an error, so it does the only thing left:
.onFailure { emit(Pair(null, null)) }
The consumer above reads list == null, writes continuation = null, and stops paging — for good,
because the token it needed to retry has just been erased. One dropped request permanently truncates
the list and there is nothing in the state to distinguish it from a list that genuinely ended. Put
every paged read behind the same envelope — and then read the next trap, because the envelope
alone does not fix this. It buys you a distinguishable failure; it does not stop you throwing that
distinction away. The enveloped consumer in this same codebase pattern-matches a clean
is Resource.Error and clears the token in that branch anyway, arriving at the identical permanent
end by a longer route.
Clear the token when you stop, and only when you stop. An exhausted token left in state is retried on every subsequent trigger, which is a request per scroll that can only ever answer "no". The error path has to make the opposite choice: it should keep the token, because that page is still retryable — clearing it there is what turns a transient failure into a permanent end. The envelope makes the failure visible; only the failure branch can keep the list recoverable, and the two are separate edits. Auditing one without the other is why this bug survives the migration that was supposed to remove it.
The failure path of a prefetch loop must advance the loop. The bounded prefetch that primes the
first pages reassigns the token inside onSuccess only. It does not live behind the two
signatures above — it is a sibling repository's paged read, the one that builds a radio queue —
so do not go looking for it under those methods; the point is that the same contract is served from
more than one place and only one of them prefetches:
// adapted — names generalized; literals kept, they are the mechanism
var count = 0
while (continuation != null && count < 3) {
remote.next(endpoint, continuation = continuation)
.onSuccess { page ->
data.addAll(page.items)
continuation = page.continuation
if (data.size >= 50) count = 3 // enough to start; stop early
count++
}
.onFailure { count = 3 } // without this, the same page forever
}
On failure the token still holds its old value and the condition is unchanged, so omitting the counter jump is not a retry — it is an unbounded loop re-requesting one page. Whatever the failure policy, the loop variable has to move.
Prefetch needs two stop conditions because pages are of unknown size. A budget of three pages might yield six items or six hundred; a target of fifty items might never arrive if the source returns short pages. The page budget bounds the work, the accumulated-item target expresses the actual goal ("enough to start"), and either one alone is the wrong guarantee.
Terminal operators are part of the contract, and they disagree. Call sites here use
.lastOrNull(), .single() and .collect { } against paged reads in the same file. They behave
differently the moment a read emits more than once — single() throws, lastOrNull() silently
drops the earlier emission — so a repository that later adds a cached first emission (see
cache-then-network) breaks some callers loudly and others invisibly. Decide whether a paged read
emits once, write it down in the interface, and keep the operators consistent with it.
A two-branch when over the envelope has to do two jobs on the error branch. Clear or keep the
token and release the in-flight flag. Releasing only on success is the classic version of this
bug: the first failure leaves the flag set, and the guard at the top of loadMore() then rejects
every future call — the list stops loading and no error is ever shown, because the code that would
have shown one never runs again.
Verifying it
Force a failure — a happy-path run proves none of this.
- Make the second page fail. The list must keep the first page, keep its token, and still page when triggered again. If it never loads again, the flag was not released; if it reports the end of the list, failure and end are sharing a representation.
- Log the token on every request and assert it changes. A loop re-requesting one page looks like healthy traffic in a network inspector and identical in the UI to a slow feed.
- Assert the final token is
nullafter the real last page, then trigger once more and assert no request is made.
Signals
- GitHub stars
- 315
- Forks
- 6
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
continuation-token-pagination-contract- Source
- github.com/maxrave-dev/kotlin-footguns