graphql-schema-env-sync
v1.0.2
Published
Multi-environment GraphQL schema sync with compatibility-safe TypeScript codegen
Downloads
274
Maintainers
Readme
graphql-schema-env-sync
Sync GraphQL schemas across multiple environments and generate compatibility-safe TypeScript types for apps that switch GraphQL endpoints at runtime.
When develop, staging, and production drift apart, this library:
- Introspects every environment
- Merges schemas into a compatibility SDL
- Marks fields as optional when they are missing in any environment
- Adds availability comments per field
- Runs
graphql-codegenagainst the merged schema - Generates a JSON report and optional default normalizers
The problem
Your app can point at different GraphQL URLs at runtime. If develop has fields that staging or production do not, generated types and hooks can lie to TypeScript and cause runtime errors.
Example:
develop
type Starship {
id: ID!
name: String!
length(unit: LengthUnit = METER): Float
}staging
type Starship {
id: ID!
length(unit: LengthUnit = METER): Float
}production
type Starship {
id: ID!
}After sync, the merged schema becomes environment-safe:
type Starship {
id: ID!
"""
Available in: develop
Missing in: staging, production
"""
name: String
"""
Available in: develop, staging
Missing in: production
"""
length(unit: LengthUnit = METER): Float
}TypeScript types then treat name and length as optional when they are not present everywhere.
Install
Core (schema sync, reports, helpers)
Installs graphql-schema-env-sync plus its runtime dependencies (graphql, commander, zod). Codegen packages are not installed.
pnpm add -D graphql-schema-env-syncRun without TypeScript/Apollo codegen:
pnpm graphql-schema-env-sync generate --skip-codegenOutputs: schema.compat.graphql, compat-report.json, compat-report.html, and defaults.ts.
With TypeScript + Apollo hooks (optional)
Add @graphql-codegen/* only if you want graphql.tsx with types and hooks:
pnpm add -D graphql-schema-env-sync \
@graphql-codegen/cli \
@graphql-codegen/typescript \
@graphql-codegen/typescript-operations \
@graphql-codegen/typescript-react-apolloThen run the full generate (see Quick start) without --skip-codegen.
Also works with npm or yarn:
npm install -D graphql-schema-env-syncQuick start
Create graphql-schema-env-sync.config.ts:
import type { SchemaSyncConfig } from 'graphql-schema-env-sync';
const config: SchemaSyncConfig = {
environments: {
develop: {
url: 'https://dev-api.example.com/graphql',
priority: 1
},
staging: {
url: 'https://staging-api.example.com/graphql',
priority: 2
},
production: {
url: 'https://prod-api.example.com/graphql',
priority: 3
}
},
baseEnvironment: 'develop',
output: {
compatSchema: './generated/schema.compat.graphql',
types: './generated/graphql.tsx',
report: './generated/compat-report.json',
defaults: './generated/defaults.ts'
},
codegen: {
documents: 'src/**/*.graphql',
plugins: ['typescript', 'typescript-operations', 'typescript-react-apollo'],
config: {
withHooks: true
}
}
};
export default config;You can also use simple URL strings:
environments: {
develop: 'https://dev-api.example.com/graphql',
staging: 'https://staging-api.example.com/graphql',
}Run (with codegen packages installed):
pnpm graphql-schema-env-sync generateCore-only install — use --skip-codegen as shown in Install.
Or from package.json:
{
"scripts": {
"generate:graphql": "graphql-schema-env-sync generate"
}
}Outputs
| File | Purpose |
| --------------------------------- | --------------------------------------------------------- |
| generated/schema.compat.graphql | Merged compatibility schema with environment comments |
| generated/graphql.tsx | TypeScript types + Apollo hooks from graphql-codegen |
| generated/compat-report.json | Machine-readable diff report |
| generated/compat-report.html | Visual per-environment report (types, queries, mutations) |
| generated/defaults.ts | Default values and normalize* helpers |
Open compat-report.html in your browser for the clearest view. The JSON report now groups results per environment:
{
"byEnvironment": {
"develop": {
"role": "base",
"types": ["LengthUnit", "Starship"],
"queries": ["starship"],
"mutations": []
},
"staging": {
"role": "target",
"missedTypes": [
{ "name": "Starship", "kind": "OBJECT", "missedFields": ["name"] }
],
"missedQueries": [],
"missedMutations": []
}
}
}Defaults, normalizers, and request helpers
The library does not mutate hook responses automatically. Instead it generates helpers in defaults.ts.
Response fields (after the server responds)
export const StarshipDefaults = {
name: '',
length: null
} as const;
export function normalizeStarship<T extends Record<string, unknown>>(
value?: T | null
) {
return {
...(value ?? {}),
name: value?.name ?? StarshipDefaults.name,
length: value?.length ?? StarshipDefaults.length
};
}Use them at the boundary:
const starship = normalizeStarship(data?.starship);Operation args (before the server receives the request)
When args differ between environments, defaults.ts also generates:
export type GraphqlEnvironment = 'develop' | 'staging' | 'production';
export function isOperationAvailable(
environment: GraphqlEnvironment,
parentType: 'Query' | 'Mutation' | 'Subscription',
fieldName: string
): boolean;
export function filterOperationArgs(
environment: GraphqlEnvironment,
parentType: 'Query' | 'Mutation' | 'Subscription',
fieldName: string,
args: Record<string, unknown>
): Record<string, unknown>;Example — dev has getQueryData(age: Int!, name: String!), staging only has age:
import { useGetQueryDataQuery } from './generated/graphql';
import {
filterOperationArgs,
isOperationAvailable,
type GraphqlEnvironment
} from './generated/defaults';
function useGetQueryData(
environment: GraphqlEnvironment,
age: number,
name?: string
) {
const skip = !isOperationAvailable(environment, 'Query', 'getQueryData');
return useGetQueryDataQuery({
skip,
variables: filterOperationArgs(environment, 'Query', 'getQueryData', {
age,
name
})
});
}Important: your .graphql document can declare the full base-environment shape. With the Apollo compat link, unsupported fields and args are removed automatically at runtime. Without it, keep documents to the intersection of all environments:
# Only declare args that exist in every environment
query GetQueryData($age: Int!) {
getQueryData(age: $age) {
id
}
}Use filterOperationArgs when you build variables in TypeScript. For dev-only args, either use a separate operation document or guard the call with isOperationAvailable.
Runtime environment switching (Apollo Client)
filterOperationArgs only strips variables. If your query document still requests fields or args that staging does not support, the server will reject the request. After graphql-schema-env-sync generate, defaults.ts also exports adaptDocumentForEnvironment() which rewrites the GraphQL document AST for the active environment — removing unsupported root operations, args, and selection fields.
Wire it with an Apollo Link so every request is adapted when the user changes environment:
import { ApolloClient, HttpLink, InMemoryCache, from } from '@apollo/client';
import { createEnvironmentCompatLink } from 'graphql-schema-env-sync/apollo';
import {
environmentCompatHelpers,
normalizeMovie,
type GraphqlEnvironment
} from './graphql/generated/defaults';
let currentEnvironment: GraphqlEnvironment = 'development';
export function setGraphqlEnvironment(environment: GraphqlEnvironment) {
currentEnvironment = environment;
client.setLink(
from([
compatLink,
new HttpLink({ uri: getEndpointForEnvironment(environment) })
])
);
void client.resetStore();
}
const compatLink = createEnvironmentCompatLink({
getEnvironment: () => currentEnvironment,
helpers: environmentCompatHelpers
});
export const client = new ApolloClient({
link: from([
compatLink,
new HttpLink({ uri: getEndpointForEnvironment(currentEnvironment) })
]),
cache: new InMemoryCache()
});Switching environments
Call setGraphqlEnvironment('staging') from your UI — use the exact keys from your config (staging, not Staging). The example above updates the HTTP link, resets the cache, and refetches with the compat link so fields like description and rating are stripped or padded for staging.
The compat link keeps id (and __typename when present) on object selections so Apollo's cache can normalize results. Skipped root operations (e.g. getMovie on staging) return { getMovie: null } instead of { data: null } to avoid cache write errors.
After each successful response, the Apollo link pads missing fields with null using typeFieldAvailability from your generated helpers — no extra runtime import in defaults.ts.
Troubleshooting Apollo cache error 13
If you see console errors like Missing field 'language' while writing result when switching to staging, Apollo is writing your development query shape against staging data. That means the compat link is not padding responses. Check:
environmentCompatHelpersis passed — not justadaptDocumentForEnvironmentalone- Compat link is before
HttpLink—from([compatLink, httpLink]) - Regenerated
defaults.tsafter updatinggraphql-schema-env-sync - Cleared Vite cache —
rm -rf node_modules/.vite client.resetStore()when switching environments
For responses, still normalize at the boundary:
const movies = data?.getMovies?.map(normalizeMovie) ?? [];Write queries against your base environment (the richest schema). The link adapts them downward for older environments automatically.
Optional: install @apollo/client only if you use the Apollo link. The runtime adapter lives in graphql-schema-env-sync/runtime if you use another client.
Programmatic API
import { generate } from 'graphql-schema-env-sync';
await generate({
configPath: './graphql-schema-env-sync.config.ts'
});CLI
graphql-schema-env-sync generate
graphql-schema-env-sync generate --config ./graphql-schema-env-sync.config.ts
graphql-schema-env-sync generate --skip-codegenConfig reference
interface SchemaSyncConfig {
environments: Record<
string,
| string
| {
url: string;
priority?: number;
headers?: Record<string, string>;
}
>;
baseEnvironment: string;
output: {
compatSchema: string;
types: string;
report: string;
defaults: string;
};
headers?: Record<string, string>;
codegen?: {
documents?: string | string[];
plugins?: string[];
config?: Record<string, unknown>;
};
}Notes
baseEnvironmentis used as the source of truth for field definitions when a field exists there.- Fields missing in any environment are made nullable in the merged schema.
- A field is only required in generated types if it is non-null in every environment.
- Per-environment headers are supported for auth during introspection.
Contributing
See CONTRIBUTING.md for development setup and pull request guidelines.
Please report bugs using the issue template.
License
MIT — see LICENSE for details.
See also CHANGELOG.md and CODE_OF_CONDUCT.md.
