@znight/graphql 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +307 -11
- package/dist/compiler.d.mts +24 -3
- package/dist/compiler.mjs +1876 -109
- package/dist/compiler.mjs.map +1 -1
- package/dist/index.d.mts +49 -8
- package/dist/index.mjs +10 -4
- package/dist/index.mjs.map +1 -1
- package/dist/names-BlzDM5cF.mjs.map +1 -1
- package/dist/{plugin-BdBxr5NQ.mjs → plugin-CR_wVfRU.mjs} +247 -24
- package/dist/plugin-CR_wVfRU.mjs.map +1 -0
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/{types-CgbV9LjD.d.mts → types-DQ713UgI.d.mts} +34 -2
- package/package.json +14 -9
- package/src/compiler/analyze.ts +129 -10
- package/src/compiler/builder.ts +6 -85
- package/src/compiler/checks.ts +105 -0
- package/src/compiler/conformance.ts +1068 -0
- package/src/compiler/constants.ts +6 -0
- package/src/compiler/data.ts +10 -0
- package/src/compiler/generate.ts +41 -27
- package/src/compiler/graphql-js.ts +42 -0
- package/src/compiler/index.ts +8 -3
- package/src/compiler/locate.ts +39 -1
- package/src/compiler/model.ts +16 -1
- package/src/compiler/options.ts +39 -8
- package/src/compiler/schema-first.ts +595 -0
- package/src/compiler/sdl-source.ts +529 -0
- package/src/compiler/sdl-view.ts +3 -1
- package/src/compiler/suggest.ts +39 -0
- package/src/decorators.ts +9 -3
- package/src/index.ts +5 -0
- package/src/metadata.ts +28 -1
- package/src/options.ts +23 -1
- package/src/pipeline.ts +66 -5
- package/src/plugin.ts +27 -5
- package/src/results.ts +206 -0
- package/src/scalars.ts +10 -3
- package/src/schema.ts +90 -12
- package/src/sdl.ts +17 -2
- package/src/types.ts +35 -0
- package/dist/plugin-BdBxr5NQ.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -23,6 +23,11 @@ batching per execution and subscriptions over Server-Sent Events. The design is
|
|
|
23
23
|
|
|
24
24
|
No `@ObjectType`, no `@Field(() => Int)`, no `reflect-metadata`, no SDL file to keep in sync.
|
|
25
25
|
|
|
26
|
+
Your `.graphql` files are the contract? The opt-in [schema-first mode](#schema-first) reads them
|
|
27
|
+
instead, binds resolvers to their fields by name (NestJS style) and checks at build time that every
|
|
28
|
+
field is resolved and that every resolver signature conforms to the SDL
|
|
29
|
+
([RFC 0007](../../rfcs/0007-graphql-schema-first.md)).
|
|
30
|
+
|
|
26
31
|
**Example:** [`graphql-api`](https://github.com/znight-org/znight/tree/main/examples/graphql-api) (a
|
|
27
32
|
book catalog with loaders, subscriptions over SSE, per-field access decisions and a committed
|
|
28
33
|
`schema.graphql`, on Node.js and Bun).
|
|
@@ -53,11 +58,13 @@ dependencies, `dataloader` is a dependency, `@znight/testing` an optional peer (
|
|
|
53
58
|
- [Context and injection](#context-and-injection)
|
|
54
59
|
- [Authorization](#authorization)
|
|
55
60
|
- [Errors](#errors)
|
|
61
|
+
- [Errors as data](#errors-as-data)
|
|
56
62
|
- [Plugin options](#plugin-options)
|
|
57
63
|
- [GraphQL over HTTP and SSE](#graphql-over-http-and-sse)
|
|
58
64
|
- [Security defaults and limits](#security-defaults-and-limits)
|
|
59
65
|
- [Testing](#testing)
|
|
60
66
|
- [The generated SDL and the compiler options](#the-generated-sdl-and-the-compiler-options)
|
|
67
|
+
- [Schema-first](#schema-first)
|
|
61
68
|
- [Observability](#observability)
|
|
62
69
|
- [Performance](#performance)
|
|
63
70
|
- [Diagnostics](#diagnostics)
|
|
@@ -418,8 +425,11 @@ curl -N -H 'accept: text/event-stream' -H 'content-type: application/json' \
|
|
|
418
425
|
# data: {"data":{"bookAdded":{"title":"The Hobbit"}}}
|
|
419
426
|
```
|
|
420
427
|
|
|
421
|
-
|
|
422
|
-
`Events
|
|
428
|
+
The events of a subscription are born elsewhere (a mutation, a scheduled job, a message consumer),
|
|
429
|
+
while the stream stays open. `PubSub<Events>` is the mailbox between them: producers
|
|
430
|
+
`publish(topic, payload)`, every open subscription `subscribe(topic, { signal })`s and receives what
|
|
431
|
+
is published on the topic from then on (nothing is stored or replayed). It is an injectable abstract
|
|
432
|
+
class (singleton, provided by `GraphQLPlugin`), where `Events` maps topics to payload types:
|
|
423
433
|
|
|
424
434
|
```ts
|
|
425
435
|
/** Topics of the application PubSub and their payloads. */
|
|
@@ -436,8 +446,14 @@ for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal
|
|
|
436
446
|
subscriber (`subscriptions.bufferSize`, 100 events). A subscriber that falls behind gets a final
|
|
437
447
|
`SUBSCRIPTION_OVERFLOW` error and its stream ends, rather than losing events silently or buffering
|
|
438
448
|
without bound. The plugin closes the `MemoryPubSub` it created when the application is disposed.
|
|
439
|
-
-
|
|
440
|
-
|
|
449
|
+
- `MemoryPubSub` reaches the subscribers of its own process only. Several instances need a shared
|
|
450
|
+
implementation (Redis pub/sub, NATS, Postgres `LISTEN`/`NOTIFY`, a broker): extend `PubSub` and
|
|
451
|
+
pass an instance as the `pubsub` option. `subscribe()` returns its iterator synchronously, ends
|
|
452
|
+
when `signal` aborts or `return()` is called, buffers a bounded number of events per subscriber
|
|
453
|
+
(`SUBSCRIPTION_OVERFLOW` beyond), and payloads must survive serialization. The plugin does not
|
|
454
|
+
close a `PubSub` it did not create. The guide's
|
|
455
|
+
[Write your own PubSub](../../docs/guide/graphql.md#custom-pubsub) recipe is a tested Redis
|
|
456
|
+
implementation (ioredis).
|
|
441
457
|
- `subscribe()` ends when its `signal` aborts: the client left, the application shuts down, the
|
|
442
458
|
stream reached `subscriptions.maxDuration`, or (`plugin.subscribe()`) its `return()` was called.
|
|
443
459
|
Always pass `ctx.signal`.
|
|
@@ -657,6 +673,104 @@ many fields. Request errors (parse, validation, variables, limits) are never log
|
|
|
657
673
|
|
|
658
674
|
Applications may use their own codes: `new GraphQLException(message, { code: 'OUT_OF_STOCK' })`.
|
|
659
675
|
|
|
676
|
+
### Errors as data
|
|
677
|
+
|
|
678
|
+
Errors a client is expected to handle can be **values** of the field instead of entries of
|
|
679
|
+
`errors[]` (the "result union" pattern, RFC 0008). The field says which ones it can return through
|
|
680
|
+
its TypeScript return type, a union; a result mapper turns errors into those values:
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
import { Injectable } from '@znight/core'
|
|
684
|
+
import {
|
|
685
|
+
Args,
|
|
686
|
+
GraphQLErrorCode,
|
|
687
|
+
GraphQLException,
|
|
688
|
+
GraphQLPlugin,
|
|
689
|
+
type GraphQLResultMapper,
|
|
690
|
+
Query,
|
|
691
|
+
Resolver,
|
|
692
|
+
} from '@znight/graphql'
|
|
693
|
+
import { NotFoundException } from '@znight/http'
|
|
694
|
+
|
|
695
|
+
export interface NotFoundRejection {
|
|
696
|
+
readonly __typename: 'NotFoundRejection'
|
|
697
|
+
readonly message: string
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export interface ValidationRejection {
|
|
701
|
+
readonly __typename: 'ValidationRejection'
|
|
702
|
+
readonly issues: readonly string[]
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export type BookResult = Book | NotFoundRejection | ValidationRejection
|
|
706
|
+
|
|
707
|
+
@Resolver()
|
|
708
|
+
export class BookResolver {
|
|
709
|
+
constructor(private readonly books: BookService) {}
|
|
710
|
+
|
|
711
|
+
@Query()
|
|
712
|
+
book(@Args('id') id: string): Promise<BookResult> {
|
|
713
|
+
return this.books.get(id) // throws NotFoundException
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
@Injectable()
|
|
718
|
+
export class Rejections implements GraphQLResultMapper {
|
|
719
|
+
/** The object types this mapper produces: only fields whose union holds one of them use it. */
|
|
720
|
+
static readonly types = ['NotFoundRejection', 'ValidationRejection']
|
|
721
|
+
|
|
722
|
+
toResult(error: unknown): NotFoundRejection | ValidationRejection | undefined {
|
|
723
|
+
if (error instanceof NotFoundException) return { __typename: 'NotFoundRejection', message: error.message }
|
|
724
|
+
if (error instanceof GraphQLException && error.code === GraphQLErrorCode.BadUserInput) {
|
|
725
|
+
const issues = (error.extensions?.['issues'] ?? []) as readonly { readonly message: string }[]
|
|
726
|
+
return { __typename: 'ValidationRejection', issues: issues.map(issue => issue.message) }
|
|
727
|
+
}
|
|
728
|
+
return undefined
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
GraphQLPlugin.configure({ resultMappers: [Rejections] })
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
`{ book(id: "7") { ... on Book { title } ... on NotFoundRejection { message } } }` answers
|
|
736
|
+
`{ "data": { "book": { "message": "No book 7" } } }`, without `errors`.
|
|
737
|
+
|
|
738
|
+
- **Which fields.** A resolver-backed field whose type, without `!`, is a union (not a list) holding
|
|
739
|
+
one of the mapper's `types`; nothing else per field. This is decided at boot: every other field
|
|
740
|
+
keeps its resolve function, with no wrapper and no cost.
|
|
741
|
+
- **Which errors.** Everything the field's pipeline throws or rejects: its guards (a guard returning
|
|
742
|
+
`false` is a `GraphQLException` with code `FORBIDDEN`, a thrown `UnauthorizedException` is passed
|
|
743
|
+
as is), the Standard Schema validation of `@Args` (`BAD_USER_INPUT`, with `extensions.issues`),
|
|
744
|
+
the method (synchronously or as a rejected promise). Guards run before validation, so input
|
|
745
|
+
errors are only shown to clients allowed to call the field. `toResult(error, ctx)` receives the
|
|
746
|
+
raw error and the `ResolverContext` guards receive (the field's coordinate and metadata, the
|
|
747
|
+
arguments, the parent, the operation).
|
|
748
|
+
- **The value.** The first mapper returning a value that the union resolves to one of its members
|
|
749
|
+
(its discriminator, then `__typename`) wins. `undefined` passes to the next mapper; a value of
|
|
750
|
+
another object type is skipped (logged at `debug`), a value of no type is skipped (`warn`), a
|
|
751
|
+
mapper that throws is skipped (`error`). `toResult()` is synchronous: a returned promise is
|
|
752
|
+
skipped (`error`). When no mapper produces a member, the error goes to `errors[]` as above.
|
|
753
|
+
- **Per field.** An error of a child field stays an error of the child, even when its parent's
|
|
754
|
+
union holds the rejection; give the child a union to return a value there. A non-null union
|
|
755
|
+
field resolved to a value is no longer nulled.
|
|
756
|
+
- **Masking and logs.** Values are data chosen by the application: never masked, and an error
|
|
757
|
+
resolved as a value is logged at `debug` only. A mapper turning unexpected errors into values (an
|
|
758
|
+
`InternalErrorRejection`) must log them itself; prefer leaving them in `errors[]`.
|
|
759
|
+
- **Hook and transports.** A value is data: an operation whose only failure became a value has
|
|
760
|
+
the `success` outcome in `graphql:operation`, with no `errors`. HTTP, SSE and `plugin.execute()`
|
|
761
|
+
send it like any result. On a root subscription field, an error when the subscription starts
|
|
762
|
+
(a guard, or the method before it returns its stream) becomes a stream of that single event (one
|
|
763
|
+
`next`, then `complete`). The body of an `async *` method runs in the stream, after the start: its
|
|
764
|
+
errors stay in `errors[]`; check before returning the generator to map them.
|
|
765
|
+
- **At boot.** A result mapper is a provider of any scope (a class that is not a provider is
|
|
766
|
+
ZNIGHT001) or an instance (its `types` property, or its class's `static types`), with a
|
|
767
|
+
`toResult()` method. Its `types` must name
|
|
768
|
+
object types of the schema: a typo is ZNIGHT066.
|
|
769
|
+
|
|
770
|
+
The thrown error can be the value itself (Pothos style) when its class is a member of the union:
|
|
771
|
+
`toResult: error => (error instanceof OutOfStock ? error : undefined)` with
|
|
772
|
+
`static types = ['OutOfStock']`.
|
|
773
|
+
|
|
660
774
|
## Plugin options
|
|
661
775
|
|
|
662
776
|
`GraphQLPlugin.configure(options | (container) => options)`, name `graphql`. A function is evaluated
|
|
@@ -669,6 +783,7 @@ ZNIGHT066.
|
|
|
669
783
|
| `http` | `{}` | `false`: no HTTP transport (schema, `execute()` and `subscribe()` only). Object: `{ meta?, guards?, bodyLimit = '100kb', get = true }` |
|
|
670
784
|
| `guards` | `[]` | Global GraphQL guards (classes or instances implementing `GraphQLGuard`) |
|
|
671
785
|
| `errorMappers` | `[]` | `GraphQLErrorMapper` classes or instances, tried in order |
|
|
786
|
+
| `resultMappers` | `[]` | `GraphQLResultMapper` classes or instances, tried in order: errors as data on the fields whose union holds one of their `types` ([Errors as data](#errors-as-data)) |
|
|
672
787
|
| `exposeErrors` | `false` | Unmasked messages for unexpected errors (development) |
|
|
673
788
|
| `introspection` | `NODE_ENV !== 'production'` | Off: graphql-js `NoSchemaIntrospectionCustomRule`, and suggestions are hidden |
|
|
674
789
|
| `graphiql` | `NODE_ENV === 'development'` | `GET path` with `accept: text/html` serves GraphiQL |
|
|
@@ -691,7 +806,11 @@ ZNIGHT066.
|
|
|
691
806
|
| `initRoots()` / `httpRoutes()` | The `PluginInitRoots` contract (`TestApp`) and the `PluginHttpRoutes` contract of `@znight/http` |
|
|
692
807
|
|
|
693
808
|
`request` is `{ query, operationName?, variables?, extensions? }`; `options` is `{ signal?,
|
|
694
|
-
requestId? }`. A direct execution has no HTTP guards
|
|
809
|
+
requestId? }`. A direct execution has no HTTP guards of its own: inside a controller, it runs in the
|
|
810
|
+
route's request scope, after the route's guards. With `http: false`, a controller can hand its
|
|
811
|
+
requests to another GraphQL server that delegates the execution to `execute()` and `subscribe()`:
|
|
812
|
+
the guide's [Serve the schema with GraphQL Yoga](../../docs/guide/graphql.md#yoga) recipe keeps
|
|
813
|
+
guards, loaders, the request scope and error formatting.
|
|
695
814
|
|
|
696
815
|
```ts
|
|
697
816
|
const plugin = app.getPlugin<GraphQLPlugin>('graphql')
|
|
@@ -845,6 +964,7 @@ export default {
|
|
|
845
964
|
| -------- | --------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
846
965
|
| `sdl` | `false` | Path of the SDL artifact, relative to the root, written with the generated module and checked by `generate --check` |
|
|
847
966
|
| `number` | `'Float'` | GraphQL type of `number` (`'Int'` suits applications whose numbers are counts and identifiers) |
|
|
967
|
+
| `schema` | `false` | Glob(s) of the `.graphql` files of the [schema-first mode](#schema-first), relative to the root |
|
|
848
968
|
|
|
849
969
|
An invalid option is a `TypeError` when `znight.config.ts` is loaded. The SDL artifact is
|
|
850
970
|
byte-identical to graphql-js `printSchema()` of the schema the plugin builds (descriptions on one
|
|
@@ -863,6 +983,161 @@ it. With Biome, exclude it in `biome.jsonc` (as this repository does for `exampl
|
|
|
863
983
|
}
|
|
864
984
|
```
|
|
865
985
|
|
|
986
|
+
## Schema-first
|
|
987
|
+
|
|
988
|
+
With `schema`, the SDL files are the schema ([RFC 0007](../../rfcs/0007-graphql-schema-first.md)):
|
|
989
|
+
|
|
990
|
+
```ts
|
|
991
|
+
import graphql from '@znight/graphql/compiler'
|
|
992
|
+
import type { ZnightOptions } from '@znight/compiler'
|
|
993
|
+
|
|
994
|
+
export default {
|
|
995
|
+
extensions: [graphql.configure({ schema: 'src/**/*.graphql', sdl: 'schema.graphql' })],
|
|
996
|
+
} satisfies ZnightOptions
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
- **Files.** The globs (a string or a list, `!` negations allowed) are relative to the project
|
|
1000
|
+
root; `node_modules` and dot-directories are excluded unless a pattern names them; the files are
|
|
1001
|
+
read in path order, files without definitions skipped, and the `sdl` artifact is never read back.
|
|
1002
|
+
They are inputs of the compile (`watchFiles`): editing, creating or deleting one recompiles in
|
|
1003
|
+
`znight dev`, the Vite plugin and `znight generate --watch` (the Bun preload recompiles on edits,
|
|
1004
|
+
and picks a new file up at its next start).
|
|
1005
|
+
- **One source of truth.** TypeScript types never add to the schema: JSDoc, `@gqlName`,
|
|
1006
|
+
`@gqlHidden`, `@default` and `number` are ignored. Descriptions, deprecations and default values
|
|
1007
|
+
come from the SDL. The runtime is the code-first one: same metadata, `schemaHash` and `sdl`
|
|
1008
|
+
artifact (the served schema, semantically equal to the SDL without its custom directives).
|
|
1009
|
+
- **SDL.** Parsed, merged (`extend` of every kind, across files) and validated with graphql-js at
|
|
1010
|
+
build time (`graphql/language`, `graphql/type`, `graphql/utilities`, loaded lazily in this mode
|
|
1011
|
+
only), with the SDL rules graphql-js keeps internal (directive locations, arguments and
|
|
1012
|
+
repetitions, unique names) checked too. Object types, interfaces (implementing interfaces too), unions, enums, input objects,
|
|
1013
|
+
`schema { ... }` root names, `@deprecated` and `@specifiedBy` are supported. Custom directives
|
|
1014
|
+
are a ZNIGHT067 warning and left out; `@oneOf` inputs are not supported yet (ZNIGHT067).
|
|
1015
|
+
- **Scalars.** A scalar of the SDL binds the `scalar<T>('Name', definition)` declaration of its
|
|
1016
|
+
name (its description and `@specifiedBy` come from the SDL, emitted in the metadata with
|
|
1017
|
+
`sdl: true`: they replace the definition's, absent ones included, so the served schema is the
|
|
1018
|
+
artifact). Without one, `DateTime`, `BigInt` and `JSON` are the built-in scalars;
|
|
1019
|
+
any other scalar is ZNIGHT067.
|
|
1020
|
+
- **Binding by name.** `@Query()`, `@Mutation()`, `@Subscription()` bind the root field named by the
|
|
1021
|
+
decorator, else by the method. `@ResolveField()` binds a field of the object type named by the
|
|
1022
|
+
class decorator: `@Resolver('Book')` (`@Resolver('Book', { scope: 'request' })` with a scope);
|
|
1023
|
+
`@Parent()` is optional. A class may hold both. `@Args('name')` receives one argument, `@Args()`
|
|
1024
|
+
every argument as an object, `@Args('name', Schema)` validates with a Standard Schema.
|
|
1025
|
+
- **Enums and abstract types.** An enum value is its SDL name on both sides (graphql-codegen's
|
|
1026
|
+
enums compare by value, so any `namingConvention` works). Unions and interfaces resolve their
|
|
1027
|
+
members with `instanceof` for the exported classes the resolvers return (a class is matched to a
|
|
1028
|
+
member by its name, or its `__typename`), then from `__typename`. graphql-codegen declares
|
|
1029
|
+
`__typename` optional: the check accepts it, but the returned objects must carry it.
|
|
1030
|
+
|
|
1031
|
+
```graphql
|
|
1032
|
+
type Book {
|
|
1033
|
+
id: ID!
|
|
1034
|
+
title: String!
|
|
1035
|
+
author: Author!
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
extend type Query {
|
|
1039
|
+
book(id: ID!): Book
|
|
1040
|
+
}
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
```ts
|
|
1044
|
+
import { Args, Loader, Parent, Query, ResolveField, Resolver } from '@znight/graphql'
|
|
1045
|
+
|
|
1046
|
+
@Resolver('Book')
|
|
1047
|
+
export class BookResolver {
|
|
1048
|
+
constructor(private readonly catalog: CatalogService) {}
|
|
1049
|
+
|
|
1050
|
+
@Query()
|
|
1051
|
+
book(@Args('id') id: QueryBookArgs['id']): Promise<Maybe<Book>> {
|
|
1052
|
+
return this.catalog.find(id)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
@ResolveField()
|
|
1056
|
+
author(@Parent() book: BookModel, @Loader() authors: Loader<AuthorsById>): Promise<Author> {
|
|
1057
|
+
return authors.load(book.authorId)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
**Coverage (ZNIGHT068).** A root field without resolver; a method naming no field (with a "did you
|
|
1063
|
+
mean", or the root type that declares it); `@Resolver('X')` naming no type, an interface, a
|
|
1064
|
+
non-object type, or a root type with field resolvers; `@Args('x')` or an `@Args()` property naming
|
|
1065
|
+
no argument; an object field without resolver that the TypeScript type returned for its parent
|
|
1066
|
+
lacks. Two methods bound to one field are ZNIGHT061; `@ResolveField` without a type name is
|
|
1067
|
+
ZNIGHT062.
|
|
1068
|
+
|
|
1069
|
+
**Type conformance (ZNIGHT068).** Types are read with the compiler's type shapes. From every
|
|
1070
|
+
binding, the return type (`Promise<T>` unwrapped, the events of a `@Subscription`), the `@Args`
|
|
1071
|
+
parameters and the `@Parent()` type are compared with the SDL, following the fields without
|
|
1072
|
+
resolver through the properties of the returned types:
|
|
1073
|
+
|
|
1074
|
+
| SDL | Output (return types, properties): assignable | Input (arguments, input fields): accepts every value |
|
|
1075
|
+
| ---------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
1076
|
+
| `T!` / `T` | `T!` rejects `null`, `undefined`, optional properties | `T` without default: accept `undefined`; with one: `null` or `undefined`; a nullable item: `null` |
|
|
1077
|
+
| `[T]` | arrays, tuples, `Set`, `ReadonlySet`, `Iterable` | arrays |
|
|
1078
|
+
| `String`, `ID` | strings, string literals and enums, branded strings (`ID`: numbers too) | `string` (a literal union is narrower) |
|
|
1079
|
+
| `Int`, `Float` | numbers, number literals and enums, `Int` / `Float` | `number` |
|
|
1080
|
+
| `Boolean` | `boolean`, `true`, `false` | `boolean` |
|
|
1081
|
+
| `DateTime`, `BigInt`, `JSON` | `Date`; `bigint` or number; anything | `Date`; `bigint`; anything |
|
|
1082
|
+
| custom scalar | the `T` of its `scalar<T>()` | the `T` of its `scalar<T>()` |
|
|
1083
|
+
| enum | literals or an enum whose values are SDL values | `string`, or literals / an enum covering every value |
|
|
1084
|
+
| object | an object type with a conforming property for each field without resolver | – |
|
|
1085
|
+
| union, interface | each member matched by its `__typename` literal, else by name | – |
|
|
1086
|
+
| input object | – | an object type whose properties are fields of the input |
|
|
1087
|
+
|
|
1088
|
+
When a nullable argument or input field is read by TypeScript types that accept `undefined` but not
|
|
1089
|
+
`null`, the runtime turns `null` into `undefined` (`nullAsUndefined`), as in code-first (never for
|
|
1090
|
+
list items). The `T` of another `scalar<T>()` declaration (a branded id for an `ID`) is accepted for
|
|
1091
|
+
`String` and `ID`.
|
|
1092
|
+
`any`, `unknown`, opaque types, missing annotations and arguments validated by a Standard Schema are
|
|
1093
|
+
not checked. Each mismatch is one diagnostic on the TypeScript annotation, the SDL definition
|
|
1094
|
+
related:
|
|
1095
|
+
|
|
1096
|
+
```txt
|
|
1097
|
+
✗ ZNIGHT068: BookResolver.book returns Book | null, which may be null, but Query.book is Book! (non-null)
|
|
1098
|
+
--> src/book.resolver.ts:7:33
|
|
1099
|
+
|
|
|
1100
|
+
7 | book(@Args('id') id: string): Promise<Book | null> {
|
|
1101
|
+
| ^^^^^^^^^^^^^^^^^^^^
|
|
1102
|
+
|
|
|
1103
|
+
::: src/schema.graphql:7:3
|
|
1104
|
+
|
|
|
1105
|
+
7 | book(id: ID!): Book!
|
|
1106
|
+
| ---- Query.book is declared here
|
|
1107
|
+
= hint: Return a value in every case, or make Query.book nullable in the SDL (Book)
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
**graphql-codegen.** The `typescript` plugin of GraphQL Code Generator writes the TypeScript types
|
|
1111
|
+
of the SDL; map every custom scalar to the `T` of its `scalar<T>()` declaration:
|
|
1112
|
+
|
|
1113
|
+
```yaml
|
|
1114
|
+
# codegen.yml
|
|
1115
|
+
schema: src/**/*.graphql
|
|
1116
|
+
generates:
|
|
1117
|
+
src/graphql.generated.ts:
|
|
1118
|
+
plugins: [ typescript ]
|
|
1119
|
+
config:
|
|
1120
|
+
scalars:
|
|
1121
|
+
DateTime: Date
|
|
1122
|
+
EventId: ./ids.ts#EventId
|
|
1123
|
+
namingConvention:
|
|
1124
|
+
enumValues: keep
|
|
1125
|
+
useTypeImports: true
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
Its output (`Scalars['X']['output']`, `Maybe<T>`, `InputMaybe<T>`, the `QueryXxxArgs` types,
|
|
1129
|
+
`__typename` on object types, intersections with interface types) passes the checks as is; the
|
|
1130
|
+
repository tests it with the verbatim output of graphql-codegen 7.4.6
|
|
1131
|
+
([`test/fixtures/codegen`](./test/fixtures/codegen)). The `typescript-resolvers` plugin is optional:
|
|
1132
|
+
decorated methods are checked directly. Models that replace the fields a field resolver computes
|
|
1133
|
+
(`authorId` instead of `author`) conform, since only the fields without resolver must be properties.
|
|
1134
|
+
|
|
1135
|
+
**Performance.** The SDL is parsed and validated once per compile; the conformance walk visits each
|
|
1136
|
+
(SDL type, TypeScript declaration) pair once, on shapes the compiler caches. An SDL of about 560
|
|
1137
|
+
types (80 SDL files, 160 TypeScript files) compiles in about 200 ms on Bun
|
|
1138
|
+
(`test/compiler/schema-first-performance.test.ts`).
|
|
1139
|
+
Boot and execution are those of code-first.
|
|
1140
|
+
|
|
866
1141
|
## Observability
|
|
867
1142
|
|
|
868
1143
|
Every operation goes through the `graphql:operation` hook of `@znight/core` (`GraphQLOperationEvent`:
|
|
@@ -935,6 +1210,8 @@ Build-time diagnostics carry a location, a hint and, for types, the path from th
|
|
|
935
1210
|
| ZNIGHT064 | Union members that cannot be told apart (no exported classes, no `__typename`, no literal property with a distinct value per member) |
|
|
936
1211
|
| ZNIGHT065 | A `@Loader()` parameter not typed `Loader<L>`, `L` not implementing `BatchLoad<K, V>`; at runtime, a batch returning another number of values than keys, invalid loader options |
|
|
937
1212
|
| ZNIGHT066 | Boot: malformed or version-skewed `extensions.graphql` (every problem listed in one error), graphql-js schema validation, invalid options, no `HttpPlugin` with the HTTP transport, a scalar implemented under another name |
|
|
1213
|
+
| ZNIGHT067 | Schema-first SDL: no file matches, syntax errors, duplicate or unknown types, fields and directives, invalid extensions, graphql-js schema validation, `@oneOf`, a scalar without `scalar()`; custom directives and built-in scalar overrides (warnings) |
|
|
1214
|
+
| ZNIGHT068 | Schema-first: a field without resolver, a resolver, type or argument the SDL does not declare, a field nothing provides, a signature that does not conform to the SDL |
|
|
938
1215
|
|
|
939
1216
|
## Compiler contract
|
|
940
1217
|
|
|
@@ -952,12 +1229,27 @@ interface GraphQLMetadata {
|
|
|
952
1229
|
}
|
|
953
1230
|
|
|
954
1231
|
type GraphQLTypeDefinition =
|
|
955
|
-
| { kind: 'object'; name: string; description?: string; fields: GraphQLFieldDefinition[] }
|
|
1232
|
+
| { kind: 'object'; name: string; description?: string; interfaces?: string[]; fields: GraphQLFieldDefinition[] }
|
|
1233
|
+
| {
|
|
1234
|
+
kind: 'interface' // schema-first only; its fields have no resolver
|
|
1235
|
+
name: string
|
|
1236
|
+
description?: string
|
|
1237
|
+
interfaces?: string[]
|
|
1238
|
+
fields: GraphQLFieldDefinition[]
|
|
1239
|
+
discriminator: GraphQLDiscriminator
|
|
1240
|
+
}
|
|
956
1241
|
| { kind: 'input'; name: string; description?: string; fields: GraphQLInputFieldDefinition[] }
|
|
957
1242
|
| { kind: 'enum'; name: string; description?: string; values: GraphQLEnumValueDefinition[] }
|
|
958
1243
|
| { kind: 'union'; name: string; description?: string; members: string[]; discriminator: GraphQLDiscriminator }
|
|
959
1244
|
| { kind: 'scalar'; name: string; description?: string; builtin: true } // DateTime, BigInt, JSON
|
|
960
|
-
| {
|
|
1245
|
+
| {
|
|
1246
|
+
kind: 'scalar'
|
|
1247
|
+
name: string
|
|
1248
|
+
scalar: ScalarDefinition<unknown> | GraphQLScalarType // imported from user code
|
|
1249
|
+
description?: string // wins over the definition's
|
|
1250
|
+
specifiedByURL?: string
|
|
1251
|
+
sdl?: true // schema-first: description and specifiedByURL replace the definition's, absent ones included
|
|
1252
|
+
}
|
|
961
1253
|
|
|
962
1254
|
interface GraphQLFieldDefinition {
|
|
963
1255
|
name: string
|
|
@@ -1037,19 +1329,23 @@ with field resolvers after the properties, enum values in declaration order, the
|
|
|
1037
1329
|
`build` and `serve`, from sources or `dist`. Guards (`@UseGuard`) and loaders (`Loader<L>`) are
|
|
1038
1330
|
bound with lazy edges (no ZNIGHT003). The extension imports no `graphql`, `dataloader` nor runtime
|
|
1039
1331
|
entry; of `@znight/http` it only imports `@znight/http/compiler` (`readRouteMeta`,
|
|
1040
|
-
`effectiveRouteMeta`, `requiredRouteGroups`).
|
|
1332
|
+
`effectiveRouteMeta`, `requiredRouteGroups`). In schema-first mode only, it loads
|
|
1333
|
+
`graphql/language`, `graphql/type` and `graphql/utilities` lazily (never the executor). Interfaces
|
|
1334
|
+
are emitted by schema-first only: code-first sections, hashes and artifacts are unchanged.
|
|
1041
1335
|
|
|
1042
1336
|
## Limitations
|
|
1043
1337
|
|
|
1044
|
-
- **
|
|
1045
|
-
directives, no `@oneOf`.
|
|
1338
|
+
- **Code-first maps no TypeScript type to a GraphQL interface**: unions cover polymorphic results
|
|
1339
|
+
(the schema-first mode supports interfaces). No custom directives, no `@oneOf`.
|
|
1340
|
+
- Schema-first: no `__resolveType` methods (return objects carrying `__typename`, or classes), no
|
|
1341
|
+
custom internal enum values (an enum value is its SDL name).
|
|
1046
1342
|
- **Subscriptions over SSE only.** No `graphql-ws` (WebSocket), no SSE single connection mode, no
|
|
1047
1343
|
multipart subscriptions.
|
|
1048
1344
|
- No `@defer` / `@stream`, no persisted or trusted documents, no query cost analysis, no response
|
|
1049
1345
|
cache, no federation or schema stitching, no file uploads (use an HTTP upload route), no operation
|
|
1050
1346
|
batching.
|
|
1051
1347
|
- `MemoryPubSub` serves one process; a multi-instance `PubSub` is the application's (`pubsub`
|
|
1052
|
-
option).
|
|
1348
|
+
option, Redis recipe in the guide).
|
|
1053
1349
|
- Types must be written: the compiler reads annotations, it never infers return or property types.
|
|
1054
1350
|
- The SDL artifact and `schemaHash` see the `description` and `specifiedByURL` of a
|
|
1055
1351
|
`ScalarDefinition` written as literals only; a graphql-js `GraphQLScalarType` (`graphql-scalars`)
|
package/dist/compiler.d.mts
CHANGED
|
@@ -11,6 +11,19 @@ interface ResolverData {
|
|
|
11
11
|
readonly scope: Scope;
|
|
12
12
|
/** Location of `@Resolver`. */
|
|
13
13
|
readonly location: Location;
|
|
14
|
+
/**
|
|
15
|
+
* `@Resolver('Event')`: the SDL object type its `@ResolveField` methods belong to (schema-first,
|
|
16
|
+
* RFC 0007), with the location of the argument.
|
|
17
|
+
*/
|
|
18
|
+
readonly typeName?: {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly location: Location;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Schema-first: a class the analysis rejected (an error was reported); it only tells which fields
|
|
24
|
+
* its methods name (never bound, never a provider).
|
|
25
|
+
*/
|
|
26
|
+
readonly invalid?: true;
|
|
14
27
|
/** Class `@UseGuard` guards. */
|
|
15
28
|
readonly guards: readonly ClassRef[];
|
|
16
29
|
/** One entry per field decorator of the class's methods, in source order. */
|
|
@@ -62,8 +75,8 @@ type ParamData = {
|
|
|
62
75
|
/** Everything the extension stores. */
|
|
63
76
|
type GraphQLData = ResolverData;
|
|
64
77
|
/**
|
|
65
|
-
* Options of the compiler extension (RFC 0005 §6.7
|
|
66
|
-
* `znight.config.ts`. Import-free.
|
|
78
|
+
* Options of the compiler extension (RFC 0005 §6.7, RFC 0007 §4.1), given with
|
|
79
|
+
* `graphql.configure({ ... })` in `znight.config.ts`. Import-free.
|
|
67
80
|
*/
|
|
68
81
|
/** Options of `graphql.configure()`. */
|
|
69
82
|
interface GraphQLCompilerOptions {
|
|
@@ -72,13 +85,21 @@ interface GraphQLCompilerOptions {
|
|
|
72
85
|
* generated module and checked by `znight generate --check`. Default `false`: no file.
|
|
73
86
|
*/
|
|
74
87
|
readonly sdl?: string | false;
|
|
75
|
-
/** GraphQL type of `number`. Default `'Float'`; `'Int'` suits counts and identifiers. */
|
|
88
|
+
/** GraphQL type of `number`. Default `'Float'`; `'Int'` suits counts and identifiers. Code-first only. */
|
|
76
89
|
readonly number?: "Float" | "Int";
|
|
90
|
+
/**
|
|
91
|
+
* Schema-first mode (RFC 0007): globs of the `.graphql` files that define the schema, relative to
|
|
92
|
+
* the project root (`'src/**\/*.graphql'`, `!` negations allowed). Resolvers are bound to their
|
|
93
|
+
* fields by name and checked against the SDL. Default `false`: code-first.
|
|
94
|
+
*/
|
|
95
|
+
readonly schema?: string | readonly string[] | false;
|
|
77
96
|
}
|
|
78
97
|
/** {@link GraphQLCompilerOptions} with their defaults. */
|
|
79
98
|
interface ResolvedGraphQLCompilerOptions {
|
|
80
99
|
readonly sdl: string | false;
|
|
81
100
|
readonly number: "Float" | "Int";
|
|
101
|
+
/** The schema-first globs; `false`: code-first. */
|
|
102
|
+
readonly schema: readonly string[] | false;
|
|
82
103
|
}
|
|
83
104
|
/** The `@znight/graphql` compiler extension, with its options and `configure()`. */
|
|
84
105
|
export interface GraphQLCompilerExtension extends CompilerExtension<GraphQLData> {
|