Skip to content

PostGraphile

This page takes a PostGraphile v5 origin, served through Grafserv on Express, through four steps: export the schema GraphPilot deploys, make responses cacheable, check that requests really came through GraphPilot, and purge when data changes.

PostGraphile generates its schema from your database, so there is no SDL file of yours to annotate. That shapes the first two steps: the file GraphPilot deploys is one PostGraphile writes.

A deployment is built from a schema.graphql file next to your gpilot.toml (see deployments). PostGraphile writes one for you on every schema build when you set exportSchemaSDLPath in your preset:

graphile.config.ts
import { PostGraphileAmberPreset } from "postgraphile/presets/amber"
const preset: GraphileConfig.Preset = {
extends: [PostGraphileAmberPreset],
schema: {
exportSchemaSDLPath: `${process.cwd()}/schema.graphql`,
sortExport: true,
},
// pgServices, grafserv, …
}
export default preset

That file is enough to deploy. It carries no cache hints, though, because PostGraphile does not know about any. Step 2 shows how to add them.

PostGraphile derives its schema from your database, so a migration that adds a column or a table adds fields as soon as PostGraphile picks it up. Export and deploy the schema to GraphPilot as part of that migration’s release: a query selecting a field the deployed schema does not declare is answered but never cached (CACHE_SKIPPED_UNRESOLVED_SELECTION). See deploy the schema before the origin.

GraphPilot reads @cacheControl hints from the schema.graphql you deploy. To get them into a generated schema, attach them to the types in a small plugin and print the schema with printSchemaWithDirectives from @graphql-tools/utils, which keeps directives that printSchema drops. The plugin below replaces exportSchemaSDLPath from step 1:

graphpilot-schema-plugin.ts
import { writeFileSync } from "node:fs"
import { printSchemaWithDirectives } from "@graphql-tools/utils"
// Freshness per GraphQL type. Row-level security usually makes rows per caller: say PRIVATE
// unless every caller sees the same data.
const HINTS: Record<string, { maxAge: number; scope?: "PUBLIC" | "PRIVATE"; swr?: number }> = {
Product: { maxAge: 300, scope: "PUBLIC" },
Order: { maxAge: 60, scope: "PRIVATE" },
}
const DIRECTIVE_SDL = `
enum CacheControlScope { PUBLIC PRIVATE }
directive @cacheControl(maxAge: Int, scope: CacheControlScope, inheritMaxAge: Boolean, swr: Int) on OBJECT | INTERFACE | UNION | FIELD_DEFINITION
`
export const GraphPilotSchemaPlugin: GraphileConfig.Plugin = {
name: "GraphPilotSchemaPlugin",
version: "0.0.0",
schema: {
hooks: {
finalize(schema) {
for (const [typeName, hint] of Object.entries(HINTS)) {
const type = schema.getType(typeName) as { extensions: Record<string, any> } | undefined
if (!type) continue
type.extensions = {
...type.extensions,
directives: { ...type.extensions?.directives, cacheControl: hint },
}
}
const sdl = printSchemaWithDirectives(schema, { pathToDirectivesInExtensions: ["directives"] })
writeFileSync("schema.graphql", DIRECTIVE_SDL + sdl)
return schema
},
},
},
}

Add it to your preset with plugins: [GraphPilotSchemaPlugin]. The hints exist only in the exported file; PostGraphile does not execute them, and nothing about your API changes.

If you set hints on fields too, a field hint that states no scope keeps its return type’s scope. See a field hint overrides only the arguments it sets.

The alternative is a Cache-Control header from your origin, which GraphPilot follows before the schema (see how long an entry lives). In Express that is one middleware, but it applies to every query the route serves, so it only suits an API whose data is the same for everyone.

GraphPilot signs every request it forwards with your service’s signing key (see request signing). Checking that signature lets your origin refuse anything that did not come through GraphPilot.

You need the signing key. The portal and gpilot service create each show it once, when the service is created, and neither shows it again. If you did not keep it, rotate it in the portal to get a new one. Store it as GRAPHPILOT_SIGNING_KEY.

Copy the Node.js verifyGraphPilotSignature function from verify the signature on your origin into src/graphpilot-signature.ts. The signature covers the raw request body. Parse the body with express.json() before Grafserv, keeping the bytes, and verify in a middleware on the same path. Grafserv’s Express adapter uses a body that is already parsed:

src/server.ts
import { createServer, type IncomingMessage } from "node:http"
import express from "express"
import { grafserv } from "postgraphile/grafserv/express/v4"
import { pgl } from "./pgl"
import { verifyGraphPilotSignature } from "./graphpilot-signature"
type WithRawBody = IncomingMessage & { rawBody?: Buffer }
// During a key rotation, add the pending key here as a second entry.
const SIGNING_KEYS = [process.env.GRAPHPILOT_SIGNING_KEY!]
const app = express()
app.use(
"/graphql",
express.json({ verify: (req, _res, buf) => { (req as WithRawBody).rawBody = buf } }),
(req, res, next) => {
const valid = SIGNING_KEYS.some((signingKey) =>
verifyGraphPilotSignature({
method: req.method,
pathAndQuery: req.originalUrl,
rawBody: (req as WithRawBody).rawBody ?? Buffer.alloc(0),
headers: req.headers,
signingKey,
}),
)
if (valid) next()
else res.status(401).end()
},
)
const server = createServer(app)
const serv = pgl.createServ(grafserv)
await serv.addTo(app, server)
server.listen(5678)

A request with no body (a GET) hashes the empty string, which is what Buffer.alloc(0) gives you. express.json() accepts 100 KB by default; raise its limit if your clients send larger queries. It also only reads application/json bodies; if your clients send another content type, widen it with type, or a signed request with such a body fails verification. The known test vector checks your setup byte for byte.

A mutation that returns the changed row purges it for you. GraphPilot tags the mutation response with the keys of the objects in it and purges every cached response that contained them (see automatic invalidation). PostGraphile’s generated mutations already return the row in their payload (updateProduct { product { … } }), so this works as long as the client selects that row. It does not have to select the key fields: GraphPilot adds them to the query and removes them again before the response reaches the client.

Rows often change outside GraphQL in a PostGraphile setup: a migration, a job writing straight to the database, a trigger. Purge the affected keys from that code with gpilot purge or the purgeBySurrogateKey mutation; see purging through the API and CLI. The API key needs the cache:purge permission.

A key names the object by the key field GraphPilot found in the response. In PostGraphile’s default preset that is id, the global Node ID, not the row’s primary key, so the key for a product is Product:id:<node id>: use the Node ID your API returns for that row. Enable gp-surrogate-key on a test service to see the exact keys a response carries.