Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b0476bc
Modernize OneOf input objects
zth Jul 31, 2026
7880f7b
Add input field defaults
zth Jul 31, 2026
fcd53c8
Add resolver argument metadata
zth Jul 31, 2026
b60831b
Add schema definition metadata
zth Jul 31, 2026
76cd135
Regenerate example argument defaults
zth Jul 31, 2026
35cde42
Validate generated GraphQL SDL
zth Jul 31, 2026
b2b9d00
Support async iterable list fields
zth Jul 31, 2026
c562d65
Add root field shorthand
zth Jul 31, 2026
facbcfe
Add custom scalar literal parsing
zth Jul 31, 2026
7a90374
Ship schema migration compatibility plugin
zth Jul 31, 2026
70f9804
Add configuration schema and check command
zth Jul 31, 2026
6a0a315
Expose directive metadata in schema tooling
zth Jul 31, 2026
17881be
Finalize the GraphQL capability roadmap
zth Jul 31, 2026
9ac7c21
Merge branch 'agent/directives-roadmap' into agent/graphql-roadmap
zth Aug 1, 2026
a390e64
Fix signature-backed resolver metadata
zth Aug 1, 2026
60da6ed
Respect defaults in interface arguments
zth Aug 1, 2026
c8390db
Preserve compatibility resolver semantics
zth Aug 1, 2026
2aaf60b
Escape schema metadata descriptions
zth Aug 1, 2026
ac0673d
Regenerate audited schema fixtures
zth Aug 1, 2026
5fb367b
Merge branch 'agent/directives-roadmap' into agent/graphql-roadmap
zth Aug 1, 2026
cbf9240
Regenerate escaped schema descriptions
zth Aug 1, 2026
14392c6
Merge branch 'agent/directives-roadmap' into agent/graphql-roadmap
zth Aug 1, 2026
ecd61be
Coerce generated GraphQL defaults
zth Aug 1, 2026
a788e29
Restore extended validation bindings
zth Aug 1, 2026
9c64b6f
Validate SDL during watch builds
zth Aug 1, 2026
3e9f3c9
Merge branch 'agent/directives-roadmap' into agent/graphql-roadmap
zth Aug 1, 2026
daff45e
Reject duplicate schema declarations
zth Aug 1, 2026
63faf04
Merge branch 'agent/directives-roadmap' into agent/graphql-roadmap
zth Aug 1, 2026
fbbbc55
Pin native OneOf runtime in example
zth Aug 1, 2026
d62f92e
Refresh vulnerable example dependencies
zth Aug 1, 2026
904ae9e
Attach shorthand fields to custom roots
zth Aug 1, 2026
6419d04
Merge branch 'agent/directives-roadmap' into agent/graphql-roadmap
zth Aug 1, 2026
a5b3981
Exclude development binary from package
zth Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
tests/lib
tests/**/*.mjs
!tests/runtime-interface-returns.mjs
!tests/runtime-compat.mjs
!tests/runtime-directives.mjs
!tests/runtime-oneof.mjs
tests/node_modules
tests/.bsb.lock
_build
Expand Down
65 changes: 61 additions & 4 deletions cli/Cli.res
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ module JsExn = Js.Exn

open PerfHooks.Performance

module GraphQLValidation = {
type schema
type location = {line: int, column: int}
type error = {message: string, locations?: array<location>}

@module("graphql") external buildSchema: string => schema = "buildSchema"
@module("graphql") external validateSchema: schema => array<error> = "validateSchema"
}

let args = argv->Array.slice(~start=2)->Array.keepSome
let argsList = args->List.fromArray

Expand All @@ -31,12 +40,43 @@ let printAuthorizationBaselineWarning = (authorization: option<Utils.authorizati
)
}

let validateGeneratedSdl = (schema: Utils.schemaConfig) => {
if !schema.dumpSchemaSdl {
true
} else {
let schemaPath = Path.resolve([schema.outputFolder, "schema.graphql"])
try {
let sdl = schemaPath->Fs.readFileSync->Buffer.toStringWithEncoding(StringEncoding.utf8)
let errors = sdl->GraphQLValidation.buildSchema->GraphQLValidation.validateSchema
errors->Array.forEach((error: GraphQLValidation.error) => {
let location = switch error.locations {
| Some(locations) if locations->Array.length > 0 =>
let location = locations->Array.get(0)->Option.getOrThrow(~message="GraphQL error location")
`:${location.line->Int.toString}:${location.column->Int.toString}`
| Some(_) | None => ""
}
Console.error(`${schemaPath}${location}: ${error.message}`)
})
errors->Array.length === 0
} catch {
| Exn.Error(error) =>
Console.error(`${schemaPath}: GraphQL SDL construction failed.`)
Console.error(error)
false
| _ =>
Console.error(`${schemaPath}: GraphQL SDL validation failed.`)
false
}
}
}

