Subscriptions
Push changes to clients as they happen: a PubSub provider, mutations that publish, a subscription that filters what each subscriber receives and one that turns a payload into a sentence, graded by specs that subscribe through the schema.
What you'll learn
- Explain subscriptions: a long-lived operation that receives an event each time the server publishes one, usually over WebSockets
- Publish events with a PubSub injected as a provider, and subscribe to them with @Subscription() returning its async iterator
- Deliver an event only to the subscribers who want it with a filter over the payload and the subscription's variables
- Shape what subscribers receive with resolve, and test subscriptions through the schema with graphql's subscribe()
The shelter's front desk shows new arrivals as they come in, and the adoption team's screen should announce every adoption the moment it happens. Polling, asking every few seconds whether anything changed, is wasteful when nothing did and late when something did. GraphQL's third operation type is the subscription: the client subscribes once, the connection stays open, and the server pushes a result each time the event happens. Each result is shaped by the subscription's selection, like a query's answer.
How events flow
A subscription resolver returns an async iterator, a stream of events. Mutations publish to it. graphql-subscriptions provides the glue, PubSub: publish(trigger, payload) sends a payload to everyone iterating over asyncIterableIterator(trigger). The docs create one module-level PubSub. Providing it under a token instead gives every class the same instance through injection, and lets tests or production swap it for one backed by Redis:
export const EVENTS = 'EVENTS';
export const eventsProvider: Provider = { provide: EVENTS, useValue: new PubSub() };
constructor(@Inject(EVENTS) private readonly events: PubSub) {}
The in-memory PubSub works inside one process only. With several servers behind a load balancer, a mutation on one never reaches subscribers on another, which is why production uses a shared implementation.
The subscription
@Mutation(() => Shift)
bookShift(@Args('input') input: ShiftInput): Shift {
const shift = this.shifts.book(input);
this.events.publish('shiftBooked', { shiftBooked: shift });
return shift;
}
@Subscription(() => Shift)
shiftBooked() {
return this.events.asyncIterableIterator('shiftBooked');
}
The payload is published under the subscription's name, { shiftBooked: shift }, because GraphQL resolves the subscription field from it like any other object. Publish the shift itself and every subscriber receives Cannot return null for non-nullable field Subscription.shiftBooked. A subscription method that forgets to return its iterator gives GraphQL nothing to iterate, and subscribing fails.
Two options shape what each subscriber receives:
filter(payload, variables)runs for each subscriber and each event, and delivers the event only when it returns true.variablesare the subscription's own arguments, soshiftBooked(day: "MON")can receive Mondays only. Declare the argument with@Args()on the method, or GraphQL refuses it.resolve(payload)turns the published payload into the value of the field. With it, publish whatever is convenient,{ shift, by }for instance, and return the field's value directly, not wrapped in the subscription's name.
Over the wire, and in tests
In production subscriptions travel over WebSockets: subscriptions: { 'graphql-ws': true } in forRoot() opens a socket server beside the HTTP endpoint, and clients such as GraphiQL or Apollo Client connect to it. This runtime has no socket server, so this lesson is graded the way the docs suggest testing a schema without one: the specs take the schema from app.get(GraphQLSchemaHost) and call graphql's own subscribe() and execute(). subscribe() returns the same async iterator a socket server would read, and each next() resolves with the next result. The resolvers, filters and PubSub are the same code either way.
Your task
test/cats.subscriptions.spec.ts is the contract; read it first. CatsService can add a cat and find one.
- Provide a
PubSubunder the tokenPUB_SUBinCatsModule, and inject it into the resolver. addCatpublishes the new cat to thecatAddedsubscription, andadoptCatpublishes the cat and the adopter.- A
catAdded(breed: String)subscription returns every new cat, or only those ofbreedwhen a subscriber gives one. - A
catAdoptedsubscription returns aString:<name> went home with <adopter>. An adoption the service refuses publishes nothing.
When it fails
Cannot return null for non-nullable field Subscription.catAdded.: the payload was published without the subscription's name as its key.Cannot read properties of undefined (reading 'breed')in the filter: the filter expects{ catAdded }and the payload is something else.- The Siamese subscriber also receives the Tabby: no
filter, or it ignoresvariables. catAdoptedfails with an object where aStringwas expected: withoutresolve, GraphQL readscatAdoptedfrom the payload and finds nothing.
Remember
- A subscription returns an async iterator; mutations publish to it through
PubSub. - Publish
{ subscriptionName: value }, or give the subscription aresolve. filter(payload, variables)decides per subscriber;variablesare the subscription's arguments.- WebSockets carry subscriptions in production; the schema itself can run them in tests.
Stuck? Show a hint
pub-sub.provider.ts: { provide: PUB_SUB, useValue: new PubSub() }, listed in the module; inject it with @Inject(PUB_SUB). Publish with this.pubSub.publish('catAdded', { catAdded: cat }) (the payload is keyed by the subscription's name) and publish('catAdopted', { cat, by }). @Subscription(() => Cat, { filter: (payload, variables) => ... }) catAdded(@Args('breed', { nullable: true }) breed?: string) { return this.pubSub.asyncIterableIterator('catAdded'); }. @Subscription(() => String, { resolve: ({ cat, by }) => ... }) catAdopted().