Skip to content

Add GraphQL screener query using shared REST filtering and pagination logic - #609

Merged
Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
danvenger:freebuff/73ba9d61ecf71cdb0791cfb8
Oct 23, 2026
Merged

Miracle656 merged 1 commit into
Divinity-Labs:mainfrom
danvenger:freebuff/73ba9d61ecf71cdb0791cfb8

Conversation

@danvenger

Copy link
Copy Markdown
Contributor

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

  • Add a screener query to GraphQL with the same filters, sort fields, and cursor pagination as the REST route
  • Model filters as input types instead of a long list of individual arguments
  • Reuse the existing REST implementation rather than creating a separate code path
  • Reject market_cap in the same way the REST route does, using the existing REST explanation for Lens lacking circulating supply data
  • Do not silently return null for this case; fail in the same explicit way as the REST route

Implementation notes

  • Align GraphQL screener behavior with the existing REST contract
  • Reuse the REST implementation so filtering, sorting, and pagination semantics stay consistent
  • Preserve the same validation/error behavior for unsupported filter combinations
  • Ensure the GraphQL layer exposes a clean, typed input model for filters and sort options

Acceptance criteria

  • Filtering, sorting, and cursor pagination all work
  • Filters are expressed as input types
  • The implementation is shared with REST
  • market_cap is rejected with the same explanation REST gives
  • A test covers one filter, one sort, and one page boundary

Validation

  • Screener query returns the expected filtered results
  • Sort order matches the REST route behavior
  • Cursor pagination works across page boundaries
  • Invalid market_cap usage returns the same explicit explanation as REST
  • GraphQL and REST remain aligned on the underlying screener logic
    closes There is no screener query #367

…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>
@Miracle656
Miracle656 merged commit e11356b into Divinity-Labs:main Oct 23, 2026
Miracle656 added a commit that referenced this pull request Oct 23, 2026
…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
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged as 1e7da6d. This is the right way to add a second surface over existing logic, and I changed nothing in it.

The decision that makes it good is that /screener lost 199 lines rather than GraphQL gaining a parallel copy. A second implementation of the same query is exactly how two surfaces end up disagreeing about what ?limit=500 or sortBy=liquidity means, and that divergence is invisible until someone compares them. Extracting src/services/screener.ts and having both call it means there is one answer.

Four details I checked specifically, because they are where this kind of change usually goes wrong:

sortBy and order are allow-listed before interpolation.

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 ORDER BY ${sortBy} ${order} and the keyset comparison (${sortBy}, pair_key) have to be interpolated — which makes that runtime check the only thing standing between ?sortBy= and the SQL. You put it there and said so in the comment ("allowlisted here as the last line of defence"). That is the single most important line in the PR.

The limit clamp lives in the service, not the route. GraphQL's limit: Int = 20 goes through the same Math.min(SCREENER_MAX_LIMIT, Math.max(1, …)) as REST. Had the clamp stayed in the REST handler, the new query would have been an amplification of the old one — a limit: 100000 nobody capped.

Historical REST quirks are preserved deliberately rather than tidied. ?limit=500 still returns 100 rows and limit=0 still behaves as 1, with a comment recording that this is existing behaviour and not an oversight. Turning those into a 400 would have been a silent breaking change for anyone already relying on the clamp.

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 ?network= discipline the issue text asks for, and it is easy to lose when moving SQL between files.

The keyset condition is a row comparison, (sortBy, pair_key) < ($n, $m), so a row inserted mid-page cannot shift a boundary — which is the property the cursor is for.

130 test files / 1,563 tests pass, including the schema.graphql drift guard (so the SDL you wrote matches printSchema()) and the existing screener suite. tsc --noEmit clean.


Two pre-existing problems this made visible

Neither 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 /graphql with no Fastify route config, so routeConfig.scope is undefined and the scope loop in the auth hook never runs. An API key is still required, but a key with no scopes can read everything through GraphQL, while REST /screener demands read:prices. getPrice, getBestRoute, getPriceHistory and oracle were already in that position; screener joins them. The fix is a per-field scope declaration checked in one place, and #618 has the shape of it — including the criterion that matters, a test that fails when a resolver is added without declaring a scope.

2. The CTEs still cast inside aggregates. COALESCE(volume::float, 0), close_price::float in the change_24h expression, COALESCE(pl.liquidity::float, 0). That is the #230 bug: a 53-bit double per row before the aggregate, so the error accumulates order-dependently. You carried it over from the old route rather than introducing it, but it now sits in a service two surfaces read, which is a reason to fix it sooner rather than later. #470 is the open PR for that work.

If you want another issue, #618 is yours if you'd like it — you have just read the surrounding code more closely than anyone.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

There is no screener query

2 participants