Zum Inhalt springen
DasPeTe Tutorials

Eine schnelle GraphQL-API mit Fastify und Yoga · Teil 5 von 8 der Serie

Teil 5: Mutations und Live-Updates mit Subscriptions

Schreib Quests mit Mutations aus, nimm sie an und erledige sie, schick jede Änderung über Server-Sent Events an die Clients, die sie abonniert haben, und halt den Cache von DataLoader in langlebigen Subscriptions frisch.

Quest-Ziele

  • Mutations mit Input-Typen und sicheren Statuswechseln schreiben
  • Clients Fehler schicken, mit denen sie etwas anfangen können
  • Events mit dem typisierten PubSub von Yoga veröffentlichen
  • Über Server-Sent Events abonnieren, mit Filtern und Topics
  • Jedem Subscription-Event frische DataLoader geben

Queries lesen. Mutations ändern Daten, und Subscriptions sagen Clients, wenn sie sich geändert haben. Beide kommen neben Query ins Schema. Ergänz sie am Ende von 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 ist ein Input-Typ: die Felder einer neuen Quest als ein Argument, das ein Client als eine einzige Variable schicken kann. questPosted hat eine optionale Gilde, für Bretter, die nur eine Gilde zeigen, und questUpdated verfolgt eine Quest. Führ pnpm codegen aus, damit die Typen davon wissen.

Die Mutations

Die Mutations brauchen ein paar Imports mehr oben in src/resolvers.ts. QuestRow und filter sind für die Subscriptions weiter unten:

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';

Dann ergänz Mutation in den Resolvern, nach Query. Es nutzt pubsub aus dem Kontext, das du gleich anlegst:

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 prüft die Eingabe und die Gilde, bevor es schreibt. Es sucht die Gilde über den Loader, der bei einer fehlenden ID fehlschlägt, und macht daraus einen Fehler, den der Client lesen kann.

acceptQuest liest die Quest nicht erst, prüft ihren Status und schreibt dann. Zwei Abenteurer könnten das im selben Moment tun, beide sähen OPEN, und beide bekämen die Quest. Stattdessen ist die Bedingung Teil des Updates: where id = 3 and status = 'OPEN'. PostgreSQL ändert die Zeile nur, wenn sie noch offen ist, und returning() ist für den Zweiten leer. completeQuest macht dasselbe für ACCEPTED. Nach dem Schreiben veröffentlicht jede Mutation, was sich geändert hat.

Der PubSub

Yoga bringt einen PubSub mit: publish schickt ein Event auf einem Kanal, und subscribe liefert alles, was darauf ankommt. Leg src/pubsub.ts an:

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;

Der Typ listet die Kanäle und was sie tragen. quest:posted trägt eine Quest. quest:updated beginnt mit einer ID, das macht ihn zu einem Topic: publish('quest:updated', 3, quest) erreicht nur, wer Quest 3 abonniert hat. Leg ihn in den Kontext:

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 };
}

Probier die Mutations aus:

~/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"}}}}

Und die Fehler, die ein Client machen kann:

~/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}

Jeder Fehler sagt, was falsch ist, und hat den Code BAD_USER_INPUT, ein Frontend kann die Fehler seiner Nutzer also von denen des Servers unterscheiden.

Die Subscriptions

Ergänz Subscription in den Resolvern, nach 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,
    },
  },

Ein Subscription-Feld hat zwei Teile. subscribe liefert den Strom der Events: Für questPosted ist das der Kanal, durch filter geleitet, wenn eine Gilde angegeben ist, und für questUpdated das Topic einer Quest. resolve macht aus jedem Event den Wert des Felds, hier die Zeile der Quest selbst. Ab da ist sie eine ganz normale Quest, und die Feld-Resolver, samt Loadern, beantworten, was der Client ausgewählt hat.

Server-Sent Events

Yoga liefert Subscriptions über Server-Sent Events: Die Antwort auf einen ganz normalen HTTP-Request bleibt offen, und jedes Event ist ein paar Zeilen Text darin. Es gibt keinen WebSocket-Server zu betreiben, es kommt durch jeden HTTP-Proxy, und die Fastify-Route, die du schon hast, kann es. Fang mit einem frischen Brett an, damit deine Ausgabe zu der hier passt:

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

Abonnier in einem Terminal Quest 3, mit -N, damit curl jedes Event zeigt, sobald es ankommt:

~/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"}}}}

Die Subscription beginnt mit :, einem Kommentar, der die Verbindung offen hält, und Yoga schickt alle 12 Sekunden einen weiteren. Die beiden Events kommen an, wenn du die Quest in einem zweiten Terminal annimmst und erledigst:

~/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 spricht ebenfalls Server-Sent Events. Starte dort eine Subscription, schreib mit curl eine Quest aus, und sie erscheint rechts. Solange eine Subscription läuft, wird der Startknopf zu einem Stoppknopf:

Eine Subscription in GraphiQL mit der Quest, die gerade ausgeschrieben wurde.

Ein veralteter Cache

Abonnier jetzt neue Quests, mit der Liste aller Quests ihrer Gilde:

~/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"}]}}}}

Die beiden Events kamen von diesen beiden 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"}}}

Lies das zweite Event genau. "Polish the lighthouse" wurde gerade ausgeschrieben, fehlt aber in der Liste der Quests seiner Gilde.

Der Grund ist der Kontext. Für eine Query legt Yoga bei jedem Request einen neuen an. Eine Subscription ist ein Request, der stundenlang lebt, ihr Kontext, und damit ihre Loader, wird also einmal angelegt und für jedes Event genutzt. Das erste Event hat die Quests der Tidecallers geladen, der Loader hat sie sich gemerkt, und das zweite Event bekam die gemerkte Liste. Sich selbst überlassen, würde der Cache einer langen Subscription außerdem mit jedem Event wachsen, das sie je verschickt hat.

Die Lösung ist ein frischer Satz Loader für jedes Event, und dafür gibt es ein Plugin:

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

Ergänz es in 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 läuft vor jedem Event und legt contextPartial über den Kontext der Subscription, hier einen neuen Satz Loader. Setz das Brett mit LOG_SQL=false pnpm db:seed zurück, abonnier noch einmal und schreib dieselben zwei Quests aus:

~/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"}]}}}}

Jetzt kennt das zweite Event den Leuchtturm.

Das funktioniert, solange es einen Server gibt. Im nächsten Teil startest du einen zweiten, siehst Events zwischen den beiden verloren gehen und verbindest sie über Valkey.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.