Skip to content

feat(api): add a network selector to the REST / GraphQL / WS API - #176

Merged
Miracle656 merged 2 commits into
Miracle656:mainfrom
royalTreasure:feat/api-network-selector
Sep 1, 2026
Merged

feat(api): add a network selector to the REST / GraphQL / WS API#176
Miracle656 merged 2 commits into
Miracle656:mainfrom
royalTreasure:feat/api-network-selector

Conversation

@royalTreasure

Copy link
Copy Markdown

Closes #163

Summary

Storage, RPC and the indexer loops are already network-aware (#159#161) — every db.ts function takes an optional network, and NETWORKS=testnet,mainnet runs a loop per chain. The API had no way to say which one it wanted, so a dual-network process could only ever serve whatever STELLAR_NETWORK said.

Every read route now takes a selector, resolved and validated once per request by a new middleware and hung off req.network, rather than each handler re-parsing the query string. Omitting the selector gives the deployment's configured network — exactly what every route read before — so existing callers see no change.

curl "http://localhost:3000/transfers/incoming/GABC…?network=mainnet"
curl -H "X-Network: mainnet" http://localhost:3000/transfers/incoming/GABC…   # query param wins

Validation

Two distinct 400s, because they need different fixes:

Request Response
?network=mainet Invalid network: "mainet". Valid values: testnet, mainnet.
?network=mainnet on a testnet-only deployment Network "mainnet" is not enabled on this deployment. Enabled networks: testnet.

Neither returns an empty result set. "No transfers" and "this process has never looked at that chain" are different statements, and answering both with [] is how a dashboard ends up confidently showing zero.

What was threaded

  • REST — transfers (incoming / outgoing / address), CSV exports, tx lookup, summary, account summary, account transfers, host-fn logs, NFT transfers and owners, search, popular assets.
  • queryHostFnLogs gained the network parameter every other db.ts function already had.
  • GraphQL — the HTTP selector reaches resolvers through the context, and each field also accepts an optional network: argument that overrides it, so one document can compare both chains in a single round-trip:
    {
      testnet: transfers(address: "GABC…", network: TESTNET) { total }
      mainnet: transfers(address: "GABC…", network: MAINNET) { total }
    }
  • WebSocketsws://host/subscribe/GABC…?network=mainnet, and the same on /graphql/subscriptions with an optional per-subscription argument.

Live events needed the network on the event

Both indexer loops publish onto one emitter, so a subscriber had nothing to filter on and would have been handed the other chain's rows. emitTransfer / emitHostFnLog now tag what they emit with the network they came from, and the subscription paths filter on it.

A socket opened with an invalid or un-enabled selector is closed with code 1008 and the reason. A subscriber cannot be told after the fact — they would sit on a socket that never delivers anything.

Health routes

  • /readyz reports per-network checks, since a single merged verdict cannot express "mainnet is behind, testnet is fine". The database is checked once rather than per network — a dead database is not a per-chain condition — and a 503 down still reports every network.
  • /status and /readyz both name the network their top-level fields describe; the existing per-network networks map on /status is unchanged.
  • The RPC health cache behind the stale-read middleware is now keyed by network, so a mainnet RPC outage no longer marks testnet reads stale.

Not included

The OHLC candle routes. ohlc.candles_* has no network column, so adding the dimension there is a storage/SQL change rather than an API one, and the router is not currently mounted.

Tests

  • src/__tests__/networkSelector.test.ts (16) — default / query / header / precedence / case-and-whitespace handling, threading into each read route, both 400 shapes, rejection before any DB call, per-network /readyz including one network degraded while the other is healthy, /status scoping, and per-network staleness headers.
  • src/__tests__/networkSelectorGraphqlWs.test.ts (13) — GraphQL context inheritance, field-level override, un-enabled network error, schema-level enum rejection, resolveSocketNetwork unit cases, and a live WebSocket asserting a ?network=mainnet subscriber receives only mainnet rows and that a bad selector closes with 1008.
  • Three existing assertions updated to expect the network argument now being passed.

Verification

  • npm test — 319 passed across 28 suites (was 290/26).
  • npx tsc --noEmit — clean.
  • npm run docs:openapi — regenerated; network documented on every read path plus the new /status and /readyz response fields.

Storage, RPC and the indexer loops were already network-aware (Miracle656#159-Miracle656#161), but
the API had no way to say which network it wanted: a process indexing both
chains could only ever serve whatever STELLAR_NETWORK said.

Every read route now accepts `?network=` or an `X-Network` header, resolved and
validated once by a new middleware and threaded into the data layer as
`req.network`. Omitting it keeps the previous behaviour exactly.

Two distinct 400s, because they need different fixes: a value that is not a
network at all is a typo, while a real network this deployment does not index
means the caller wants a different deployment — and the message names the ones
this one serves. Neither returns an empty list: "no transfers" and "this process
has never looked at that chain" are different statements.

Threaded through the transfers, summary, tx, host-fn, NFT, accounts, search,
export and popular-asset routes. queryHostFnLogs gained the `network` parameter
every other db function already had.

GraphQL reads the same HTTP selector through its resolver context, and each
field also takes an optional `network:` argument that overrides it, so one
document can compare both chains in a round-trip.

Live subscriptions needed the network on the event itself: both loops publish to
one emitter, so `emitTransfer`/`emitHostFnLog` now tag what they emit and
/subscribe filters on it. An invalid selector closes the socket with 1008 and a
reason rather than leaving it open delivering nothing.

/readyz reports per-network checks (the DB is checked once — a dead database is
not a per-chain condition), and both /readyz and /status name the network their
top-level fields describe while still reporting every enabled one. The RPC
health cache is keyed by network so a mainnet outage cannot mark testnet reads
stale.

The OHLC candle routes are untouched: `ohlc.candles_*` has no network column,
which is a storage change rather than an API one.
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@royalTreasure Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Both conflicts were additive unions:
- src/api.ts: Miracle656#175's metrics import beside Miracle656#163's network middleware import.
- README.md: Miracle656#175's /metrics section beside Miracle656#163's /readyz section, with
  both /status notes kept under the /status example.

openapi.json regenerates identically from src/openapi/build.ts after the
merge, so the committed document is not stale.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved and merging — I resolved the conflict with #175 on your branch and verified the result.

This completes the #159#160#161#163 chain, and it is the hardest link in it because the network dimension has to reach places that are easy to forget. It reached all of them.

The rejection design is the part I want to single out. Two distinct 400s rather than one:

  • "mainet" → not a network at all, so the caller has a typo;
  • "mainnet" on a testnet-only deployment → a real network this process does not index, and the message names the ones it does serve.

And critically, neither returns an empty list. "no transfers" and "this process has never looked at that chain" are different statements, and quietly conflating them is exactly how a dashboard ends up confidently rendering a flat zero for a chain nobody is indexing. Choosing the noisy answer here is right.

Other things done correctly rather than adequately:

  • Query beats header. A URL someone can paste and share should win over a default their HTTP client set for them.
  • The RPC health cache is keyed by network. A single cache entry would let a mainnet RPC outage mark testnet reads stale — a cross-network false alarm, which is the worst kind because it points at the healthy system.
  • /readyz checks the DB once, not per network. A dead database is not a per-chain condition, and reporting it twice would imply it might be.
  • WS subscriptions filter on a network tag carried by the event itself. Both loops publish to one emitter, so this had to be pushed down into emitTransfer/emitHostFnLog rather than solved at the socket. Closing an invalid selector with 1008 and a reason also beats the alternative — a socket that stays open forever delivering nothing looks identical to a quiet chain.
  • Leaving the OHLC routes alone. ohlc.candles_* has no network column; adding a selector there would be an API promise the storage cannot keep. Correctly identified as a storage change, not an API one.

Verified locally after merging main: tsc --noEmit clean, full suite 328/328, and npm run docs:openapi regenerates openapi.json byte-identical to the committed copy — so the spec is not stale.

One nit for later, nothing blocking: the invalid-network 400 echoes the raw selector back unbounded. It is JSON-encoded so there is no injection, but worth truncating to ~32 chars on principle.

Excellent work — this and #175 together are the strongest pair in the wave.

@Miracle656
Miracle656 merged commit f923671 into Miracle656:main Sep 1, 2026
3 of 5 checks passed
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.

Add a network selector to the REST / GraphQL / WS API

2 participants