let helpText = `
**ResGraph v0.1.0 CLI**
This is the CLI of ResGraph. All configuration is read from \`resgraph.json\`.
Available commands:

init | Validate the project configuration.
check | Validate resgraph.json and referenced paths.
build [schema] | Build all schemas, or one named schema.
authorization baseline [schema] | Create or update a schema's authorization baseline.
watch [schema] | Watch all schemas, or one named schema.
Expand Down Expand Up @@ -157,8 +197,15 @@ let buildSchemas = (config: Utils.config, schemas: array<Utils.schemaConfig>) =>
switch Utils.callPrivateCli(GenerateSchema(schema)) {
| Completion(_) | Hover(_) | Definition(_) | FindDefinition(_) | NotInitialized => ()
| Success(_) =>
printBuildTime(schema, performance->now -. timeStart, ~showSchemaName)
printAuthorizationBaselineWarning(schema.authorization)
if validateGeneratedSdl(schema) {
printBuildTime(schema, performance->now -. timeStart, ~showSchemaName)
printAuthorizationBaselineWarning(schema.authorization)
} else {
if showSchemaName {
Console.error(`[${schema.name}] Generated GraphQL schema validation failed.`)
}
hadError := true
}
| Error({errors}) =>
if showSchemaName {
Console.error(`[${schema.name}] Schema generation failed.`)
Expand Down Expand Up @@ -235,6 +282,10 @@ try {
} else {
Console.log("✅ Project already set up correctly.")
}
| list{"check"} =>
let config = readConfig()
validateConfig(config)
Console.log("✅ ResGraph configuration is valid.")
| list{"authorization", "baseline"} => generateAuthorizationBaseline(None)
| list{"authorization", "baseline", schemaName} => generateAuthorizationBaseline(Some(schemaName))
| list{"build"} =>
Expand Down Expand Up @@ -285,8 +336,14 @@ try {
}
ErrorPrinter.printErrors(errors)
| Utils.GeneratorResult(Success(_)) =>
printBuildTime(schema, performance->now -. timeStart, ~showSchemaName)
printAuthorizationBaselineWarning(schema.authorization)
if validateGeneratedSdl(schema) {
printBuildTime(schema, performance->now -. timeStart, ~showSchemaName)
printAuthorizationBaselineWarning(schema.authorization)
} else if showSchemaName {
Console.error(
`[${schema.name}] Generated GraphQL schema validation failed.`,
)
}
| Utils.GeneratorResult(_) =>
Console.error(`[${schema.name}] Unexpected generator response.`)
}
Expand Down
12 changes: 12 additions & 0 deletions compat.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {GraphQLSchema} from "graphql";

export declare function unwrapResolverSource(source: unknown): unknown;

export interface ResGraphCompatPlugin {
onSchemaChange(input: {
schema: GraphQLSchema;
replaceSchema(schema: GraphQLSchema): void;
}): void;
}

export declare function resgraphCompatPlugin(): ResGraphCompatPlugin;
31 changes: 31 additions & 0 deletions compat.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {defaultFieldResolver, isIntrospectionType} from "graphql";

const compatResolver = Symbol.for("resgraph.compatResolver");

export const unwrapResolverSource = source => {
if (typeof source !== "object" || source === null) return source;
if ("_0" in source) return source._0;
if ("VAL" in source) return source.VAL;
return source;
};

export const resgraphCompatPlugin = () => ({
onSchemaChange({schema, replaceSchema}) {
for (const type of Object.values(schema.getTypeMap())) {
if (isIntrospectionType(type)) continue;
if (!("getFields" in type)) continue;

for (const [fieldName, field] of Object.entries(type.getFields())) {
const originalResolver = field.resolve ?? defaultFieldResolver;
if (originalResolver[compatResolver]) continue;

const resolver = (source, args, context, info) =>
originalResolver(unwrapResolverSource(source), args, context, info);
Object.defineProperty(resolver, compatResolver, {value: true});
field.resolve = resolver;
}
}

replaceSchema(schema);
},
});
7 changes: 6 additions & 1 deletion docs/docs/custom-scalars.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ module Datetime: {
type t

let parseValue: ResGraph.GraphQLLiteralValue.t => option<t>
let parseLiteral: ResGraph.GraphQLValueNode.t => option<t>
let serialize: t => ResGraph.GraphQLLiteralValue.t
} = {
type t = Date.t
Expand All @@ -147,6 +148,9 @@ module Datetime: {
| _ => None
}

let parseLiteral = node =>
node->ResGraph.GraphQLValueNode.toLiteralValue->parseValue

let serialize = d => d->Date.toJSON->Option.getExn->String
}
```
Expand All @@ -161,8 +165,9 @@ scalar Datetime
Let's distill what's going on here:

