@verifyhash/graphql-sdl-diff
v0.1.0
Published
Pure, dependency-free-at-runtime diff of two GraphQL SDL strings into a classified list of breaking / dangerous / safe changes, built on a vendored graphql-js.
Maintainers
Readme
graphql-sdl-diff
Compare two GraphQL SDL strings and get back a flat, classified list of what
changed between them — every entry graded breaking, dangerous, or
safe. It is a thin, honest layer over the reference
graphql-js implementation, which is
vendored and pinned in vendor/graphql/ (see VENDOR.md), so
there is no network access, no filesystem access, and no install step at
runtime.
Who it's for
Anyone who evolves a GraphQL API and wants a machine-checkable answer to "will this schema change break my clients?" — CI gates, migration reviews, changelog generation, or a schema-registry pre-flight check.
Honest limits (read these first)
This is a small, conservative tool, not a schema-registry replacement. Before you rely on it, know exactly what it does and does not do:
- Conservative rail — never a false
safe. Anything the diff cannot prove backward-compatible is gradeddangerousand tagged "review manually", never quietly downgraded.safeis emitted only for provably additive output changes (a new type, a new output field) and for noted@deprecatedchanges. - Directives and custom scalars are flagged, not resolved. A directive's or
custom scalar's runtime effect lives in your executor, outside the SDL, so this
tool surfaces those changes as
dangerous+ "review manually" rather than guessing whether they break clients. - Single-SDL / stitched-SDL input only (v1). It diffs two complete SDL strings.
It does not stitch multiple
.graphqlfiles for you, and it does not resolve Apollo Federation directives (@key,@external, …) — feed it the already-composed schema text. - Type-system breakage only. It cannot detect behavioural breakage — a field that keeps its type but changes its meaning — because no schema differ can.
- Backward-compatible relaxations report as no change, not
safe. Only added types and added output fields (and noted@deprecated) are emitted assafe. A change that is merely non-breaking — an input field made optional (String!→String), an output field made non-null (String→String!), or an output list strengthened ([String]→[String!]!) — produces an empty change list. Note the direction is opposite between input and output: on an input field adding!is breaking (callers must now supply it) while removing!is a no-change; on an output field removing!is breaking (clients can now receive null) while adding!is a no-change. Absence of an entry therefore means "nothing your clients depend on broke", not "identical". - GraphQL names must be ASCII. Per the GraphQL spec, type/field/argument names
match
/[_A-Za-z][_0-9A-Za-z]*/; non-ASCII identifiers (e.g.café,你好) are not valid SDL. Feeding them in returns a structured{ error }— never a throw and never a silent partial diff. (Unicode inside descriptions is fine.) - Built on vendored graphql-js. The parser and the core breaking/dangerous
taxonomy come from a pinned, vendored copy of graphql-js 16.11.0 (see
VENDOR.md); this library is the honest classification layer on top, not a from-scratch GraphQL implementation.
The full taxonomy and the exact edge-case behaviour are documented in What each severity means and Honesty rail (and its limits) below.
Install / run
There is nothing to install. Clone the folder and require it:
const { diffSchemas } = require('./core.js');
const before = `
type Query { user(id: ID!): User }
type User { id: ID! name: String }
`;
const after = `
type Query { user(id: ID!): User }
type User { id: ID! name: String! email: String }
`;
console.log(diffSchemas(before, after));
// [
// { type: 'FIELD_CHANGED_KIND', severity: 'breaking',
// description: 'User.name changed type from String to String!.' },
// { type: 'FIELD_ADDED', severity: 'safe',
// description: 'User.email was added.' }
// ]API
diffSchemas(oldSDL, newSDL) -> Change[] | { error }
- Returns an array of
Changeobjects on success, ordered breaking → dangerous → safe. - Never throws. Invalid SDL on either side (or a non-string argument)
returns a structured
{ error: string }instead — the message names which side failed and includes the underlying parse error.
Each Change is:
{
type: string, // e.g. 'FIELD_REMOVED', 'VALUE_ADDED_TO_ENUM', 'TYPE_ADDED'
severity: 'breaking' | 'dangerous' | 'safe',
description: string // human-readable, one line
}What each severity means
| severity | meaning | examples |
| --- | --- | --- |
| breaking | Existing valid client operations can now fail. | removed type/field, field type changed, argument made required, new required argument, enum value removed, union member removed, interface no longer implemented, output field T! → T, input field T → T! |
| dangerous | Compiles for everyone, but can surprise clients/servers at runtime. | enum value added (an input can now receive an unseen value), optional input field added, optional argument added, arg default changed |
| safe | Purely additive to the output surface; existing clients are unaffected. | a new type; a new field on an existing object/interface type |
What we add on top of graphql-js
graphql-js's findBreakingChanges / findDangerousChanges are authoritative in
the unsafe direction, but they intentionally (or, for a few categories, simply
do not) report several things. Our own pure layer fills those gaps.
Additive safe layer — graphql-js says nothing for purely additive output
changes; we classify them safe:
- Added types — a type present in the new schema but not the old.
- Added output fields — a new field on an object/interface type that exists in both schemas.
Conservative-rail semantic layer — graphql-js (as of the pinned 16.x)
reports nothing at all for the following, which would otherwise be an
invisible false safe. We detect each ourselves:
| case | our verdict | why |
| --- | --- | --- |
| directive applied/removed/changed on a type, field, arg, input field or enum value (DIRECTIVE_USAGE_CHANGED) | dangerous + review manually | a directive's runtime effect (auth, rate-limit, formatting…) is opaque to a type-only diff |
| custom directive definition add/remove/change (DIRECTIVE_DEFINITION_CHANGED) | dangerous + review manually | same — behaviour lives in the executor, not the SDL |
| custom scalar definition change, e.g. @specifiedBy url or description (CUSTOM_SCALAR_CHANGED) | dangerous + review manually | a scalar's serialize/parse contract is defined outside the SDL |
| input-field default-value change (INPUT_FIELD_DEFAULT_VALUE_CHANGE) | dangerous | clients that omit the field now send a different value (graphql-js reports the argument equivalent, but not input fields) |
| @deprecated added / removed / reason changed (DEPRECATION_CHANGED) | safe, but explicitly noted | deprecation is a standardised, backward-compatible mechanism — surfaced, never silent |
@deprecated is deliberately excluded from the directive-usage rail above: it is
the one directive whose semantics are standardised and backward-compatible, so
it is the only case this layer ever grades safe — and even then it is labelled,
not dropped.
Everything else — every removal, mutation, tightening, and every input-side
addition (enum values, input-object fields, arguments) — is delegated to
graphql-js, which grades it breaking or dangerous.
Honesty rail (and its limits)
This tool is deliberately conservative: safe is only ever emitted for the two
additive cases above, which are provably backward-compatible. If graphql-js were
ever to emit a change category this layer doesn't recognise, it is kept
dangerous and tagged "review manually" rather than silently downgraded. The
tool never invents a false safe.
Honest limits:
- Making an output field non-null (
String→String!) is genuinely safe for consumers, and graphql-js reports nothing for it — so this tool also reports nothing (no entry), rather than asafeline. It is a silent, compatible change, not a labelled one. - "Breaking" here means type-system breaking. It cannot know about behavioural breakage (a field that keeps its type but changes its meaning); no schema differ can.
- Directive and custom-scalar changes cannot be proven safe from SDL alone, so
the conservative-rail layer reports them
dangerous+ "review manually" rather than guessing. That is intentional over-caution: a human confirms them once. - Description-only edits are not breaking and are not invented as changes; the differ round-trips descriptions and comments without noise.
Tests
npm testRuns two files, both pure Node assert with no external test dependencies:
test/vectors.test.js— hand-verified golden vectors covering the full graphql-js taxonomy (removed type, removed field, field type changed, argument made required, new required argument, enum value removed/added, nullable↔non-null flips in both directions, added optional field, added type, union member removed, interface no longer implemented) plus the invalid-SDL and no-false-positive contracts.test/adversarial.test.js— conservative-rail vectors that pin the cases graphql-js under-covers: directives added/removed/changed (usage and definition), custom-scalar changes, arg vs input-field default-value changes,@deprecated(safe + noted), root-type renames, description/comment round-trip stability, a 500+ type schema (asserts no hang and prints the runtime), and duplicate/conflicting type names (structured error, never a throw). Each asserts the rail law: no uncertain change is ever a silentsafe.
License
MIT (this project). The vendored graphql-js is MIT — see VENDOR.md.
