feat(api): add a network selector to the REST / GraphQL / WS API - #176
Conversation
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.
|
@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! 🚀 |
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
left a comment
There was a problem hiding this comment.
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.
/readyzchecks 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/emitHostFnLograther 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 nonetworkcolumn; 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.
Closes #163
Summary
Storage, RPC and the indexer loops are already network-aware (#159–#161) — every
db.tsfunction takes an optionalnetwork, andNETWORKS=testnet,mainnetruns a loop per chain. The API had no way to say which one it wanted, so a dual-network process could only ever serve whateverSTELLAR_NETWORKsaid.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.Validation
Two distinct 400s, because they need different fixes:
?network=mainetInvalid network: "mainet". Valid values: testnet, mainnet.?network=mainneton a testnet-only deploymentNetwork "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
queryHostFnLogsgained thenetworkparameter every otherdb.tsfunction already had.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 } }ws://host/subscribe/GABC…?network=mainnet, and the same on/graphql/subscriptionswith 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/emitHostFnLognow 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
1008and the reason. A subscriber cannot be told after the fact — they would sit on a socket that never delivers anything.Health routes
/readyzreports per-networkchecks, 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 a503 downstill reports every network./statusand/readyzboth name the network their top-level fields describe; the existing per-networknetworksmap on/statusis unchanged.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/readyzincluding one network degraded while the other is healthy,/statusscoping, 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,resolveSocketNetworkunit cases, and a live WebSocket asserting a?network=mainnetsubscriber receives only mainnet rows and that a bad selector closes with 1008.Verification
npm test— 319 passed across 28 suites (was 290/26).npx tsc --noEmit— clean.npm run docs:openapi— regenerated;networkdocumented on every read path plus the new/statusand/readyzresponse fields.