- `Datetime.t` is opaque, and the underlying type is `Date.t`, which isn't a [valid GraphQL type](valid-graphql-types).
- We define `parseValue: ResGraph.GraphQLLiteralValue.t => option<t>` and `serialize: t => ResGraph.GraphQLLiteralValue.t`. These need to be defined _exactly_ like this, as in be called those names, and use `GraphQLLiteralValue.t` + the local `t` type.
- We define `parseValue: ResGraph.GraphQLLiteralValue.t => option<t>` and `serialize: t => ResGraph.GraphQLLiteralValue.t`. These need to be named exactly as shown and use `GraphQLLiteralValue.t` plus the local `t` type.
- `parseValue` is responsible for parsing the value GraphQL gives you at runtime, into your local `t`.
- The optional `parseLiteral` hook handles values written directly in an operation. `GraphQLValueNode.toLiteralValue` lets it share coercion logic with `parseValue`. Without this hook, `graphql-js` falls back to `parseValue` for simple scalar literals, but an explicit hook is useful for AST-sensitive coercion.
- `serialize` is responsible for turning your `t` into a literal value that can be transferred to the client.

With this, your custom scalar can now be serialized and parsed even if it isn't backed by a valid GraphQL type.
34 changes: 33 additions & 1 deletion docs/docs/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,39 @@ arguments, and value coercion while generating the schema.

Applications are currently supported on scalars, objects, interfaces, unions,
enums, enum values, input objects, input fields, and output fields. Schema-level
applications and resolver-argument applications are planned follow-ups.
and resolver-argument applications use the same syntax.

## Describe and annotate the schema

Add one optional abstract `@gql.schema` marker when the schema itself needs a
description, directives, or non-conventional root names:

```rescript
/** The public commerce API. */
@gql.annotate({name: "tag", args: {name: "public"}})
@gql.schema({
query: "StorefrontQuery",
mutation: "StorefrontMutation",
})
type publicSchema
```

The `query`, `mutation`, and `subscription` fields are optional GraphQL object
type names. They can match either a type's emitted GraphQL name (including an
`@as` override) or its ReScript type name. Without a mapping, ResGraph keeps
using the conventional `query`, `mutation`, and `subscription` types. The
marker emits an explicit SDL schema definition and the equivalent
`graphql-js` schema description, roots, and directive extensions.

A bare marker is valid when only a description or directive is needed:

```rescript
/** Internal administration schema. */
@gql.schema
type adminSchema
```

Only one schema marker can belong to a generated schema.

## Repeatable directives and order

Expand Down
26 changes: 26 additions & 0 deletions docs/docs/input-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ Use `@as` for input object fields, including inline record payload fields genera

You can add comments to the type definition itself, and to all record fields. These will then be exposed in your schema.

### Default values

Use `@gql.default` when GraphQL should provide a value for an omitted input
field. The payload must be a GraphQL constant compatible with the field type.

```rescript
@gql.inputObject
type searchOptions = {
@gql.default(20)
limit: int,
@gql.default(["name", "createdAt"])
fields: array<string>,
}
```

```graphql
input SearchOptions {
limit: Int! = 20
fields: [String!]! = ["name", "createdAt"]
}
```

The generated resolver receives a complete ReScript record after GraphQL has
applied defaults. ResGraph validates defaults recursively while generating the
schema. A required input field may only be deprecated when it has a default.

### Handling `null`

