api-graphql-mercurius
GraphQL server for Fastify with Mercurius — loaders, subscriptions, federation, JIT compilation
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-graphql-mercurius/skills/api-graphql-mercurius
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
GraphQL with Mercurius
Quick Guide: Use Mercurius as a Fastify plugin for GraphQL APIs with built-in loader batching (solves N+1), JIT query compilation, subscriptions via WebSocket, and federation support. Register with
app.register(mercurius, { schema, resolvers, loaders }). Loaders are Mercurius's killer feature: define them per-type to batch field resolution automatically. Usejit: 1to enable query compilation for production performance.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST define loaders for any field that fetches related data — loaders solve the N+1 problem automatically through batching)
(You MUST use named constants for all numeric values — JIT thresholds, query depth limits, port numbers)
(You MUST return an array from loaders matching the exact length and order of the queries parameter)
(You MUST use fastify.graphql.pubsub.publish() inside mutations to trigger subscriptions — not external pubsub directly)
</critical_requirements>
Auto-detection: Mercurius, mercurius, app.graphql, fastify.graphql, mercurius loaders, mercurius subscription, pubsub.publish, pubsub.subscribe, @mercuriusjs/federation, @mercuriusjs/gateway, mercurius-codegen, MercuriusContext, graphql-jit, withFilter, preParsing, preValidation, preExecution, onResolution
When to use:
- Building GraphQL APIs on Fastify (Mercurius is Fastify-native)
- Need automatic batching/caching for N+1 query prevention (loader system)
- Want JIT query compilation for production performance
- Building federated GraphQL services with
@mercuriusjs/federation - Need real-time subscriptions via WebSocket with built-in pubsub
- Want GraphQL lifecycle hooks (preParsing, preValidation, preExecution, onResolution)
When NOT to use:
- Not using Fastify (Mercurius is Fastify-only)
- Need a framework-agnostic GraphQL server
- Building a standalone schema-first design tool (use the schema library directly)
- Simple REST endpoints without GraphQL requirements
Key patterns covered:
- Plugin registration with schema, resolvers, and loaders
- Loader system for batched data fetching (the core differentiator)
- JIT compilation configuration for production performance
- Subscriptions with built-in pubsub and
withFilter - Federation services and gateway composition
- TypeScript context typing with
MercuriusContextaugmentation - GraphQL lifecycle hooks for cross-cutting concerns
Detailed Resources:
- examples/core.md - Registration, resolvers, loaders, context, error handling, testing
- examples/subscriptions.md - Pubsub, subscription resolvers, withFilter, WebSocket config
- examples/federation.md - Federated services, gateway, __resolveReference as loader
- reference.md - Decision frameworks, hook lifecycle, plugin options, anti-patterns
<red_flags>
RED FLAGS
High Priority Issues
- No loaders defined for related data fields — Every field that fetches associated data (e.g.,
User.posts,Post.author) should use a loader, not inline resolver queries. Without loaders, you get the classic N+1 problem. - Loader returns wrong length/order — The returned array MUST match the
queriesarray by index. Returning fewer/more items or in wrong order corrupts the response silently. __resolveReferenceas resolver instead of loader in federation — Causes N+1 on entity resolution across services. The docs strongly recommend defining it as a loader.- JIT left at default (disabled) —
jit: 0means no JIT compilation. Setjit: 1for production workloads with repeated queries.
Medium Priority Issues
- Not using
contextfunction for per-request data — Accessing request headers or auth tokens requires a context builder function, not Fastify decorators alone - GraphiQL enabled in production — Set
graphiql: falseor conditionally disable based onNODE_ENV - Missing
queryDepthlimit — Without depth limiting, deeply nested queries can exhaust server resources - Modifying schema/document in
preExecution— Disables JIT compilation for that query execution
Common Mistakes
- Using external DataLoader instead of Mercurius loaders — Mercurius loaders are built-in and request-scoped by default; no need for manual DataLoader instantiation
- Forgetting
subscription: truein registration — Subscriptions are disabled by default; subscription resolvers silently fail without this option - Publishing with wrong payload shape — The
payloadinpubsub.publish()must match the subscription field name exactly (e.g.,{ notificationAdded: data }for anotificationAddedsubscription) - Calling
addHookbeforeapp.ready()— GraphQL hooks must be registered afterapp.ready()or inside a Fastify plugin that ensures readiness
Detailed anti-pattern code examples: reference.md
Gotchas & Edge Cases
- Loader caching is enabled by default — Within a single request, identical loader calls return cached results. Disable with
opts: { cache: false }when data changes mid-request preValidationis skipped for cached queries — If a query is parsed from cache, validation hooks do not fire- Subscription context is different from query context — Subscription context receives the WebSocket connection info, not the HTTP request. Use
subscription.contextoption for custom subscription context connection_initpayload goes intorequest.headers— During WebSocket handshake, properties from the client'sconnection_initpayload are copied into request headers automatically- Gateway mode disables local schema/resolvers/loaders — When running as a gateway, you cannot define
schema,resolvers, orloaderson the gateway instance queryDepthmust be at least 7 for GraphiQL — GraphiQL's introspection query requires depth 7+; lower values break the IDE
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST define loaders for any field that fetches related data — loaders solve the N+1 problem automatically through batching)
(You MUST use named constants for all numeric values — JIT thresholds, query depth limits, port numbers)
(You MUST return an array from loaders matching the exact length and order of the queries parameter)
(You MUST use fastify.graphql.pubsub.publish() inside mutations to trigger subscriptions — not external pubsub directly)
Failure to follow these rules will cause N+1 performance problems, corrupted GraphQL responses, and broken subscriptions.
</critical_reminders>
Files (skills)
-
examples
-
core.md 10 KB
# Mercurius - Core Examples > Essential patterns for registration, resolvers, loaders, context, error handling, and testing. See [SKILL.md](../SKILL.md) for decision guidance. **Prerequisites**: Fastify knowledge recommended. See also: [subscriptions.md](subscriptions.md), [federation.md](federation.md). --- ## Pattern 1: Full Registration with Options ### Good Example - Complete Setup ```typescript import Fastify from "fastify"; import mercurius from "mercurius"; import type { MercuriusContext } from "mercurius"; import type { FastifyRequest, FastifyReply } from "fastify"; const JIT_THRESHOLD = 1; const MAX_QUERY_DEPTH = 10; const SERVER_PORT = 3000; const schema = ` type Query { user(id: ID!): User users(limit: Int): [User!]! } type Mutation { createUser(name: String!, email: String!): User! } type User { id: ID! name: String! email: String! posts: [Post!]! } type Post { id: ID! title: String! authorId: ID! } `; const buildContext = async (req: FastifyRequest, _reply: FastifyReply) => { return { userId: req.headers["x-user-id"] as string | undefined, }; }; type PromiseType<T> = T extends PromiseLike<infer U> ? U : T; declare module "mercurius" { interface MercuriusContext extends PromiseType< ReturnType<typeof buildContext> > {} } const app = Fastify({ logger: true }); app.register(mercurius, { schema, resolvers, loaders, context: buildContext, jit: JIT_THRESHOLD, queryDepth: MAX_QUERY_DEPTH, graphiql: process.env.NODE_ENV !== "production", }); const start = async () => { await app.listen({ port: SERVER_PORT }); }; start(); ``` **Why good:** JIT enabled for production performance, query depth prevents abuse, GraphiQL disabled in production, context builder provides typed per-request data, all numeric values are named constants ### Bad Example - Missing Key Options ```typescript import Fastify from "fastify"; import mercurius from "mercurius"; const app = Fastify(); app.register(mercurius, { schema, resolvers, // No loaders - N+1 problems // No jit - missing performance optimization // No queryDepth - vulnerable to depth attacks // No context - no per-request data // graphiql defaults to true - exposed in production }); ``` **Why bad:** No loaders means N+1 queries, JIT disabled (default 0), no query depth limit allows abuse, graphiql exposed in production --- ## Pattern 2: Resolvers ### Good Example - Typed Resolvers with Context Access ```typescript import type { MercuriusContext } from "mercurius"; const DEFAULT_LIMIT = 20; const resolvers = { Query: { user: async ( _parent: unknown, args: { id: string }, ctx: MercuriusContext, ) => { return ctx.reply.server.db.findUser(args.id); }, users: async ( _parent: unknown, args: { limit?: number }, ctx: MercuriusContext, ) => { const limit = args.limit ?? DEFAULT_LIMIT; return ctx.reply.server.db.listUsers({ limit }); }, }, Mutation: { createUser: async ( _parent: unknown, args: { name: string; email: string }, ctx: MercuriusContext, ) => { if (!ctx.userId) { throw new mercurius.ErrorWithProps("Unauthorized", {}, 401); } return ctx.reply.server.db.createUser(args); }, }, }; ``` **Why good:** Typed context gives autocomplete, Fastify decorators accessed via `ctx.reply.server`, `ErrorWithProps` for GraphQL-compliant errors with extensions, named constant for default limit ### Bad Example - Untyped Resolvers ```typescript const resolvers = { Query: { user: async (_: any, args: any, ctx: any) => { // No type safety, no autocomplete return db.findUser(args.id); }, users: async () => { return db.listUsers({ limit: 20 }); // Magic number }, }, }; ``` **Why bad:** `any` types lose all type safety, direct `db` access instead of Fastify decorators, magic number for limit --- ## Pattern 3: Loaders (Batched Data Fetching) ### Good Example - Loader with Proper Batching ```typescript import type { MercuriusContext } from "mercurius"; interface LoaderQuery<T> { obj: T; params: Record<string, unknown>; } const loaders = { User: { async posts( queries: Array<LoaderQuery<{ id: string }>>, ctx: MercuriusContext, ) { // 1. Collect all IDs from the batch const userIds = queries.map(({ obj }) => obj.id); // 2. Single bulk fetch const allPosts = await ctx.reply.server.db.findPostsByAuthorIds(userIds); // 3. Map results back in query order (CRITICAL) return queries.map(({ obj }) => allPosts.filter((post) => post.authorId === obj.id), ); }, }, Post: { async author( queries: Array<LoaderQuery<{ authorId: string }>>, ctx: MercuriusContext, ) { const authorIds = [...new Set(queries.map(({ obj }) => obj.authorId))]; const authors = await ctx.reply.server.db.findUsersByIds(authorIds); const authorMap = new Map(authors.map((a) => [a.id, a])); return queries.map(({ obj }) => authorMap.get(obj.authorId) ?? null); }, }, }; ``` **Why good:** Single bulk query instead of N individual queries, results mapped back by index (required), Map for O(1) lookups, deduplicates IDs with Set ### Bad Example - Resolver Instead of Loader ```typescript const resolvers = { User: { // N+1 problem: called once per user in the list posts: async (parent: { id: string }) => { return db.findPostsByAuthorId(parent.id); // 1 query per user! }, }, }; ``` **Why bad:** If the query returns 50 users, this fires 50 individual database queries. A loader would batch all 50 into a single query. --- ## Pattern 4: Loader with Cache Control ### Disabling Cache Per-Loader ```typescript const loaders = { User: { posts: { async loader( queries: Array<LoaderQuery<{ id: string }>>, ctx: MercuriusContext, ) { const userIds = queries.map(({ obj }) => obj.id); const allPosts = await ctx.reply.server.db.findPostsByAuthorIds(userIds); return queries.map(({ obj }) => allPosts.filter((post) => post.authorId === obj.id), ); }, opts: { cache: false, // Disable caching for this loader }, }, }, }; ``` **When to use:** When the same field may return different data within a single request (e.g., after a mutation modifies the data mid-request). **Default behavior:** Caching is enabled by default. Within one request, if the same loader is called with the same `obj`, the cached result is returned. --- ## Pattern 5: Error Handling ### Good Example - ErrorWithProps for GraphQL Errors ```typescript import mercurius from "mercurius"; const HTTP_UNAUTHORIZED = 401; const HTTP_NOT_FOUND = 404; const HTTP_FORBIDDEN = 403; const resolvers = { Query: { user: async ( _parent: unknown, args: { id: string }, ctx: MercuriusContext, ) => { if (!ctx.userId) { throw new mercurius.ErrorWithProps( "Authentication required", { code: "UNAUTHENTICATED" }, HTTP_UNAUTHORIZED, ); } const user = await ctx.reply.server.db.findUser(args.id); if (!user) { throw new mercurius.ErrorWithProps( "User not found", { code: "NOT_FOUND", id: args.id }, HTTP_NOT_FOUND, ); } return user; }, }, }; ``` **Why good:** `ErrorWithProps` returns GraphQL-spec-compliant errors with extensions, HTTP status codes as named constants, extensions carry machine-readable error codes ### Custom Error Formatter ```typescript app.register(mercurius, { schema, resolvers, errorFormatter: (execution, ctx) => { const errors = execution.errors?.map((error) => ({ message: error.message, locations: error.locations, path: error.path, extensions: { code: error.extensions?.code ?? "INTERNAL_ERROR", // Strip stack traces in production ...(process.env.NODE_ENV !== "production" && { stack: error.extensions?.stack, }), }, })); return { statusCode: execution.errors?.[0]?.extensions?.statusCode ?? 200, response: { data: execution.data, errors }, }; }, }); ``` **Why good:** Custom formatter strips stack traces in production, preserves machine-readable error codes, returns proper status code from error extensions --- ## Pattern 6: Testing with app.graphql() ### Good Example - Direct GraphQL Execution ```typescript import { buildApp } from "./app"; let app: ReturnType<typeof buildApp>; beforeEach(async () => { app = buildApp(); await app.ready(); }); afterEach(async () => { await app.close(); }); it("should return a user by ID", async () => { const query = ` query GetUser($id: ID!) { user(id: $id) { id name email } } `; const response = await app.graphql(query, null, { id: "user-1" }); expect(response.data.user).toStrictEqual({ id: "user-1", name: "Alice", email: "alice@example.com", }); }); it("should return error for missing user", async () => { const query = ` query GetUser($id: ID!) { user(id: $id) { id name } } `; const response = await app.graphql(query, null, { id: "nonexistent" }); expect(response.errors).toBeDefined(); expect(response.errors[0].message).toBe("User not found"); }); ``` **Why good:** `app.graphql()` tests the full GraphQL pipeline without HTTP overhead, factory function for test isolation, tests both success and error paths ### Testing via HTTP with inject() ```typescript it("should handle GraphQL over HTTP", async () => { const response = await app.inject({ method: "POST", url: "/graphql", payload: { query: `{ users(limit: 5) { id name } }`, }, }); expect(response.statusCode).toBe(200); const body = response.json(); expect(body.data.users).toHaveLength(5); }); ``` **When to use:** When you need to test HTTP-level concerns (headers, status codes, content negotiation) rather than just GraphQL resolution. -
federation.md 6.5 KB
# Mercurius - Federation > Federated GraphQL services and gateway composition with `@mercuriusjs/federation` and `@mercuriusjs/gateway`. See [SKILL.md](../SKILL.md) for decision guidance, [core.md](core.md) for fundamentals. **Prerequisites**: Core Mercurius patterns, loader system understanding. --- ## Pattern 1: Federated Service ### Good Example - Service with Entity Loader ```typescript import Fastify from "fastify"; import mercuriusFederation from "@mercuriusjs/federation"; const SERVER_PORT = 3001; const schema = ` extend type Query { me: User } type User @key(fields: "id") { id: ID! name: String! email: String! } `; const resolvers = { Query: { me: async (_parent: unknown, _args: unknown, ctx: MercuriusContext) => { return ctx.reply.server.db.getCurrentUser(ctx.userId); }, }, }; // CRITICAL: Define __resolveReference as a LOADER, not a resolver const loaders = { User: { async __resolveReference( queries: Array<{ obj: { id: string } }>, ctx: MercuriusContext, ) { const ids = queries.map(({ obj }) => obj.id); const users = await ctx.reply.server.db.findUsersByIds(ids); const userMap = new Map(users.map((u) => [u.id, u])); // Return in query order return queries.map(({ obj }) => userMap.get(obj.id) ?? null); }, }, }; const app = Fastify({ logger: true }); app.register(mercuriusFederation, { schema, resolvers, loaders, }); app.listen({ port: SERVER_PORT }); ``` **Why good:** `__resolveReference` as a loader batches entity resolution (strongly recommended by Mercurius docs), Map for O(1) lookups, results in query order ### Bad Example - \_\_resolveReference as Resolver ```typescript const resolvers = { User: { // N+1: called once per referenced entity __resolveReference: async (source: { id: string }) => { return db.findUser(source.id); // 1 query per entity reference! }, }, }; ``` **Why bad:** When the gateway resolves 50 user references, this fires 50 individual queries. As a loader, it would batch all 50 into a single query. --- ## Pattern 2: Gateway Configuration ### Good Example - Gateway Composing Services ```typescript import Fastify from "fastify"; import mercuriusGateway from "@mercuriusjs/gateway"; const GATEWAY_PORT = 3000; const POLLING_INTERVAL_MS = 5000; const app = Fastify({ logger: true }); app.register(mercuriusGateway, { gateway: { services: [ { name: "user", url: "http://user-service:3001/graphql", mandatory: true, }, { name: "post", url: "http://post-service:3002/graphql", mandatory: true, }, { name: "analytics", url: "http://analytics-service:3003/graphql", mandatory: false, // Non-critical service }, ], pollingInterval: POLLING_INTERVAL_MS, errorHandler: (error, service) => { if (service.mandatory) { app.log.error( { error: error.message, service: service.name }, "Mandatory service error", ); } else { app.log.warn( { error: error.message, service: service.name }, "Optional service unavailable", ); } }, }, }); app.listen({ port: GATEWAY_PORT }); ``` **Why good:** `mandatory: true` ensures critical services must be available for schema composition, non-critical services degrade gracefully, polling interval enables automatic schema refresh, error handler differentiates severity --- ## Pattern 3: Gateway with Header Forwarding ### Good Example - Forwarding Auth Headers to Services ```typescript app.register(mercuriusGateway, { gateway: { services: [ { name: "user", url: "http://user-service:3001/graphql", mandatory: true, rewriteHeaders: (headers, context) => { return { authorization: headers.authorization, "x-request-id": headers["x-request-id"], }; }, }, ], }, }); ``` **Why good:** Only forwards necessary headers (not all), preserves auth context across service boundaries, request ID enables distributed tracing --- ## Pattern 4: Dynamic Schema Refresh ### Programmatic Schema Update ```typescript // Trigger manual refresh (e.g., after deploying a new service version) const newSchema = await app.graphql.gateway.refresh(); if (newSchema) { app.graphql.replaceSchema(newSchema); app.log.info("Gateway schema refreshed"); } else { app.log.info("Gateway schema unchanged"); } ``` **When to use:** When you need to trigger schema refresh outside of the polling interval (e.g., after a service deployment, via an admin endpoint). **Note:** `refresh()` returns `null` if the schema hasn't changed. Always check before calling `replaceSchema()`. --- ## Pattern 5: Federated Service with Extended Types ### Good Example - Post Service Extending User ```typescript import Fastify from "fastify"; import mercuriusFederation from "@mercuriusjs/federation"; const SERVER_PORT = 3002; const schema = ` extend type Query { post(id: ID!): Post posts(limit: Int): [Post!]! } type Post @key(fields: "id") { id: ID! title: String! content: String! author: User! } extend type User @key(fields: "id") { id: ID! @external posts: [Post!]! } `; const resolvers = { Query: { post: async ( _parent: unknown, args: { id: string }, ctx: MercuriusContext, ) => { return ctx.reply.server.db.findPost(args.id); }, }, }; const loaders = { User: { async posts( queries: Array<{ obj: { id: string } }>, ctx: MercuriusContext, ) { const userIds = queries.map(({ obj }) => obj.id); const allPosts = await ctx.reply.server.db.findPostsByAuthorIds(userIds); return queries.map(({ obj }) => allPosts.filter((post) => post.authorId === obj.id), ); }, }, Post: { async __resolveReference( queries: Array<{ obj: { id: string } }>, ctx: MercuriusContext, ) { const ids = queries.map(({ obj }) => obj.id); const posts = await ctx.reply.server.db.findPostsByIds(ids); const postMap = new Map(posts.map((p) => [p.id, p])); return queries.map(({ obj }) => postMap.get(obj.id) ?? null); }, }, }; const app = Fastify({ logger: true }); app.register(mercuriusFederation, { schema, resolvers, loaders, }); app.listen({ port: SERVER_PORT }); ``` **Why good:** Post service extends User type to add `posts` field, `@external` marks fields owned by another service, both `User.posts` and `Post.__resolveReference` use loaders for batch resolution -
subscriptions.md 6.6 KB
# Mercurius - Subscriptions > Real-time data with WebSocket subscriptions, pubsub patterns, and event filtering. See [SKILL.md](../SKILL.md) for decision guidance, [core.md](core.md) for fundamentals. **Prerequisites**: Core Mercurius registration and resolver patterns. --- ## Pattern 1: Basic Subscription Setup ### Good Example - Enable and Define Subscriptions ```typescript import Fastify from "fastify"; import mercurius from "mercurius"; import type { MercuriusContext } from "mercurius"; const JIT_THRESHOLD = 1; const MESSAGE_TOPIC = "NEW_MESSAGE"; const schema = ` type Query { messages: [Message!]! } type Mutation { sendMessage(text: String!, channelId: ID!): Message! } type Subscription { messageSent(channelId: ID!): Message! } type Message { id: ID! text: String! channelId: ID! createdAt: String! } `; const resolvers = { Mutation: { sendMessage: async ( _parent: unknown, args: { text: string; channelId: string }, ctx: MercuriusContext, ) => { const message = { id: generateId(), text: args.text, channelId: args.channelId, createdAt: new Date().toISOString(), }; // Publish to subscription topic await ctx.pubsub.publish({ topic: MESSAGE_TOPIC, payload: { messageSent: message }, }); return message; }, }, Subscription: { messageSent: { subscribe: async ( _parent: unknown, _args: unknown, ctx: MercuriusContext, ) => { return ctx.pubsub.subscribe(MESSAGE_TOPIC); }, }, }, }; const app = Fastify({ logger: true }); app.register(mercurius, { schema, resolvers, subscription: true, // REQUIRED - subscriptions are disabled by default jit: JIT_THRESHOLD, }); ``` **Why good:** `subscription: true` explicitly enables WebSocket support, topic as named constant, payload key matches subscription field name (`messageSent`), pubsub accessed from context ### Bad Example - Wrong Payload Shape ```typescript const resolvers = { Mutation: { sendMessage: async ( _: unknown, args: { text: string }, ctx: MercuriusContext, ) => { const message = { id: "1", text: args.text }; await ctx.pubsub.publish({ topic: "NEW_MESSAGE", payload: { message }, // WRONG: key must match subscription field name }); return message; }, }, Subscription: { messageSent: { subscribe: async (_: unknown, _args: unknown, ctx: MercuriusContext) => { return ctx.pubsub.subscribe("NEW_MESSAGE"); }, }, }, }; ``` **Why bad:** Payload key is `message` but subscription field is `messageSent` — subscribers receive `null`. The payload object key must exactly match the subscription field name. --- ## Pattern 2: Filtering with withFilter ### Good Example - Channel-Specific Subscriptions ```typescript import mercurius from "mercurius"; import type { MercuriusContext } from "mercurius"; const { withFilter } = mercurius; const MESSAGE_TOPIC = "NEW_MESSAGE"; const resolvers = { Subscription: { messageSent: { subscribe: withFilter( // Iterator factory (_parent: unknown, _args: unknown, ctx: MercuriusContext) => { return ctx.pubsub.subscribe(MESSAGE_TOPIC); }, // Filter function: return true to deliver, false to skip ( payload: { messageSent: { channelId: string } }, args: { channelId: string }, ) => { return payload.messageSent.channelId === args.channelId; }, ), }, }, }; ``` **Why good:** `withFilter` prevents delivering events to unrelated subscribers, filter receives both the published payload and the subscription args, only matching events reach the client **When to use:** Whenever subscribers should only receive a subset of events (by ID, by type, by permission level). --- ## Pattern 3: Subscription Context (Authentication) ### Good Example - Custom Subscription Context ```typescript import mercurius from "mercurius"; app.register(mercurius, { schema, resolvers, subscription: { context: async (_connection, request) => { // connection_init payload is copied to request.headers const token = request.headers.authorization; if (!token) { throw new Error("Missing authorization"); } const user = await verifyToken(token); return { user }; }, // Optional: called when client sends connection_init onConnect: (data) => { // data.payload contains connection_init payload from client return true; // return false to reject the connection }, onDisconnect: (context) => { // Cleanup when client disconnects }, }, }); ``` **Why good:** Subscription context is separate from query context (WebSocket vs HTTP), `connection_init` payload is available via `request.headers`, connection can be rejected in `onConnect` **Gotcha:** The subscription `context` function receives the WebSocket connection info, not a standard HTTP request. Properties from the client's `connection_init` payload are automatically copied into `request.headers`. --- ## Pattern 4: Redis PubSub for Distributed Systems ### Good Example - Redis Emitter for Multi-Instance Deployments ```typescript import Fastify from "fastify"; import mercurius from "mercurius"; import mqRedis from "mqemitter-redis"; const REDIS_PORT = 6379; const REDIS_HOST = "127.0.0.1"; const emitter = mqRedis({ port: REDIS_PORT, host: REDIS_HOST, }); const app = Fastify({ logger: true }); app.register(mercurius, { schema, resolvers, subscription: { emitter, // Replace default in-memory emitter with Redis }, }); ``` **Why good:** Default in-memory emitter only works on a single process. Redis emitter enables subscriptions across multiple server instances. **When to use:** Any deployment with more than one server instance (horizontal scaling, Kubernetes pods, load-balanced servers). Without a shared emitter, events published on one instance do not reach subscribers on another. --- ## Pattern 5: Multiple Topic Subscriptions ### Subscribing to Multiple Topics ```typescript const COMMENT_ADDED = "COMMENT_ADDED"; const COMMENT_DELETED = "COMMENT_DELETED"; const resolvers = { Subscription: { commentActivity: { subscribe: async ( _parent: unknown, _args: unknown, ctx: MercuriusContext, ) => { return ctx.pubsub.subscribe([COMMENT_ADDED, COMMENT_DELETED]); }, }, }, }; ``` **Why good:** Single subscription receives events from multiple topics, topics as named constants, useful for activity feeds and notification streams.
-
-
reference.md 10.6 KB
# Mercurius Reference > Decision frameworks, hook lifecycle, plugin options, and anti-patterns. Referenced from [SKILL.md](SKILL.md). --- <decision_framework> ## Decision Framework ### When to Use Mercurius ``` Building a GraphQL API on Node.js? ├─ Already using Fastify? │ └─ YES → Mercurius (native Fastify integration) ├─ Need batched data loading built-in? │ └─ YES → Mercurius (loader system, no external DataLoader) ├─ Need federation support? │ └─ YES → Mercurius (@mercuriusjs/federation + @mercuriusjs/gateway) ├─ Need JIT query compilation? │ └─ YES → Mercurius (graphql-jit integration) ├─ Not using Fastify? │ └─ Consider a framework-agnostic solution └─ Default → Mercurius if on Fastify, otherwise evaluate alternatives ``` ### Loader vs Resolver Decision ``` Does this field fetch related data from a data source? ├─ YES → Always use a loader (prevents N+1) └─ NO → Is it a computed/derived field? ├─ YES → Use a resolver (no batching needed) └─ NO → Is it a root query/mutation? ├─ YES → Use a resolver └─ NO → Check if the parent already provides the data ``` ### Subscription Transport Decision ``` Running multiple server instances? ├─ YES → Use Redis emitter (mqemitter-redis) └─ NO → Default in-memory emitter works Need subscription authentication? ├─ YES → Use subscription.context + onConnect └─ NO → Default context is sufficient Need event filtering per subscriber? ├─ YES → Use withFilter └─ NO → Direct pubsub.subscribe is sufficient ``` ### Federation vs Monolith Decision ``` Is the GraphQL API managed by multiple teams? ├─ YES → Federation (@mercuriusjs/federation + @mercuriusjs/gateway) └─ NO → Is the schema too large for one service? ├─ YES → Federation for domain separation └─ NO → Monolith (single Mercurius instance) ``` </decision_framework> --- ## GraphQL Lifecycle Hooks ### Request Lifecycle Order | Hook | When | Can Modify | Common Use | | ------------- | --------------------------- | ---------------------- | ---------------------------- | | preParsing | Before query string parsing | source (query string) | Tracing, query preprocessing | | preValidation | After parsing | document (AST) | Custom validation | | preExecution | Before execution | document, schema, vars | Auth, rate limiting, logging | | onResolution | After execution complete | execution result | Metrics, response logging | ### Subscription Lifecycle Order | Hook | When | Common Use | | ----------------------------- | -------------------------- | ----------------------------- | | preSubscriptionParsing | Before parsing sub query | Tracing | | preSubscriptionExecution | After parsing, before exec | Auth, connection validation | | onSubscriptionResolution | After each event resolves | Event logging, transformation | | onSubscriptionEnd | Subscription terminates | Cleanup, metrics | | onSubscriptionConnectionClose | WebSocket closes | Session cleanup | | onSubscriptionConnectionError | Connection error occurs | Error logging, alerting | ### Hook Registration ```typescript // Register after app.ready() or inside a Fastify plugin app.graphql.addHook("preParsing", async (schema, source, context) => { context.reply.server.log.info({ query: source }, "Incoming query"); }); app.graphql.addHook("preExecution", async (schema, document, context) => { // Can return { document, schema, variables, errors } // Modifying schema/document disables JIT for this execution }); app.graphql.addHook("onResolution", async (execution, context) => { if (execution.errors?.length) { context.reply.server.log.warn( { errors: execution.errors }, "GraphQL errors", ); } }); ``` **Warning:** `preValidation` is skipped for queries served from the parse cache. --- ## Plugin Options Reference ### Core Options | Option | Type | Default | Description | | --------------------- | ------------------ | ---------- | ------------------------------------------------ | | `schema` | string / string[] | required | GraphQL SDL schema definition | | `resolvers` | object | required | Resolver functions by type | | `loaders` | object | - | Batch loader functions by type/field | | `context` | function | - | `(req, reply) => object` per-request context | | `jit` | integer | 0 | Executions before JIT compilation (0 = disabled) | | `queryDepth` | integer | - | Maximum allowed query nesting depth | | `graphiql` | boolean / string | true | Enable GraphiQL IDE at `/graphiql` | | `routes` | boolean | true | Expose `/graphql` endpoint | | `path` | string | `/graphql` | Custom GraphQL endpoint path | | `subscription` | boolean / object | false | Enable WebSocket subscriptions | | `errorHandler` | function / boolean | true | Custom GraphQL error handler | | `errorFormatter` | function | - | Custom error response formatting | | `allowBatchedQueries` | boolean | false | Accept arrays of queries | | `persistedQueries` | object | - | Hash-to-query map for persisted queries | | `onlyPersisted` | boolean | false | Reject non-persisted queries | | `defineMutation` | boolean | false | Auto-add empty Mutation type if undefined | ### Subscription Options (when `subscription` is an object) | Option | Type | Description | | -------------- | -------- | ------------------------------------------------ | | `emitter` | object | Custom MQEmitter (e.g., mqemitter-redis) | | `pubsub` | object | Custom PubSub implementation | | `context` | function | `(connection, request) => object` for WS context | | `onConnect` | function | Called on WebSocket connection_init | | `onDisconnect` | function | Called on WebSocket disconnect | --- ## Anti-Patterns to Avoid ### Using External DataLoader Instead of Mercurius Loaders ```typescript // WRONG: Manual DataLoader instantiation import DataLoader from "dataloader"; const resolvers = { Query: { users: async (_parent: unknown, _args: unknown, ctx: MercuriusContext) => { // DataLoader created per-request manually const userLoader = new DataLoader((ids: string[]) => fetchUsers(ids)); return userLoader.loadMany(["1", "2", "3"]); }, }, }; ``` ```typescript // CORRECT: Mercurius built-in loaders const loaders = { Query: { // Loaders are request-scoped and batched automatically async users( queries: Array<{ params: { ids: string[] } }>, ctx: MercuriusContext, ) { const allIds = queries.flatMap(({ params }) => params.ids); return fetchUsers(allIds); }, }, }; ``` **Why it matters:** Mercurius loaders are request-scoped by default and integrate with the GraphQL execution pipeline. External DataLoader requires manual per-request instantiation and does not benefit from Mercurius's caching layer. --- ### Missing subscription: true ```typescript // WRONG: Subscription resolvers defined but subscriptions not enabled app.register(mercurius, { schema, // includes Subscription type resolvers, // includes Subscription resolvers // subscription option missing — defaults to false }); ``` ```typescript // CORRECT: Explicitly enable subscriptions app.register(mercurius, { schema, resolvers, subscription: true, }); ``` **Why it matters:** Subscription resolvers are silently ignored when `subscription` is not enabled. No error is thrown — subscribers simply never receive events. --- ### Hooks Registered Too Early ```typescript // WRONG: Hook registered before plugin is ready const app = Fastify(); app.register(mercurius, { schema, resolvers }); // app.graphql does not exist yet! app.graphql.addHook("preExecution", async () => {}); ``` ```typescript // CORRECT: Register hooks inside a Fastify plugin (ensures readiness) app.register(async (fastify) => { fastify.graphql.addHook("preExecution", async (schema, document, context) => { // Hook registered after mercurius is loaded }); }); ``` **Why it matters:** `app.graphql` is decorated by Mercurius during plugin registration. Accessing it before `ready()` or outside a plugin throws a runtime error. --- ## Quick Reference: Mercurius Ecosystem Packages | Package | Purpose | | ------------------------------- | ------------------------------------------ | | `mercurius` | Core GraphQL plugin for Fastify | | `@mercuriusjs/federation` | Build federated GraphQL services | | `@mercuriusjs/gateway` | Compose federated services into a gateway | | `mercurius-codegen` | Auto-generate TypeScript types from schema | | `mercurius-cache` | Response-level caching for resolvers | | `mercurius-upload` | File upload support (multipart) | | `mercurius-logging` | Automatic query/mutation logging | | `mercurius-integration-testing` | Testing utilities for Mercurius APIs | --- ## Production Checklist ### Before Deploying - [ ] JIT enabled (`jit: 1` or higher threshold) - [ ] `queryDepth` set to prevent abuse (minimum 7 if using GraphiQL) - [ ] `graphiql: false` in production - [ ] Loaders defined for ALL fields fetching related data - [ ] Error formatter strips stack traces in production - [ ] `subscription: true` only if subscriptions are needed - [ ] Redis emitter configured for multi-instance deployments - [ ] Context function provides auth data from request headers - [ ] Federation `__resolveReference` defined as loaders (not resolvers) - [ ] Hook registration happens inside Fastify plugins (not at top level) -
SKILL.md 15.7 KB
--- name: api-graphql-mercurius description: GraphQL server for Fastify with Mercurius — loaders, subscriptions, federation, JIT compilation --- # GraphQL with Mercurius > **Quick Guide:** Use Mercurius as a Fastify plugin for GraphQL APIs with built-in loader batching (solves N+1), JIT query compilation, subscriptions via WebSocket, and federation support. Register with `app.register(mercurius, { schema, resolvers, loaders })`. Loaders are Mercurius's killer feature: define them per-type to batch field resolution automatically. Use `jit: 1` to enable query compilation for production performance. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST define loaders for any field that fetches related data — loaders solve the N+1 problem automatically through batching)** **(You MUST use named constants for all numeric values — JIT thresholds, query depth limits, port numbers)** **(You MUST return an array from loaders matching the exact length and order of the `queries` parameter)** **(You MUST use `fastify.graphql.pubsub.publish()` inside mutations to trigger subscriptions — not external pubsub directly)** </critical_requirements> --- **Auto-detection:** Mercurius, mercurius, app.graphql, fastify.graphql, mercurius loaders, mercurius subscription, pubsub.publish, pubsub.subscribe, @mercuriusjs/federation, @mercuriusjs/gateway, mercurius-codegen, MercuriusContext, graphql-jit, withFilter, preParsing, preValidation, preExecution, onResolution **When to use:** - Building GraphQL APIs on Fastify (Mercurius is Fastify-native) - Need automatic batching/caching for N+1 query prevention (loader system) - Want JIT query compilation for production performance - Building federated GraphQL services with `@mercuriusjs/federation` - Need real-time subscriptions via WebSocket with built-in pubsub - Want GraphQL lifecycle hooks (preParsing, preValidation, preExecution, onResolution) **When NOT to use:** - Not using Fastify (Mercurius is Fastify-only) - Need a framework-agnostic GraphQL server - Building a standalone schema-first design tool (use the schema library directly) - Simple REST endpoints without GraphQL requirements **Key patterns covered:** - Plugin registration with schema, resolvers, and loaders - Loader system for batched data fetching (the core differentiator) - JIT compilation configuration for production performance - Subscriptions with built-in pubsub and `withFilter` - Federation services and gateway composition - TypeScript context typing with `MercuriusContext` augmentation - GraphQL lifecycle hooks for cross-cutting concerns --- **Detailed Resources:** - [examples/core.md](examples/core.md) - Registration, resolvers, loaders, context, error handling, testing - [examples/subscriptions.md](examples/subscriptions.md) - Pubsub, subscription resolvers, withFilter, WebSocket config - [examples/federation.md](examples/federation.md) - Federated services, gateway, \_\_resolveReference as loader - [reference.md](reference.md) - Decision frameworks, hook lifecycle, plugin options, anti-patterns --- <philosophy> ## Philosophy **Fastify-native GraphQL.** Mercurius is not a standalone server bolted onto Fastify — it is a Fastify plugin that deeply integrates with Fastify's lifecycle, encapsulation model, and plugin system. This means your GraphQL API inherits Fastify's performance characteristics and plugin architecture naturally. **Loaders over DataLoader.** Instead of requiring a separate DataLoader library, Mercurius provides a built-in loader system. Loaders are defined per-type/per-field and receive batched queries automatically. This is simpler than manually instantiating DataLoader instances per-request and is the primary mechanism for solving the N+1 problem. **JIT for production.** Mercurius uses graphql-jit to compile frequently-executed queries into optimized V8 functions. After a configurable threshold of executions, subsequent runs of the same query bypass the GraphQL execution engine entirely — delivering significant performance gains for repeated queries. **Federation as a plugin.** Federation support is split into separate packages (`@mercuriusjs/federation` for services, `@mercuriusjs/gateway` for composition), keeping the core library lean for non-federated use cases. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Plugin Registration Register Mercurius as a Fastify plugin with schema (SDL string), resolvers, and optional loaders. ```typescript import Fastify from "fastify"; import mercurius from "mercurius"; const JIT_THRESHOLD = 1; const SERVER_PORT = 3000; const app = Fastify({ logger: true }); const schema = ` type Query { user(id: ID!): User users: [User!]! } type User { id: ID! name: String! posts: [Post!]! } type Post { id: ID! title: String! } `; app.register(mercurius, { schema, resolvers, loaders, jit: JIT_THRESHOLD, graphiql: process.env.NODE_ENV !== "production", }); ``` **Why good:** JIT threshold as named constant, GraphiQL disabled in production, loaders passed at registration level alongside resolvers > Full registration with context, error handling, and all options: [examples/core.md](examples/core.md) --- ### Pattern 2: Loaders (N+1 Prevention) Loaders are Mercurius's primary mechanism for batch data fetching. Define them per-type per-field. Each loader receives an array of `queries` (batched requests) and must return an array of results in the same order. ```typescript const loaders = { User: { async posts( queries: Array<{ obj: User; params: Record<string, unknown> }>, context: MercuriusContext, ) { const userIds = queries.map(({ obj }) => obj.id); const allPosts = await fetchPostsByUserIds(userIds); // Return array matching queries order return queries.map(({ obj }) => allPosts.filter((post) => post.authorId === obj.id), ); }, }, }; ``` **Why good:** Single bulk query replaces N individual queries, result array matches input order (required), batching is automatic per-request **Gotcha:** The returned array MUST match the length and order of `queries` — Mercurius maps results by index, not by key. > Full loader patterns with caching options: [examples/core.md](examples/core.md) --- ### Pattern 3: Resolver Structure Resolvers follow the standard GraphQL signature: `(parent, args, context, info)`. The context includes the Fastify `reply` object for accessing Fastify decorators. ```typescript const resolvers = { Query: { user: async ( _parent: unknown, args: { id: string }, context: MercuriusContext, ) => { return context.reply.server.db.findUser(args.id); }, users: async ( _parent: unknown, _args: unknown, context: MercuriusContext, ) => { return context.reply.server.db.listUsers(); }, }, }; ``` **Why good:** Accesses Fastify decorators via `context.reply.server`, standard GraphQL resolver signature > Complete resolver examples with mutations: [examples/core.md](examples/core.md) --- ### Pattern 4: TypeScript Context Typing Augment the `MercuriusContext` interface to get type-safe context in resolvers and loaders. ```typescript import type { FastifyRequest, FastifyReply } from "fastify"; const buildContext = async (req: FastifyRequest, _reply: FastifyReply) => { return { userId: req.headers["x-user-id"] as string | undefined, }; }; type PromiseType<T> = T extends PromiseLike<infer U> ? U : T; declare module "mercurius" { interface MercuriusContext extends PromiseType< ReturnType<typeof buildContext> > {} } // Registration app.register(mercurius, { schema, resolvers, context: buildContext, }); ``` **Why good:** Context type is derived from the builder function, no manual interface duplication, resolvers get full type inference on `ctx.userId` > Full TypeScript patterns with codegen: [examples/core.md](examples/core.md) --- ### Pattern 5: Subscriptions with PubSub Enable subscriptions for real-time data. Mercurius provides a built-in pubsub system accessible via context. ```typescript const NOTIFICATION_TOPIC = "NOTIFICATION_ADDED"; const resolvers = { Mutation: { addNotification: async ( _parent: unknown, args: { message: string }, context: MercuriusContext, ) => { const notification = { id: generateId(), message: args.message }; await context.pubsub.publish({ topic: NOTIFICATION_TOPIC, payload: { notificationAdded: notification }, }); return notification; }, }, Subscription: { notificationAdded: { subscribe: async ( _parent: unknown, _args: unknown, context: MercuriusContext, ) => { return context.pubsub.subscribe(NOTIFICATION_TOPIC); }, }, }, }; ``` **Why good:** Topic as named constant, pubsub accessed from context (Mercurius-managed), subscribe returns async iterator > Full subscription patterns with withFilter and Redis: [examples/subscriptions.md](examples/subscriptions.md) --- ### Pattern 6: Federation Services Build federated services with `@mercuriusjs/federation`. Define `__resolveReference` as a **loader** for batch entity resolution. ```typescript import mercuriusFederation from "@mercuriusjs/federation"; const schema = ` extend type Query { me: User } type User @key(fields: "id") { id: ID! name: String! } `; const loaders = { User: { async __resolveReference(queries: Array<{ obj: { id: string } }>) { const ids = queries.map(({ obj }) => obj.id); const users = await fetchUsersByIds(ids); return queries.map(({ obj }) => users.find((u) => u.id === obj.id)); }, }, }; app.register(mercuriusFederation, { schema, resolvers, loaders }); ``` **Why good:** `__resolveReference` as loader prevents N+1 on entity resolution (strongly recommended by Mercurius docs), batch fetches all referenced entities at once > Full federation with gateway: [examples/federation.md](examples/federation.md) --- ### Pattern 7: GraphQL Lifecycle Hooks Mercurius provides hooks for cross-cutting concerns at specific points in the GraphQL execution lifecycle. **Hook execution order:** 1. `preParsing` - Before query string parsing (tracing, query preprocessing) 2. `preValidation` - After parsing, before validation (skipped for cached queries) 3. `preExecution` - Before execution (auth, rate limiting, query modification) 4. `onResolution` - After execution completes (metrics, response logging) ```typescript app.graphql.addHook("preExecution", async (schema, document, context) => { const startTime = performance.now(); context.startTime = startTime; }); app.graphql.addHook("onResolution", async (execution, context) => { const duration = performance.now() - context.startTime; context.reply.server.log.info({ duration }, "GraphQL query executed"); }); ``` **Why good:** Hooks integrate with Fastify's logging, run at precise lifecycle points, can modify schema/document/variables in preExecution **Warning:** Modifying `schema` or `document` in `preExecution` disables JIT compilation for that query. > Full hook patterns: [reference.md](reference.md) --- ### Pattern 8: JIT Compilation Configuration Enable JIT to compile frequently-executed queries into optimized V8 functions. ```typescript const JIT_THRESHOLD = 1; const MAX_QUERY_DEPTH = 10; app.register(mercurius, { schema, resolvers, jit: JIT_THRESHOLD, queryDepth: MAX_QUERY_DEPTH, }); ``` **Why good:** `jit: 1` compiles after first execution (suitable for production with repeated queries), `queryDepth` prevents abuse, both values as named constants **Gotcha:** JIT is disabled (default `0`) out of the box. Set `jit: 1` for production. Setting it higher (e.g., `5`) delays compilation until the query has been seen N times, which helps avoid compiling one-off queries. </patterns> --- <red_flags> ## RED FLAGS ### High Priority Issues - **No loaders defined for related data fields** — Every field that fetches associated data (e.g., `User.posts`, `Post.author`) should use a loader, not inline resolver queries. Without loaders, you get the classic N+1 problem. - **Loader returns wrong length/order** — The returned array MUST match the `queries` array by index. Returning fewer/more items or in wrong order corrupts the response silently. - **`__resolveReference` as resolver instead of loader in federation** — Causes N+1 on entity resolution across services. The docs strongly recommend defining it as a loader. - **JIT left at default (disabled)** — `jit: 0` means no JIT compilation. Set `jit: 1` for production workloads with repeated queries. ### Medium Priority Issues - **Not using `context` function for per-request data** — Accessing request headers or auth tokens requires a context builder function, not Fastify decorators alone - **GraphiQL enabled in production** — Set `graphiql: false` or conditionally disable based on `NODE_ENV` - **Missing `queryDepth` limit** — Without depth limiting, deeply nested queries can exhaust server resources - **Modifying schema/document in `preExecution`** — Disables JIT compilation for that query execution ### Common Mistakes - **Using external DataLoader instead of Mercurius loaders** — Mercurius loaders are built-in and request-scoped by default; no need for manual DataLoader instantiation - **Forgetting `subscription: true` in registration** — Subscriptions are disabled by default; subscription resolvers silently fail without this option - **Publishing with wrong payload shape** — The `payload` in `pubsub.publish()` must match the subscription field name exactly (e.g., `{ notificationAdded: data }` for a `notificationAdded` subscription) - **Calling `addHook` before `app.ready()`** — GraphQL hooks must be registered after `app.ready()` or inside a Fastify plugin that ensures readiness > Detailed anti-pattern code examples: [reference.md](reference.md#anti-patterns-to-avoid) ### Gotchas & Edge Cases - **Loader caching is enabled by default** — Within a single request, identical loader calls return cached results. Disable with `opts: { cache: false }` when data changes mid-request - **`preValidation` is skipped for cached queries** — If a query is parsed from cache, validation hooks do not fire - **Subscription context is different from query context** — Subscription context receives the WebSocket connection info, not the HTTP request. Use `subscription.context` option for custom subscription context - **`connection_init` payload goes into `request.headers`** — During WebSocket handshake, properties from the client's `connection_init` payload are copied into request headers automatically - **Gateway mode disables local schema/resolvers/loaders** — When running as a gateway, you cannot define `schema`, `resolvers`, or `loaders` on the gateway instance - **`queryDepth` must be at least 7 for GraphiQL** — GraphiQL's introspection query requires depth 7+; lower values break the IDE </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST define loaders for any field that fetches related data — loaders solve the N+1 problem automatically through batching)** **(You MUST use named constants for all numeric values — JIT thresholds, query depth limits, port numbers)** **(You MUST return an array from loaders matching the exact length and order of the `queries` parameter)** **(You MUST use `fastify.graphql.pubsub.publish()` inside mutations to trigger subscriptions — not external pubsub directly)** **Failure to follow these rules will cause N+1 performance problems, corrupted GraphQL responses, and broken subscriptions.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.