Skip to content
DasPeTe Tutorials

A Fast GraphQL API With Fastify and Yoga · Part 5 of 8 in the series

Part 5: Mutations and Live Updates With Subscriptions

Post, accept and complete quests with mutations, push every change to subscribed clients over Server-Sent Events, and keep the DataLoader cache from going stale in long-lived subscriptions.

Quest objectives

  • Write mutations with input types and safe status changes
  • Send clients errors they can act on
  • Publish events with the typed PubSub of Yoga
  • Subscribe over Server-Sent Events, with filters and topics
  • Give every subscription event fresh DataLoaders

Queries read. Mutations change data, and subscriptions tell clients when it has changed. Both go into the schema next to Query. Add them at the end of src/schema.graphql:

src/schema.graphql
type Mutation {
  postQuest(input: PostQuestInput!): Quest!
  acceptQuest(questId: ID!, adventurerId: ID!): Quest!
  completeQuest(questId: ID!): Quest!
}

input PostQuestInput {
  guildId: ID!
  title: String!
  reward: Int!
}

type Subscription {
  questPosted(guildId: ID): Quest!
  questUpdated(id: ID!): Quest!
}

PostQuestInput is an input type: the fields of a new quest as one argument, which a client can send as a single variable. questPosted has an optional guild, for boards that only show one guild, and questUpdated follows one quest. Run pnpm codegen, so the types know about them.

The mutations

The mutations need a few more imports at the top of src/resolvers.ts. QuestRow and filter are for the subscriptions further down:

src/resolvers.ts
import { and, eq } from 'drizzle-orm';
import { GraphQLError } from 'graphql';
import { filter, pipe } from 'graphql-yoga';
import { guilds, quests, type QuestRow } from './db/schema.ts';
import type { Resolvers } from './generated/graphql.ts';

Then add Mutation to the resolvers, after Query. It uses pubsub from the context, which you create in a moment:

src/resolvers.ts
Mutation: {
    postQuest: async (_parent, { input }, { db, loaders, pubsub }) => {
      const title = input.title.trim();
      if (!title) throw badInput('A quest needs a title');
      if (input.reward < 1) throw badInput('The reward must be at least 1 gold');

      const guild = await loaders.guild.load(Number(input.guildId)).catch(() => null);
      if (!guild) throw badInput(`There is no guild with the id ${input.guildId}`);

      const [quest] = await db
        .insert(quests)
        .values({ title, reward: input.reward, guildId: guild.id })
        .returning();
      pubsub.publish('quest:posted', quest);
      return quest;
    },

    acceptQuest: async (_parent, { questId, adventurerId }, { db, loaders, pubsub }) => {
      const adventurer = await loaders.adventurer.load(Number(adventurerId)).catch(() => null);
      if (!adventurer) throw badInput(`There is no adventurer with the id ${adventurerId}`);

      const [quest] = await db
        .update(quests)
        .set({ status: 'ACCEPTED', assigneeId: adventurer.id })
        .where(and(eq(quests.id, Number(questId)), eq(quests.status, 'OPEN')))
        .returning();
      if (!quest) throw badInput(`Quest ${questId} is not open`);

      pubsub.publish('quest:updated', quest.id, quest);
      return quest;
    },

    completeQuest: async (_parent, { questId }, { db, pubsub }) => {
      const [quest] = await db
        .update(quests)
        .set({ status: 'DONE' })
        .where(and(eq(quests.id, Number(questId)), eq(quests.status, 'ACCEPTED')))
        .returning();
      if (!quest) throw badInput(`Quest ${questId} has not been accepted`);

      pubsub.publish('quest:updated', quest.id, quest);
      return quest;
    },
  },

postQuest checks the input and the guild before it writes. It looks the guild up through the loader, which fails for a missing id, and turns that into an error the client can read.

acceptQuest does not read the quest, check its status and then write. Two adventurers could do that at the same moment, both see OPEN, and both get the quest. Instead the condition is part of the update: where id = 3 and status = 'OPEN'. PostgreSQL changes the row only if it is still open, and returning() is empty for the one who came second. completeQuest does the same for ACCEPTED. After writing, each mutation publishes what changed.

The PubSub

Yoga comes with a PubSub: publish sends an event on a channel, and subscribe returns everything that arrives on it. Create src/pubsub.ts:

src/pubsub.ts
import { createPubSub } from 'graphql-yoga';
import type { QuestRow } from './db/schema.ts';

