Repository navigation
Add GraphQL screener query using shared REST filtering and pagination logic - #609
Conversation
…loses Divinity-Labs#367) Clients had no way to ask GraphQL for the filtered, sorted slice of pairs that GET /screener serves — the whole point of GraphQL. Extract the route's query into src/services/screener.ts and expose it as a screener query with input-type filters, the same sort fields, the same [1, 100] limit clamp and the same cursor pagination. market_cap is rejected with the exact explanation REST gives (UNSUPPORTED_PARAMETER / INVALID_INPUT), never a silent null. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
…loses #367) `screener(filters, sortBy, order, cursor, limit)` is now a GraphQL query, and the filtering, sorting, keyset cursor and limit clamp live once in `src/services/screener.ts` with both REST and GraphQL calling it. That is the right shape for this: a second implementation of the same query is how the two surfaces end up disagreeing about what `?limit=500` or `sortBy=liquidity` means, and `/screener` lost 199 lines to the extraction rather than gaining a parallel copy. The details that make it safe were already right and I changed nothing: - **`sortBy` and `order` are allow-listed at the SQL boundary**, before they are interpolated into `ORDER BY` and into the keyset comparison. They have to be interpolated — a column name cannot be a bind parameter — so the runtime check against `ALLOWED_SORT_FIELDS` is load-bearing, and the comment says so. - **The limit clamp is in the service**, so GraphQL's `limit: Int = 20` goes through the same `Math.min(100, Math.max(1, …))` as REST. A clamp on only one side would have made the new query an amplification of the old one. - **Historical REST quirks are preserved deliberately**, not tidied into a 400: `?limit=500` still returns 100 rows and `limit=0` still behaves as 1, with a comment recording that this is existing behaviour rather than an oversight. - **Every CTE is scoped to one network.** These tables hold both chains and the indexes lead with `network`; pooling them would rank a pair on the other chain's data. - Keyset pagination via row comparison `(sortBy, pair_key) < ($n, $m)`, so a row inserted mid-page cannot shift a boundary. 130 test files / 1,563 tests pass, including the `schema.graphql` drift guard and the existing screener suite; tsc clean. Two pre-existing problems this makes more visible, neither introduced here and both tracked separately: - `/graphql` is registered by Mercurius with no route `config`, so it carries no `scope` requirement. Every query already reachable there bypasses the scope its REST equivalent demands — `getPrice` and `oracle` against `read:prices`, `getPriceHistory` against `/candles`'s `read:history` — and `screener` now joins them. An API key is still required; only the scope check is skipped. - The CTEs carry `volume::float`, `close_price::float` and `liquidity::float` over from the old route. Casting inside an aggregate is the #230 bug, and it now sits in a service two surfaces read. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
|
Merged as The decision that makes it good is that Four details I checked specifically, because they are where this kind of change usually goes wrong:
const sortBy = opts.sortBy ?? 'volume'
if (!(ALLOWED_SORT_FIELDS as readonly string[]).includes(sortBy)) throw new ScreenerSortError(…)
const order = (opts.order ?? 'desc').toLowerCase()
if (order !== 'asc' && order !== 'desc') throw new ScreenerSortError(…)A column name cannot be a bind parameter, so The limit clamp lives in the service, not the route. GraphQL's Historical REST quirks are preserved deliberately rather than tidied. Every CTE is scoped to one network, with a comment explaining that these tables hold both chains and pooling them would rank a pair on the other chain's data. That is the The keyset condition is a row comparison, 130 test files / 1,563 tests pass, including the Two pre-existing problems this made visibleNeither is yours and neither blocked the merge, but you should know about both since you now own a GraphQL resolver. 1. GraphQL carries no scope requirement — filed as #618. Mercurius registers 2. The CTEs still cast inside aggregates. If you want another issue, #618 is yours if you'd like it — you have just read the surrounding code more closely than anyone. |
Problem / Goal
The GraphQL API is missing the screener query that clients most want to use: a filtered, sorted, paginated view of pairs based on the same behavior already exposed by the REST route.
Today, the REST endpoint supports filtering and sorting by volume, 24h change, price, and liquidity, along with cursor pagination, but GraphQL does not expose this as a first-class query. This gap prevents clients from using GraphQL in the way it is intended: asking for exactly the slice of data they need with the fields they want.
What to build
Implementation notes
Acceptance criteria
Validation
closes There is no screener query #367