Just like in [arguments of object type fields](object-types#handling-null-in-arguments), you can choose to explicitly handle `null` values by annotating fields in the input object to be `Js.Nullable.t`.
Expand Down
32 changes: 5 additions & 27 deletions docs/docs/input-unions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,12 @@ sidebar_position: 8

# Input Unions

Even though they're not officially in the spec yet, ResGraph has first class support for [input unions via the `@oneOf` server directive proposal](https://github.com/graphql/graphql-spec/pull/825).
ResGraph models input unions as standard [OneOf Input Objects](https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects).
They use graphql-js's native validation, input coercion, SDL, and introspection
support. No validation plugin is required.

## Prerequisites

You'll need to add the `@oneOf` validation package and configure it in your server to be able to use input unions. It looks like this:

```bash
# First, install the package that has the validation rule. This is using graphql-yoga@3.
npm i @envelop/extended-validation@2
```

Next, add the plugin to your GraphQL Yoga server:

```rescript
open GraphQLYoga

let yoga = createYoga({
schema: ResGraphSchema.schema,
plugins: [
Envelope.Plugin.ExtendedValidation.use({
rules: [Envelope.Plugin.ExtendedValidation.Rule.oneOfInputObjectsRule],
}),
],
```

Now you should be all set!

> You can [read more here](https://the-guild.dev/graphql/envelop/docs/guides/using-graphql-features-from-the-future) on how this plugin works with GraphQL Yoga.
Install `graphql@^16.11` or `graphql@^17` alongside ResGraph. GraphQL 17
requires Node 22 or newer; GraphQL 16 remains supported for Node 20 projects.

## Using Input Unions

Expand Down
60 changes: 10 additions & 50 deletions docs/docs/integrating-with-existing-graphql-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,72 +48,32 @@ You'll also need to set up an `Envelop` plugin. This will make sure that your cu

> Note that this is only something that needs solving when you're using ResGraph with _something else_. If all you're using is ResGraph, you don't need to set this up, it'll just work.

For now, the compat `Envelop` plugin doesn't ship with ResGrah. Instead you can copy paste it into your project from below:
ResGraph ships the compatibility plugin as `resgraph/compat.mjs`:

```typescript
// resgraphCompatPlugin.ts
import { Plugin } from "@envelop/core";
import { GraphQLSchema } from "graphql";
import { resgraphCompatPlugin } from "resgraph/compat.mjs";

export const resgraphCompatPlugin = (): Plugin => {
return {
onSchemaChange({ schema: s, replaceSchema }) {
const schema: GraphQLSchema = s;
const unwrapResolverSource = (src: unknown) => {
if (typeof src === "object" && src != null) {
if ("_0" in src) {
return src["_0"];
}
if ("VAL" in src) {
return src["VAL"];
}
}

return src;
};

const newSchema = new GraphQLSchema({
...schema.toConfig(),
types: Object.values(schema.getTypeMap()).map((type) => {
if ("getFields" in type) {
const fields = type.getFields();
Object.keys(fields).forEach((fieldName) => {
const field = fields[fieldName];
const defaultResolver = (source: any) => source[fieldName];
const originalResolver =
"resolve" in field
? field.resolve ?? defaultResolver
: defaultResolver;

if ("resolve" in field) {
field.resolve = (source, args, context, info) => {
const src = unwrapResolverSource(source);
return originalResolver(src, args, context, info);
};
}
});
}
return type;
}),
});

replaceSchema(newSchema);
},
};
};
const plugin: Plugin = resgraphCompatPlugin();
```

Finally, make sure you add the plugin to your `Envelop` setup:

```typescript
import { envelop, useEngine, useSchema } from "@envelop/core";
import { resgraphCompatPlugin } from "./resgraphCompatPlugin";
import { resgraphCompatPlugin } from "resgraph/compat.mjs";

export const getEnveloped = envelop({
plugins: [useSchema(schema), resgraphCompatPlugin(), useEngine(GraphQLJs)],
});
```

The package also exports `unwrapResolverSource` for integrations that need the
same record/variant unwrapping without Envelop. The plugin is intentionally
small: it preserves the merged schema configuration and wraps field resolvers
so ReScript record and variant payload representations work across the schema
boundary.

## 4. Duplicate the needed types

In general, the easiest way for ResGraph and an existing schema to co-exist is to _duplicate_ types between.
Expand Down
Loading