export const pubsub = createPubSub<{
  'quest:posted': [quest: QuestRow];
  'quest:updated': [id: number, quest: QuestRow];
}>();

export type PubSub = typeof pubsub;

The type lists the channels and what they carry. quest:posted carries a quest. quest:updated starts with an id, which makes it a topic: publish('quest:updated', 3, quest) reaches only those who subscribed to quest 3. Add it to the context:

src/context.ts
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { YogaInitialContext } from 'graphql-yoga';
import { db, type Database } from './db/index.ts';
import { createLoaders, type Loaders } from './loaders.ts';
import { pubsub, type PubSub } from './pubsub.ts';

export interface ServerContext {
  req: FastifyRequest;
  reply: FastifyReply;
}

export interface Context extends YogaInitialContext, ServerContext {
  db: Database;
  loaders: Loaders;
  pubsub: PubSub;
}

export function createContext(): Pick<Context, 'db' | 'loaders' | 'pubsub'> {
  return { db, loaders: createLoaders(db), pubsub };
}

Try the mutations:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 2, title: \"Hunt the sea serpent\", reward: 350 }) { id title status guild { name } } }"}'{"data":{"postQuest":{"id":"11","title":"Hunt the sea serpent","status":"OPEN","guild":{"name":"Tidecallers"}}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { acceptQuest(questId: 3, adventurerId: 3) { id status assignee { name } } }"}'{"data":{"acceptQuest":{"id":"3","status":"ACCEPTED","assignee":{"name":"Corin"}}}}

