Relay Cursors for Database Queries

Cursors are just strings, at least as far as GraphQL clients are concerned. It has absolutely no meaning beyond identifying a specific result in a set of edges. There’s no limits on its length; most are base64 encoded to emphasize that point. I'll come back to that in a moment..

Currently Code Drift is a Ghost shop, using their excellent content API, which depends on page and limit for accessing and paging through records. However, Ghost’s id isn’t sufficient for implementing a cursor. The Ghost API doesn’t have a “all posts after id:aedf0001...” call, only access to those page and limit values. We could in theory get back to a given post in Ghost as long as we knew those values.

Which brings us back to the importance of cursors being opaque to a client. What if instead of using the content’s id, we also included the page and limit values that got us here?

id:aedf001bcbc|page:2|limit:10

This works because while the client assumes the cursor to be opaque, the specification makes no such claims about the server. The server can (and should) care deeply about the cursor. In this example, our cursor can tell us how to reconstruct our query or API calls.

We can store our query’s state in our cursor. I promise, it isn’t as bad as it sounds.

Metadata In the Cursor

By definition, cursors in Relay are a string-serializable value. While anything can go into the string, I’d recommend the GraphQL Foundation’s advice of base64 encoding the value. For example, here’s a query with a cursor on Code Drift:

query {
posts(first: 1) {
edges {
cursor
}
}
}
{
"data": {
"posts": {
"edges": [
{
"cursor": "eyJ0eXBlIjoiUG9zdCIsImZpbHRlciI6bnVsbCwib3JkZXJCeSI6InB1Ymxpc2hlZF9hdCBERVNDIiwiaWQiOiI2MDc3MzE1ZTU0OTBkNjAwM2IxM2FiNjEiLCJvZmZzZXQiOjB9"
}
]
}
}
}

Our client shouldn’t care what’s in the cursor since it’s supposed to be opaque. But it’s long and starts with ey which usually means we’re going to see some JSON. Decoding the cursor shows us that it is just a JSON blob.

new Buffer("eyJ0eXBlIjoiUG9zdCIsImZpb...", 'base64').toString();
/*
{
"type": "Post",
"filter": null,
"orderBy": "published_at DESC",
"id": "6077315e5490d6003b13ab61",
"offset": 0
}
*/

It contains everything the server would need to reconstruct the original query including the offset. The next result is predictably offset + 1, assuming our query parameters such as filter and orderBy didn’t change.

Encoding the ID

The second requirement for Relay is that our IDs need to be globally unique. For a small project like Code Drift, I trust Ghost to not double up my unique IDs. If I ever was concerned though, I'd probably just follow Hasura's model of encoding the ID as JSON arrays (which guarantees the order on serialization):

const id = Buffer.from(
JSON.stringify([
1, // version id (for back-compat)
"Post", // type of record
["aedf0..."], // keys array (uniquely ID this record)
])
).toString("base64");

If you do implement these custom IDs, you'll want to update any resolvers to spot these JSON encoded keys and unpack them.

Reconstructing the Query

The biggest limitation of storing query reconstruction in the cursor is that you must revalidate the parameters. In our example, if the orderBy variable changed, then the offset is probably no longer correct. In most environments, you can probably just add checks to ensure these parameters don’t fluctuate. In a complex case, you can implement custom business logic to “find” your result by its cursor and adjust your pagination accordingly.

Since most websites only deal with forward / backward pagination of the same result set (and therefore the same query parameters), you likely won’t need the complex case. It’s significantly more code.

It should also go without saying that your cursor should only have metadata in it. Don’t shove private values in there like an AWS key. Don’t make someone’s email address part of your cursor. In general, don’t put anything in there that you wouldn’t be willing to display on the client or expose in your GraphQL endpoints.

Encoding Cursors on Return

The final step is to generate our special cursors. Every cursor needs the same data structure, allowing us to reconstruct the query regardless of which cursor the client sends along in the next request. The trickiest part is calculating the offset, but in most cases that will be offset + N + 1 where offset was in your original cursor, and N is the Nth edge in your result set.

If it helps clean up the code, use a pair of encodeCursor and decodeCursor functions for consistency in your cursor payload. Because it’s base64 (and JSON) these cursors will be larger than they probably needed to be, but come with the added benefit of making it easier to reconstruct the query later.

This pattern is used pretty heavily on Code Drift, specifically because every cursor must result in a RESTful query to Ghost’s content API. If Ghost ever implemented their own opaque cursors, I could switch over to those with a small GraphQL change. Until then though, this provides a crucial link between a Relay API and a traditional REST endpoint for an individual record regardless if it comes from an API, Firebase, or even a Postgres database.