@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.
Files changed (42) hide show
  1. package/README.md +307 -11
  2. package/dist/compiler.d.mts +24 -3
  3. package/dist/compiler.mjs +1876 -109
  4. package/dist/compiler.mjs.map +1 -1
  5. package/dist/index.d.mts +49 -8
  6. package/dist/index.mjs +10 -4
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/names-BlzDM5cF.mjs.map +1 -1
  9. package/dist/{plugin-BdBxr5NQ.mjs → plugin-CR_wVfRU.mjs} +247 -24
  10. package/dist/plugin-CR_wVfRU.mjs.map +1 -0
  11. package/dist/testing.d.mts +1 -1
  12. package/dist/testing.mjs +1 -1
  13. package/dist/{types-CgbV9LjD.d.mts → types-DQ713UgI.d.mts} +34 -2
  14. package/package.json +14 -9
  15. package/src/compiler/analyze.ts +129 -10
  16. package/src/compiler/builder.ts +6 -85
  17. package/src/compiler/checks.ts +105 -0
  18. package/src/compiler/conformance.ts +1068 -0
  19. package/src/compiler/constants.ts +6 -0
  20. package/src/compiler/data.ts +10 -0
  21. package/src/compiler/generate.ts +41 -27
  22. package/src/compiler/graphql-js.ts +42 -0
  23. package/src/compiler/index.ts +8 -3
  24. package/src/compiler/locate.ts +39 -1
  25. package/src/compiler/model.ts +16 -1
  26. package/src/compiler/options.ts +39 -8
  27. package/src/compiler/schema-first.ts +595 -0
  28. package/src/compiler/sdl-source.ts +529 -0
  29. package/src/compiler/sdl-view.ts +3 -1
  30. package/src/compiler/suggest.ts +39 -0
  31. package/src/decorators.ts +9 -3
  32. package/src/index.ts +5 -0
  33. package/src/metadata.ts +28 -1
  34. package/src/options.ts +23 -1
  35. package/src/pipeline.ts +66 -5
  36. package/src/plugin.ts +27 -5
  37. package/src/results.ts +206 -0
  38. package/src/scalars.ts +10 -3
  39. package/src/schema.ts +90 -12
  40. package/src/sdl.ts +17 -2
  41. package/src/types.ts +35 -0
  42. 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
- `PubSub<Events>` is an injectable abstract class (singleton, provided by `GraphQLPlugin`), where
422
- `Events` maps topics to payload types:
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
- - Several instances need a shared implementation (Redis, a broker): pass any object with
440
- `publish(topic, payload)` and `subscribe(topic, { signal })` as the `pubsub` option.
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
- | { kind: 'scalar'; name: string; scalar: ScalarDefinition<unknown> | GraphQLScalarType } // imported from user code
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
- - **No GraphQL interfaces** in this version: unions cover polymorphic results. No custom
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`)
@@ -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), given with `graphql.configure({ ... })` in
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> {