And the mistakes a client can make:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 9, title: \"Find guild nine\", reward: 10 }) { id } }"}'{"errors":[{"message":"There is no guild with the id 9","locations":[{"line":1,"column":12}],"path":["postQuest"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { acceptQuest(questId: 1, adventurerId: 3) { id } }"}'{"errors":[{"message":"Quest 1 is not open","locations":[{"line":1,"column":12}],"path":["acceptQuest"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}

Every error says what is wrong and has the code BAD_USER_INPUT, so a frontend can tell its user's mistakes apart from the server's.

The subscriptions

Add Subscription to the resolvers, after Mutation:

src/resolvers.ts
Subscription: {
    questPosted: {
      subscribe: (_parent, { guildId }, { pubsub }) =>
        pipe(
          pubsub.subscribe('quest:posted'),
          filter((quest) => !guildId || quest.guildId === Number(guildId)),
        ),
      resolve: (quest: QuestRow) => quest,
    },

    questUpdated: {
      subscribe: (_parent, { id }, { pubsub }) => pubsub.subscribe('quest:updated', Number(id)),
      resolve: (quest: QuestRow) => quest,
    },
  },

A subscription field has two parts. subscribe returns the stream of events: for questPosted that is the channel, passed through filter when a guild is given, and for questUpdated the topic of one quest. resolve turns each event into the value of the field, here the quest row itself. From there on it is a normal quest, and the field resolvers, loaders included, answer whatever the client selected.

Server-Sent Events

Yoga delivers subscriptions over Server-Sent Events: the response to an ordinary HTTP request stays open, and every event is a few lines of text in it. There is no WebSocket server to run, it passes through every HTTP proxy, and the Fastify route you already have handles it. Start from a fresh board, so your output matches the one here:

~/projects/guild-api
$ LOG_SQL=false pnpm db:seedSeeded 3 guilds, 9 adventurers and 10 quests.

Subscribe to quest 3 in one terminal, with -N, so curl shows every event as it arrives:

~/projects/guild-api
$ curl -sN localhost:4000/graphql -H 'accept: text/event-stream' -H 'content-type: application/json' -d '{"query":"subscription { questUpdated(id: 3) { title status assignee { name } } }"}':event: nextdata: {"data":{"questUpdated":{"title":"Find the lost forge hammer","status":"ACCEPTED","assignee":{"name":"Corin"}}}}event: nextdata: {"data":{"questUpdated":{"title":"Find the lost forge hammer","status":"DONE","assignee":{"name":"Corin"}}}}

The subscription starts with :, a comment that keeps the connection open, and Yoga sends another one every 12 seconds. The two events arrive when you accept and complete the quest in a second terminal:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { acceptQuest(questId: 3, adventurerId: 3) { status } }"}'{"data":{"acceptQuest":{"status":"ACCEPTED"}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { completeQuest(questId: 3) { status } }"}'{"data":{"completeQuest":{"status":"DONE"}}}

GraphiQL speaks Server-Sent Events too. Run a subscription there, post a quest with curl, and it appears on the right. While a subscription runs, the run button turns into a stop button:

A subscription in GraphiQL, showing the quest that was just posted.Open the screenshot in full size
A subscription in GraphiQL, showing the quest that was just posted.

A stale cache

Now subscribe to new quests, with the list of all quests of their guild:

~/projects/guild-api
$ curl -sN localhost:4000/graphql -H 'accept: text/event-stream' -H 'content-type: application/json' -d '{"query":"subscription { questPosted { title reward guild { name quests { title } } } }"}':event: nextdata: {"data":{"questPosted":{"title":"Hunt the sea serpent","reward":350,"guild":{"name":"Tidecallers","quests":[{"title":"Chart the sunken reef"},{"title":"Calm the storm spirit"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Hunt the sea serpent"}]}}}}event: nextdata: {"data":{"questPosted":{"title":"Polish the lighthouse","reward":15,"guild":{"name":"Tidecallers","quests":[{"title":"Chart the sunken reef"},{"title":"Calm the storm spirit"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Hunt the sea serpent"}]}}}}

The two events came from these two mutations:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 2, title: \"Hunt the sea serpent\", reward: 350 }) { id } }"}'{"data":{"postQuest":{"id":"11"}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 2, title: \"Polish the lighthouse\", reward: 15 }) { id } }"}'{"data":{"postQuest":{"id":"12"}}}

Read the second event carefully. "Polish the lighthouse" was just posted, but it is missing from the list of the guild's quests.

The reason is the context. For a query, Yoga makes a new one for every request. A subscription is one request that lives for hours, so its context, and with it its loaders, is made once and used for every event. The first event loaded the quests of the Tidecallers, the loader remembered them, and the second event got the remembered list. Left alone, the cache of a long subscription would also grow with every event it ever sent.

The answer is a fresh set of loaders for every event, and there is a plugin for it:

~/projects/guild-api
$ pnpm add @envelop/execute-subscription-eventdependencies:+ @envelop/execute-subscription-event 9.0.0

Add it to src/server.ts:

src/server.ts
import Fastify, { LogController } from 'fastify';
import { createYoga } from 'graphql-yoga';
import { useExtendContextValuePerExecuteSubscriptionEvent } from '@envelop/execute-subscription-event';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';
import { createContext, type Context, type ServerContext } from './context.ts';
import { db } from './db/index.ts';
import { createLoaders } from './loaders.ts';
import { schema } from './schema.ts';

const app = Fastify({
  logger: true,
  logController: new LogController({ disableRequestLogging: true }),
});

const yoga = createYoga<ServerContext>({
  schema,
  context: createContext,
  plugins: [
    // A subscription keeps its context for every event: give each event fresh loaders.
    useExtendContextValuePerExecuteSubscriptionEvent<Context>(() => ({
      contextPartial: { loaders: createLoaders(db) },
    })),
  ],
  logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});

app.route({
  url: yoga.graphqlEndpoint,
  method: ['GET', 'POST', 'OPTIONS'],
  handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});

await app.listen({ port: Number(process.env.PORT) });

useExtendContextValuePerExecuteSubscriptionEvent runs before every event and lays contextPartial over the context of the subscription, here a new set of loaders. Reset the board with LOG_SQL=false pnpm db:seed, subscribe again and post the same two quests:

~/projects/guild-api
$ curl -sN localhost:4000/graphql -H 'accept: text/event-stream' -H 'content-type: application/json' -d '{"query":"subscription { questPosted { title reward guild { name quests { title } } } }"}':event: nextdata: {"data":{"questPosted":{"title":"Hunt the sea serpent","reward":350,"guild":{"name":"Tidecallers","quests":[{"title":"Chart the sunken reef"},{"title":"Calm the storm spirit"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Hunt the sea serpent"}]}}}}event: nextdata: {"data":{"questPosted":{"title":"Polish the lighthouse","reward":15,"guild":{"name":"Tidecallers","quests":[{"title":"Chart the sunken reef"},{"title":"Calm the storm spirit"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Hunt the sea serpent"},{"title":"Polish the lighthouse"}]}}}}

Now the second event knows about the lighthouse.

This works as long as there is one server. In the next part you start a second one, watch events get lost between them, and connect them through Valkey.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.