GraphQL Schema ↔ Proxy Drift Check
A fail-closed CI gate that catches a specific, otherwise-invisible bug class: a
frontend proxy query names a GraphQL field the schema does not define
(usually because the schema renamed it). The GraphQL call then fails validation
at runtime, and a catch → empty state handler masks the failure as "no data" —
the page renders silently empty, and code review never sees it. This is the class
behind ZAF-724 and ZAF-924.
The check lives in scripts/check-graphql-proxy-drift.mjs (pure Node, no
dependencies) and runs as the GraphQL Proxy Drift job in
.github/workflows/ci.yml on every PR.
The invariant
Every GraphQL field referenced in a
query/mutationtemplate literal underapps/{web,admin,id,stats}/srcorshared/must exist on the type it is selected from inapps/api/schema.graphql- at every depth of the selection set, not just the operation root.
The scan covers proxy route handlers, server-side loaders such as
apps/web/src/lib/settings/loaders/* (serverGqlSSR() callers), and the shared
API client in shared/api. Unit tests are skipped: __tests__/ directories and
*.test.ts(x) / *.spec.ts(x) files send deliberately fake operations to mocked
clients. node_modules/, .next/ and dist/ are skipped too.
The checker resolves the selection tree against the schema: it starts at
type Query / type Mutation, and for every field that has a sub-selection it
resolves that field's return type and checks the nested fields against it,
recursively.
The original gate (ZAF-926) only compared root field names. That left it
blind to the far more common drift one selection deeper - e.g. selecting
duplicateWidgetInstance { id name } when CreateWidgetResult exposes
widget / accessToken / obsUrl. Every field-level drift catalogued in the
ZAF-1005 baseline sweep sat below the root and slipped past the root-only check.
ZAF-1013 walks the full selection tree so the whole class is covered.
Run it locally
just graphql-proxy-drift
# or directly:
node scripts/check-graphql-proxy-drift.mjs # the real check
node scripts/check-graphql-proxy-drift.mjs --self-test # extractor unit tests
Exit 0 = clean, 1 = drift detected, 2 = internal/format error.
Deliberately conservative (false-positives ≈ 0)
The check is scoped narrowly so it never blocks a PR on a query it cannot
statically understand. A field is flagged only when its parent type is a
type we parsed from the schema and the field is absent from it - anything
unresolvable is skipped, never guessed at:
- Unresolvable parent ⇒ skipped. If a field's return type is a scalar, enum, input, union base, or a type the parser did not capture, its sub-selection is skipped rather than flagged. False-positives stay ≈ 0.
- Aliases are resolved.
first: myFieldis checked asmyField, notfirst, at the root and at every nested level. - Arguments are ignored.
field(input: { a: 1 })- including object-valued arguments with their own braces - do not disturb the selection walk. - Inline fragments switch context. Fields under
... on Type { … }are checked againstType. Plain fragment spreads (...Name) resolve to a definition the static extractor does not follow, so they are skipped. - Dynamic queries are skipped. An operation whose body contains a
${…}template interpolation cannot be statically resolved, so it is skipped whole. - Built-in meta fields (
__typename,__schema,__type) are always valid.
Allowlist escape hatch
scripts/graphql-proxy-drift-allowlist.json is the documented escape hatch. Add a
field name here (matched by bare field name, at any depth) only when it is a
known, tracked exception the static extractor cannot resolve. Every entry needs a
reason.
{
"allow": [
{ "field": "someComputedAliasField", "kind": "query", "reason": "built via a computed alias the extractor can't see", "trackedBy": "ZAF-1234" }
]
}
Prefer fixing the query (rename it to the canonical schema field) or fixing the extractor over allowlisting. New drift must be fixed, not allowlisted - the allowlist is for pre-existing/tracked exceptions only.
The root-field check shipped with a 14-entry baseline of pre-existing drifts.
ZAF-942 burned down 12 (7 renamed to the canonical schema field, 4 removed as dead
consumer-less proxy routes, 1 re-derived), and ZAF-943 cleared the final 2 by
landing the developer-facing verification resolvers. The allowlist is now empty
("allow": []). New drift must be fixed, not allowlisted. The ZAF-1013
nested-field extension started from a clean root-field baseline; the field-level
drifts it surfaces are fixed in the frontend, never allowlisted.
When you touch a GraphQL query or a resolver name
- Renaming any field (root or nested) in the API? Update every frontend proxy query that references it - the check will fail the PR otherwise, at whatever depth the field is selected.
- Adding a new proxy query? Make sure every field it selects exists on its parent
type in
apps/api/schema.graphql(regenerate withjust schemasafter backend changes).
Extending the check
The extractor is intentionally simple. If a real query pattern trips it (a genuine
false positive), the right fix is usually to teach the extractor about that pattern
rather than to allowlist - allowlisting hides real drift on the same field name.
The --self-test mode builds a synthetic schema and synthetic drift source
in-memory and asserts the extractor flags an invented field; extend those cases
when you change the extractor.