Data
One upstream API, three layers over it, and the failure modes the boundary exists to absorb.
Everything the app knows comes from one upstream API. The data layer exists to make that API's quirks somebody else's problem, once, rather than something each screen has to remember.
Three levels
There is a stack under lib/api, and it is worth knowing which level you are
working at before you start typing.
The bottom is the client and the endpoint modules. The generic JSON transport
comes from statshub-api-client, shared with web.
The app-level client adds the upstream-specific behavior: Cloudflare backoff,
cached-5xx recovery, and the API's inconsistent empty-result envelopes. One
endpoint module exists per documented surface: events, odds, players,
referees, and so on.
The middle is a feature's model. Pure functions over the shapes the endpoints return. Given a row, what is its hit rate, what should it be labelled, is it worth showing. No fetching, no React.
The top is a feature's queries. These are the things a screen actually calls.
Read from the top, write from the bottom. A screen that needs something new should almost always be adding a query, not calling an endpoint directly.
Resources
Queries are not written by hand. You declare a resource once — its key, how to fetch it, how long it stays fresh — and get back the hook, the imperative fetch, the prefetch and the invalidation, all agreeing on the same key.
That last part is the point. The classic caching bug is a mutation invalidating one key while the query reads a slightly different one, so the screen silently never refreshes. Declaring the key once makes that unrepresentable.
Fetchers take an AbortSignal last and forward it, which is what makes queries
cancellable when a screen unmounts mid-flight.
What the boundary is protecting you from
The upstream API has a set of failure modes that all look like success. Every
one of them returns 200 OK with a plausible body and the wrong data. They are
handled once, at the boundary, rather than remembered at each call site.
There are three different kinds of tournament ID, none interchangeable, all plain numbers on the wire. They are branded types here, so passing the wrong one is a compile error instead of another league's fixtures appearing on screen. This is the single most common way to get this API wrong.
Stat type names are unvalidated upstream. A typo returns an empty list rather than an error. The vocabulary is a union type, and the player and team sets are deliberately separate types because they are different vocabularies.
Pagination comes in three different envelopes, plus some routes that omit theirs entirely. They are normalised to one shape.
Several endpoints return numbers as strings. They are coerced at the boundary, so a string never reaches a comparison and quietly sorts wrong.
Errors are not uniform either: plain-text 405s that throw on JSON parse, and HTML from the CDN on 403, 429 and 503. The client parses defensively and backs off rather than retrying tight.
If you find a new one of these, fix it at the boundary and write down what it was. That comment is worth more than the fix.
Caching
Stale times are chosen per resource from how often the underlying thing actually changes. A finished match does not change. A live score changes constantly. A player's season totals change once a week.
Do not reach for a shorter stale time to fix a screen that looks out of date. Work out which write should have invalidated it and invalidate there.