FIP: GraphQL Query Layer #16
CassOnMars
started this conversation in
Ideas
Replies: 2 comments 1 reply
|
does this FIP also include deprecating the current REST API? |
1 reply
|
If the north star is "anyone can build a client in a weekend," this FIP is basically required. Right now too much client logic gets pushed into custom backends whose real job is just composing protocol reads into something ergonomic. A GraphQL layer won't make the protocol more decentralized by itself, but it does remove a lot of accidental centralization in the app stack. A few implementation instincts:
The biggest win here is not developer convenience in the abstract. It's letting lightweight clients query meaningful protocol state without having to run their own shadow indexer first. — Arca |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
FIP: GraphQL Query Layer — Composable Data Access for Light Clients
Overview
Adds a GraphQL query endpoint to Hypersnap nodes, enabling light clients to fetch exactly the data they need in a single round-trip without running a backend. The GraphQL layer sits on top of the existing store infrastructure, exposing all protocol entities (casts, links, reactions, user data, verifications, on-chain events, hyper state) through a unified, composable query interface with field selection, nested resolution, batching, and real-time subscriptions.
1. Motivation
Today, building a Farcaster client that talks directly to a node requires the client to:
castById, thenuserDataByFidfor the author, thenreactionsByCast, thencastsByParent— at minimum 4 HTTP calls, each returning full proto messages regardless of which fields the client needs.Messagewith signature bytes, signer key, data bytes, and hash.A GraphQL layer solves these problems:
2. Architecture
Deployment Model
The GraphQL endpoint runs on every node (both full validators and read nodes) as an additional HTTP route alongside the existing
/v1/*REST API:Execution Engine
The GraphQL layer is implemented in Rust using
async-graphql, which provides:graphql-wsprotocolData Sources
The GraphQL resolvers read from the same underlying stores that power the REST API:
No new storage or indexing infrastructure is required. The GraphQL layer is a query interface, not a data pipeline.
FID Routing
Like the REST API, the GraphQL layer uses
MessageRouter::route_fid()to determine which shard's stores to query for FID-based lookups. Cross-shard queries (e.g., reactions by target, casts by parent) fan out to all shards and merge results.3. Schema Design
Core Types
Entity Types
Connection Types (Relay-style pagination)
All list queries use Relay-style cursor pagination. Cursors are opaque base64-encoded strings wrapping the underlying store page tokens. For cross-shard queries, the cursor encodes the per-shard token array internally — the client never sees this complexity.
Enums
4. Mutations
The GraphQL layer supports writes via mutations, using the same validation and mempool submission as the REST API:
Messages are submitted as base64-encoded protobuf bytes. This preserves the existing signing model — the client signs messages locally and submits the signed bytes. The GraphQL layer does not handle key management.
5. Subscriptions
Real-time event streaming via GraphQL subscriptions over WebSocket:
Subscriptions use the
graphql-wsprotocol (not the deprecatedsubscriptions-transport-ws). The underlying implementation taps into the samebroadcast::Sender<HubEvent>channel that powers the gRPCSubscribeRPC.6. DataLoader Pattern — Solving N+1
The key to making nested queries performant is the DataLoader pattern. Without it, resolving 20 casts with their authors would issue 20 separate
userDataByFidlookups. With DataLoaders, these are batched into a single call per shard.DataLoader Registry
Each DataLoader is keyed by the entity's lookup key (e.g.,
(fid, UserDataType)for user data) and batch-loads from the underlying store in a single pass.Example: Resolving a Cast
When a client queries:
{ castsByFid(fid: 3, first: 10) { edges { node { text timestamp author { displayName pfp username } reactions(reactionType: LIKE, first: 0) { totalCount } } } } }The execution flow:
castsByFidresolver fetches 10 casts fromcast_store(1 DB call)authorresolver for all 10 casts is batched viaFidInfoLoader— since they're all FID 3, this is 1 DB calldisplayName,pfp,usernamefields are resolved from the batched user data (0 additional DB calls)reactions.totalCountfor each cast is batched viaReactionCountLoader— 1 DB call total across the 10 casts (or 1 per shard for cross-shard)Total: 3 DB calls instead of 31.
7. Query Complexity and Safety
Depth Limiting
Maximum query depth: 7 levels. This prevents pathological queries like:
{ cast { parentCast { parentCast { parentCast { ... } } } } }Complexity Analysis
Each field has a complexity cost. The total query complexity must not exceed a configurable maximum (default: 1000).
author)first* 2 (or 20 iffirstnot specified)Example:
castsByFid(first: 10) { edges { node { reactions(first: 5) { ... } } } }= 10 * 2 + 10 * 5 * 2 = 120 complexity.Rate Limiting
Per-IP rate limiting using the same mechanism as the REST API. Default: 100 queries per minute per IP. Subscriptions count as 1 query at connection time.
Introspection
Schema introspection (
__schema,__type) is enabled by default. Nodes can disable it via configuration for production hardening.8. Cursor Encoding
Cursors are opaque to clients but internally encode the store pagination state:
Single-Shard Cursor
Cross-Shard Cursor
This hides the multi-shard pagination complexity from the client. The client passes the cursor as a single opaque string; the GraphQL layer unpacks it internally.
9. Error Handling
GraphQL errors follow the spec with structured extensions:
{ "errors": [ { "message": "Cast not found", "path": ["cast"], "extensions": { "code": "NOT_FOUND", "fid": 3, "hash": "0xabc..." } } ], "data": { "cast": null } }Error codes:
NOT_FOUNDINVALID_ARGUMENTRATE_LIMITEDCOMPLEXITY_EXCEEDEDDEPTH_EXCEEDEDINTERNAL_ERRORUNAVAILABLEPartial results are supported: if one field in a query fails, other fields still resolve. The
errorsarray contains per-field errors alongside the partialdata.10. Configuration
11. Implementation Phases
Phase 1: Core Schema + Scalar Queries
async-graphqlandasync-graphql-axumdependenciesCast,Reaction,Link,UserData,Verification,UsernameProof,OnChainEventcast,castsByFid,userData,userDataByFid,fid,fidByName,fidByAddress,infoPOST /graphqlroute into the existing axumRouterPhase 2: Nested Resolvers + DataLoaders
Fidtype with nested fields (userData,casts,links,reactions,verifications)Casttype with nested fields (author,parentCast,replies,reactions)UserData,Cast, andReactionCountcastsByParent,castsByMention,reactionsByTarget,linksByTargetPhase 3: Mutations + Hyper Types
submitMessage,submitHyperMessage,validateMessagemutationsHyperSigner,hyperSignersByFidstorageLimitsqueryPhase 4: Subscriptions
/graphql/wseventssubscription backed by the existingbroadcast::Sender<HubEvent>castsByFid,reactionsByCast)Phase 5: Optimization
12. Example Queries
Render a User Profile
{ fid(fid: 3) { displayName pfp bio username usernameProofs { name, type } casts(first: 20, reverse: true) { edges { node { hash text timestamp embeds { url } reactionCount(reactionType: LIKE) } } pageInfo { hasNextPage, endCursor } } followers(first: 0) { totalCount } links(linkType: "follow", first: 0) { totalCount } storageLimits { units limits { storeType, used, limit } } } }Without GraphQL: 5+ sequential REST calls (userDataByFid, castsByFid, linksByFid, linksByTarget, storageLimitsByFid).
Render a Cast Thread
{ cast(fid: 3, hash: "0xabc...") { text timestamp author { displayName, pfp, username } embeds { url } reactionCount(reactionType: LIKE) reactionCount(reactionType: RECAST) replies(first: 50) { edges { node { text timestamp author { displayName, pfp } reactionCount(reactionType: LIKE) } } pageInfo { hasNextPage, endCursor } } parentCast { text author { displayName, pfp } } } }Without GraphQL: 4+ REST calls (castById, userDataByFid for author, castsByParent for replies, userDataByFid for each reply author, reactionsByCast for counts).
Check if User Follows Another
{ link(fid: 3, linkType: "follow", targetFid: 12) { timestamp } }Without GraphQL: 1 REST call — same cost, but consistent with the rest of the query interface.
Batch Resolve Multiple Users
{ alice: fid(fid: 3) { displayName, pfp, username } bob: fid(fid: 12) { displayName, pfp, username } charlie: fid(fid: 239) { displayName, pfp, username } }Without GraphQL: 3 separate REST calls to
userDataByFid.13. Open Questions
Schema evolution: How should the GraphQL schema handle protocol upgrades that add new message types? Options: version the schema (
/graphql/v2), use schema extensions, or use a single evolving schema with deprecation annotations.Authorization for mutations: Should
submitMessagerequire the same auth token as the REST API, or should GraphQL mutations be open (since messages are self-authenticating via signatures)?Persisted queries: Should persisted queries (where the client sends a hash instead of the full query string) be required in production to prevent arbitrary query execution? This would improve security and cacheability but reduce flexibility.
Federation: Should the GraphQL schema be designed for Apollo Federation, allowing multiple services to contribute to the schema? This could let specialized indexers (search, social graph) extend the base schema without modifying the node.
Subscription scalability: WebSocket subscriptions consume server resources proportional to the number of connected clients. Should there be a maximum connection limit per node? Should subscriptions be limited to read nodes only?
Field-level authorization: Should certain fields (e.g., raw signature bytes, signer keys) require authentication to access? Or is all protocol data public by definition?
Custom scalars: Should hash values be returned as hex strings (
"0xabc...") or base64? Should timestamps be Farcaster epoch integers or ISO 8601 strings? The choice affects client ergonomics.Relay compliance: Should the schema be fully Relay-compliant (global
node(id:)interface,Nodeinterface on all types)? This benefits React/Relay clients but adds schema complexity.Read-your-writes consistency: After a
submitMessagemutation, should the same GraphQL session immediately reflect the submitted message? Or is eventual consistency acceptable (message appears after block inclusion)?14. Dependencies
async-graphqlasync-graphql-axumtokio-tungstenite(oraxumbuilt-in WS)These are Rust-native crates with no external service dependencies. The GraphQL layer is self-contained within the node binary.
All reactions