Skip to content

Selection set expansion

GraphQL only

Selection set expansion makes sure every field a surrogate key needs is in the query your origin receives, even when the client did not select it. Your clients write the queries they need, and GraphPilot still tags every cached response with the keys that let a write purge it.

Your client gets exactly what it asked for; the entry gets its keys.

A surrogate key is built from field values in the response, such as a product’s id. If the client did not select that field, the value is not in the response, and without expansion the cached entry would carry no key for it. A later write to that product could not purge it.

Expansion removes that dependency on the client:

  • Keys do not depend on what the client selected. The same operation is tagged with the same keys whether or not the client asked for id, so a purge reaches it either way.
  • Clients need no cache-specific fields. Nobody has to remember to select id “for the cache”, and adding such a field to one client does not split the cache from the clients that do not.
  • Composite and related keys work. A key built from a relation, such as category.id, gets the relation’s field added too.

When you deploy, the deployment build reads your schema and every configured source of surrogate keys: the @surrogateKey directive, a directive of your own, and the global rules for scalars, enums and field names. From these it knows which fields each key needs.

On every query or mutation that reaches your origin, GraphPilot compares the client’s selection set with those fields and adds the missing ones. Each added field gets an alias with the prefix set by origin.field_prefix (default __gp_), so it cannot collide with a field the client selected. The prefix is reserved for this: a query with a response key that starts with it is refused with 400 and RESERVED_FIELD_PREFIX before it reaches your origin, since its answer would otherwise be removed along with the added fields. The response key is a field’s alias when it has one and its name otherwise, so an unaliased schema field whose name starts with the prefix is refused as well. Keep that in mind before you set a short prefix such as data_: every query that selects such a field without an alias is then refused, and the fix the edge’s message asks for (“Choose a different alias”) means aliasing that field. When the response comes back, GraphPilot reads the key values from the added fields, derives the surrogate keys, and removes the added fields from the response, and from any errors entry that mentions them, before the client receives it.

Consider this schema, from a product catalog:

schema.graphql
type Query {
products(category: ID): [Product!]!
}
type Product @surrogateKey(of: "category.id") {
id: ID!
name: String!
price: Int!
category: Category!
}
type Category {
id: ID!
name: String!
}

A client sends:

Query from the client
query {
products {
name
price
}
}

GraphPilot adds what the keys on Product need: id for the automatic Product:id key (an ID field), and category.id for the key the directive declares.

Expanded query sent to your origin
query {
products {
name
price
__gp_id: id
__gp_category: category {
id
}
}
}

The alias sits on the field GraphPilot added. Fields inside an added relation, here id inside __gp_category, keep their names, because the whole relation is already set apart by its alias. No __typename is added here: Product and Category are object types, so the type of every position is known from the query. GraphPilot adds __typename only where a key sits under an interface or a union, to tell the concrete types apart.

Your origin responds:

Response from your origin
{
"data": {
"products": [
{
"name": "Studio headphones",
"price": 1999,
"__gp_id": "p01",
"__gp_category": { "id": "audio" }
},
{
"name": "Standing desk",
"price": 39900,
"__gp_id": "p06",
"__gp_category": { "id": "desks" }
}
]
}
}

From it, GraphPilot derives these surrogate keys for the cached entry:

What a purge by this key reachesSurrogate keys
Products with a specific idProduct:id:p01
Product:id:p06
Products of a specific categoryProduct:category.id:audio
Product:category.id:desks

There is no Category:id:audio among them, although Category.id is a key field: the category GraphPilot added is there for the Product key alone. A client that selects category { id name } itself gets Category:id:audio on its entry as well.

Before the response leaves, GraphPilot removes every added field, so the client receives exactly what it asked for:

Response sent to the client
{
"data": {
"products": [
{ "name": "Studio headphones", "price": 1999 },
{ "name": "Standing desk", "price": 39900 }
]
}
}

The gp-surrogate-key header shows the same thing on a real response. A client asking only for product(id: "p02") { name price } gets back an entry tagged with both keys:

gp-surrogate-key: ck:0ed2ffb7…7884 q:product Product Query Product:category.id:audio Product:id:p02

Product:category.id:audio is on every entry that contains a product of the audio category, whichever query produced it and whether or not the client selected the category. One purge reaches all of them:

Terminal window
gpilot purge --service shop-api Product:category.id:audio

It is a different key from Category:id:audio, which is on entries that contain the category object itself, such as a page that selected category { name }. A renameCategory mutation returns the category, so it purges Category:id:audio and reaches the pages that show the old name. A price change in the category is the job of Product:category.id:audio. Pick the key that matches what a write actually changes.

GraphPilot never adds fields to a fragment definition. A fragment can be spread in several places, so a field added to its definition would change every place it is used. Instead, the missing fields are added to the selection set at the place where the fragment is used, beside the spread. This is the same whether the @surrogateKey sits on the type or on the field that returns it.

Query from the client
fragment ProductFields on Product {
name
}
query {
products {
...ProductFields
}
}
Expanded query sent to your origin
fragment ProductFields on Product {
name
}
query {
products {
__gp_id: id
__gp_category: category {
id
}
...ProductFields
}
}

A fragment spread in two places gets the added fields at both places. A field that is already selected where the fragment is used, directly or through an inline fragment, is reused rather than added a second time.

A fragment cannot spread itself, directly or through another fragment: the GraphQL specification forbids fragment cycles, and your origin rejects such a document.

  • A response GraphPilot cannot read keeps the added fields. Removing them needs the response parsed as JSON. When that fails, the client receives the body exactly as your origin sent it, including the aliased key fields (__gp_id and the like), and the response is not stored (CACHE_SKIPPED_UNPARSEABLE_GRAPHQL_RESPONSE). The causes are a body larger than 133 MiB and a JSON integer larger than 18446744073709551615. A body that is not JSON at all, such as an HTML error page, carries no added fields in the first place. The added fields are only ever key fields of the objects the client selected, never data from anywhere else, and because the prefix is reserved a client cannot mistake one for a field of its own. To avoid it, return very large integers as strings (an ID or a custom scalar) and keep responses well below the size limit. See refusals that depend only on what the response carries.