GraphQL Live Queries

7 Aug 2022

Quite often you'll work with an application that you'll want to receive the most up to date data for. Quite often we turn to GraphQL Subscriptions. Subscriptions are great for knowing what changed and why, but there are times you simply don't care, and just want the updated data.

GraphQL "live" queries is a concept that's been around for quite a while, and implementations can vary. Some are even subscriptions under the hood.

Today we'll look at the library @n1ru4l/in-memory-live-query-store which allows us to create a new InMemoryLiveQueryStore to keep track of and invalidate data, in-memory.

The InMemoryLiveQueryStore is a drop-in replacement for the execute function provided by the GraphQL package.

When queries are invoked with the custom directive @live it will return a AsyncIterator instead of a Promise. This can then be used to send updates to the client. This is similar to what we did with GraphQL Subscriptions & Server Sent Events.

Let's take the following GraphQL Yoga server:

import { createServer } from "@graphql-yoga/node";

type Bid = {
  amount: number;
};

type Auction = {
  id: string;
  title: string;
  bids: Bid[];
};

const auctions: Auction[] = [
  { id: "1", title: "Digital-only PS5", bids: [{ amount: 100 }] },
];

const server = createServer({
  schema: {
    typeDefs: [
      /* GraphQL */ `
        type Query {
          auction(id: ID!): Auction
        }
        type Auction {
          id: ID!
          title: String!
          highestBid: Bid
          bids: [Bid!]!
        }
        type Bid {
          amount: Int!
        }
        type Mutation {
          bid(input: BidInput!): Bid
        }
        input BidInput {
          auctionId: ID!
          amount: Int!
        }
      `,
    ],
    resolvers: {
      Query: {
        auction: (_, { id }) => auctions.find((a) => a.id === id),
      },
      Mutation: {
        bid: async (_, { input }) => {
          const { auctionId, amount } = input;

          const index = auctions.findIndex((a) => a.id === auctionId);

          const bid = { amount };

          auctions[index].bids.push(bid);

          return bid;
        },
      },
      Auction: {
        highestBid: ({ bids }: Auction) => {
          const [max] = bids.sort((a, b) => b.amount - a.amount);

          return max;
        },
      },
    },
  },
  plugins: [],
});

server.start();

Here we can query for an auction by ID, and add new bids using GraphQL mutations. We also have a root resolver for the highest bid on the Auction type.

Let's fetch our only auction by executing the following query:

query GetAuctionById {
  auction(id: "1") {
    id
    title
    highestBid {
      amount
    }
    bids {
      amount
    }
  }
}

If you open a new window and send the mutation to add a new bid using this mutation:

mutation AddBid {
  bid(input: { auctionId: "1", amount: 200 }) {
    amount
  }
}

You'll notice that the original query hasn't been updated. You'll need to execute that operation again to get the updated value.

This is where live queries come in!

Let's add the following dependencies to our project:

npm install @n1ru4l/graphql-live-query @n1ru4l/in-memory-live-query-store @graphql-tools/utils

Next inside of our server we'll import some named exports for functions we'll need to use, and pass to our Yoga server:

import { useLiveQuery } from "@envelop/live-query";
import { InMemoryLiveQueryStore } from "@n1ru4l/in-memory-live-query-store";
import { GraphQLLiveDirective } from "@n1ru4l/graphql-live-query";
import { astFromDirective } from "@graphql-tools/utils";

Then we'll next instantiate a new InMemoryLiveQueryStore() and assign it to a variable.

const liveQueryStore = new InMemoryLiveQueryStore();

Further on down we'll pass to our Yoga plugins array useLiveQuery, and pass that the configuration value liveQueryStore:

const server = createServer({
  // ...
  plugins: [useLiveQuery({ liveQueryStore })],
});

The useLiveQuery plugin will now take over the execute function of GraphQL, thanks to Envelop — the missing plugin ecosystem for GraphQL.

Before we can use the @live query directive in our actual operation, we'll need to add the directive to the schema:

const server = createServer({
  schema: {
    typeDefs: [
      // ...
      astFromDirective(GraphQLLiveDirective),
    ],
    resolvers: {
      // ...
    },
  },
  // ...
});

Finally, all that's left for us to do is invalidate the query auction when a new bid is submitted. We can do this one of two ways, the first way is we can invalidate the entire Query "auction":

const server = createServer({
  schema: {
    // ...
    resolvers: {
      Mutation: {
        bid: async (_, { input }) => {
          // ...

          liveQueryStore.invalidate("Query.auction");
        },
      },
    },
  },
  // ...
});

Or we can invalidate the specific Auction by ID:

const server = createServer({
  schema: {
    // ...
    resolvers: {
      Mutation: {
        bid: async (_, { input }) => {
          // ...

          liveQueryStore.invalidate(`Auction:${auctionId}`);
        },
      },
    },
  },
  // ...
});

That's it! If we now run the same query we did previously but pass it the @live directive, it now send updates to the client each time the query store invalidates:

query GetAuctionById @live {
  auction(id: "1") {
    title
    highestBid {
      amount
    }
    bids {
      amount
    }
  }
}

Try it! In one window we'll run our query, and in another we'll send the mutation. Notice that the previous window is getting the updated data pushed automatically.

All this works in-memory, so you'll want to reach for using something like Redis in production. We'll explore that in another tutorial.