Skip to main content

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 / mutation template literal under apps/{web,admin,id,stats}/src or shared/ must exist on the type it is selected from in apps/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.

Extended in ZAF-1013 (nested fields)

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: myField is checked as myField, not first, 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 against Type. 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.

Baseline retired (ZAF-926 → ZAF-942 → ZAF-943)

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 with just schemas after 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.