@znight/graphql 0.0.0 → 0.2.1

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1346 -0
  3. package/dist/compiler.d.mts +114 -0
  4. package/dist/compiler.mjs +4599 -0
  5. package/dist/compiler.mjs.map +1 -0
  6. package/dist/index.d.mts +583 -0
  7. package/dist/index.mjs +134 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/names-BlzDM5cF.mjs +111 -0
  10. package/dist/names-BlzDM5cF.mjs.map +1 -0
  11. package/dist/plugin-CR_wVfRU.mjs +3445 -0
  12. package/dist/plugin-CR_wVfRU.mjs.map +1 -0
  13. package/dist/testing.d.mts +101 -0
  14. package/dist/testing.mjs +306 -0
  15. package/dist/testing.mjs.map +1 -0
  16. package/dist/types-DQ713UgI.d.mts +266 -0
  17. package/package.json +97 -1
  18. package/src/compiler/analyze.ts +727 -0
  19. package/src/compiler/builder.ts +1561 -0
  20. package/src/compiler/checks.ts +105 -0
  21. package/src/compiler/conformance.ts +1068 -0
  22. package/src/compiler/constants.ts +80 -0
  23. package/src/compiler/data.ts +83 -0
  24. package/src/compiler/defaults.ts +92 -0
  25. package/src/compiler/discriminators.ts +53 -0
  26. package/src/compiler/docs.ts +55 -0
  27. package/src/compiler/generate.ts +419 -0
  28. package/src/compiler/graphql-js.ts +42 -0
  29. package/src/compiler/ids.ts +128 -0
  30. package/src/compiler/index.ts +98 -0
  31. package/src/compiler/locate.ts +82 -0
  32. package/src/compiler/model.ts +137 -0
  33. package/src/compiler/options.ts +85 -0
  34. package/src/compiler/scalars.ts +138 -0
  35. package/src/compiler/schema-first.ts +595 -0
  36. package/src/compiler/sdl-source.ts +529 -0
  37. package/src/compiler/sdl-view.ts +61 -0
  38. package/src/compiler/suggest.ts +39 -0
  39. package/src/context.ts +181 -0
  40. package/src/decisions.ts +169 -0
  41. package/src/decorators.ts +202 -0
  42. package/src/errors.ts +89 -0
  43. package/src/execution.ts +1064 -0
  44. package/src/format.ts +285 -0
  45. package/src/index.ts +89 -0
  46. package/src/limits.ts +177 -0
  47. package/src/loaders.ts +149 -0
  48. package/src/memory-pubsub.ts +133 -0
  49. package/src/metadata.ts +222 -0
  50. package/src/names.ts +47 -0
  51. package/src/options.ts +331 -0
  52. package/src/pipeline.ts +338 -0
  53. package/src/plugin.ts +579 -0
  54. package/src/pubsub.ts +36 -0
  55. package/src/results.ts +206 -0
  56. package/src/scalars.ts +138 -0
  57. package/src/schema-hash.ts +18 -0
  58. package/src/schema.ts +635 -0
  59. package/src/sdl.ts +530 -0
  60. package/src/testing.ts +454 -0
  61. package/src/tokens.ts +22 -0
  62. package/src/transport/abort.ts +80 -0
  63. package/src/transport/accept.ts +155 -0
  64. package/src/transport/graphiql.ts +106 -0
  65. package/src/transport/http.ts +373 -0
  66. package/src/transport/sse.ts +45 -0
  67. package/src/type-ref.ts +89 -0
  68. package/src/types.ts +225 -0
  69. package/src/validate-args.ts +48 -0
package/README.md ADDED
@@ -0,0 +1,1346 @@
1
+ # @znight/graphql
2
+
3
+ Code-first GraphQL for [Znight](../../README.md): **the TypeScript types are the schema**. Resolvers
4
+ are providers marked with `@Resolver`, `@Query`, `@Mutation`, `@Subscription` and `@ResolveField`;
5
+ the compiler extension (`@znight/graphql/compiler`) follows every type their signatures reach and
6
+ derives the complete schema at build time, with descriptions and deprecations taken from JSDoc.
7
+ At runtime, `GraphQLPlugin` serves it on graphql-js 17 through `HttpPlugin`, with DataLoader
8
+ batching per execution and subscriptions over Server-Sent Events. The design is
9
+ [RFC 0005](../../rfcs/0005-graphql.md).
10
+
11
+ - **Schema errors at build time.** A `Uint8Array` reached from a query, two types named `Review`, a
12
+ type used as input and output, a union that cannot be told apart at runtime, a field resolver
13
+ without parent, a loader of the wrong type: each is a `ZNIGHT06x` diagnostic with a code frame
14
+ and the path from the root field (`reached from Query.book → Book`).
15
+ - **No schema work at boot beyond graphql-js constructors.** The generated metadata holds the type
16
+ graph as data and one `invoke` arrow per resolver method; no SDL parsing, no reflection.
17
+ - **DI everywhere.** Resolvers, guards, loaders and error mappers are providers, bound and
18
+ scope-checked by the compiler. Request-scoped providers work in resolvers as in controllers.
19
+ - **One transport for every adapter.** The endpoint is a route of `HttpPlugin`: built-in server,
20
+ Fastify, Express and Hono, on Node.js and Bun, through the same pipeline as controllers.
21
+ - **One access-control vocabulary.** The route decorators of `@znight/http` (`createRouteDecorator`,
22
+ with required groups) apply to resolvers, and one guard class can protect routes and fields.
23
+
24
+ No `@ObjectType`, no `@Field(() => Int)`, no `reflect-metadata`, no SDL file to keep in sync.
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
+
31
+ **Example:** [`graphql-api`](https://github.com/znight-org/znight/tree/main/examples/graphql-api) (a
32
+ book catalog with loaders, subscriptions over SSE, per-field access decisions and a committed
33
+ `schema.graphql`, on Node.js and Bun).
34
+
35
+ ```sh
36
+ # bun
37
+ bun add @znight/core @znight/http @znight/graphql graphql
38
+ bun add -d @znight/compiler vite
39
+
40
+ # npm
41
+ npm install @znight/core @znight/http @znight/graphql graphql
42
+ npm install -D @znight/compiler vite
43
+ ```
44
+
45
+ Runs on Node.js >= 24 and Bun >= 1.3 (ESM only). `@znight/http` and `graphql` 17 are required peer
46
+ dependencies, `dataloader` is a dependency, `@znight/testing` an optional peer (for
47
+ `@znight/graphql/testing`).
48
+
49
+ ## Contents
50
+
51
+ - [Quick start](#quick-start)
52
+ - [Types are the schema](#types-are-the-schema)
53
+ - [Resolvers](#resolvers)
54
+ - [Arguments and validation](#arguments-and-validation)
55
+ - [Loaders](#loaders)
56
+ - [Subscriptions and PubSub](#subscriptions-and-pubsub)
57
+ - [Unions, generic types and scalars](#unions-generic-types-and-scalars)
58
+ - [Context and injection](#context-and-injection)
59
+ - [Authorization](#authorization)
60
+ - [Errors](#errors)
61
+ - [Errors as data](#errors-as-data)
62
+ - [Plugin options](#plugin-options)
63
+ - [GraphQL over HTTP and SSE](#graphql-over-http-and-sse)
64
+ - [Security defaults and limits](#security-defaults-and-limits)
65
+ - [Testing](#testing)
66
+ - [The generated SDL and the compiler options](#the-generated-sdl-and-the-compiler-options)
67
+ - [Schema-first](#schema-first)
68
+ - [Observability](#observability)
69
+ - [Performance](#performance)
70
+ - [Diagnostics](#diagnostics)
71
+ - [Compiler contract](#compiler-contract)
72
+ - [Limitations](#limitations)
73
+
74
+ ## Quick start
75
+
76
+ ```ts
77
+ // src/catalog/book.ts
78
+ import type { ID, Int } from '@znight/graphql'
79
+
80
+ /** A book of the catalog. */
81
+ export interface Book {
82
+ readonly id: ID
83
+ readonly title: string
84
+ /** Year of first publication, `null` when unknown. */
85
+ readonly publishedYear: Int | null
86
+ }
87
+ ```
88
+
89
+ ```ts
90
+ // src/catalog/book.resolver.ts
91
+ import { Args, type ID, Query, Resolver } from '@znight/graphql'
92
+
93
+ import type { Book } from './book.js'
94
+ import { CatalogService } from './catalog.service.js'
95
+
96
+ @Resolver()
97
+ export class BookResolver {
98
+ constructor(private readonly catalog: CatalogService) {}
99
+
100
+ /**
101
+ * One book.
102
+ * @param id The id of the book.
103
+ */
104
+ @Query()
105
+ book(@Args('id') id: ID): Promise<Book | null> {
106
+ return this.catalog.find(id)
107
+ }
108
+ }
109
+ ```
110
+
111
+ ```ts
112
+ // src/main.ts
113
+ import { Application } from '@znight/core'
114
+ import { GraphQLPlugin } from '@znight/graphql'
115
+ import { HttpPlugin } from '@znight/http'
116
+
117
+ import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
118
+
119
+ const app = await Application.create({
120
+ metadata: __ZNIGHT_METADATA__,
121
+ plugins: [HttpPlugin.configure({ port: 3000 }), GraphQLPlugin.configure()],
122
+ })
123
+ await app.start() // POST /graphql, GET /graphql (queries; GraphiQL in development), SSE subscriptions
124
+ ```
125
+
126
+ The compiler extension is discovered as soon as the code imports `@znight/graphql`: nothing else to
127
+ configure. The schema it derives:
128
+
129
+ ```graphql
130
+ """
131
+ A book of the catalog.
132
+ """
133
+ type Book {
134
+ id: ID!
135
+ title: String!
136
+
137
+ """
138
+ Year of first publication, `null` when unknown.
139
+ """
140
+ publishedYear: Int
141
+ }
142
+
143
+ type Query {
144
+ """
145
+ One book.
146
+ """
147
+ book(
148
+ """
149
+ The id of the book.
150
+ """
151
+ id: ID!
152
+ ): Book
153
+ }
154
+ ```
155
+
156
+ ```sh
157
+ curl -H 'content-type: application/json' -d '{"query":"{ book(id: \"1\") { title } }"}' http://localhost:3000/graphql
158
+ ```
159
+
160
+ ## Types are the schema
161
+
162
+ The schema is made of the root fields (`@Query`, `@Mutation`, `@Subscription`) and of every type
163
+ they reach. **Output position** is a resolver return type (`Promise<T>` unwrapped) and the fields
164
+ of object types; **input position** is an argument and the fields of input types.
165
+
166
+ | TypeScript | GraphQL | Position |
167
+ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------- |
168
+ | `string`, one string literal | `String` | both |
169
+ | `number`, one number literal | `Float` (`'Int'` with the `number` compiler option) | both |
170
+ | `Int` / `Float` / `ID` (aliases of `@znight/graphql`) | `Int` / `Float` / `ID` | both |
171
+ | `boolean`, `true`, `false` | `Boolean` | both |
172
+ | `Date` | `DateTime` (RFC 3339 with offset) | both |
173
+ | `bigint` | `BigInt` (a decimal string on the wire) | both |
174
+ | `JSONValue` (`@znight/graphql`) | `JSON`, always nullable (the type includes `null`) | both |
175
+ | the `T` of a `scalar<T>(name, ...)` declaration | the scalar `name` | both |
176
+ | TypeScript `enum` (string or numeric members) | enum: names = member names, internal values = member values | both |
177
+ | named alias of a union of string literals (`type BookSort = 'title' \| 'newest'`) | enum named after the alias, names = values = the literals | both |
178
+ | class, interface, named alias of an object type or of an intersection of those | object type (output) or input object type (input) | both |
179
+ | generic instance (`Page<Book>`) | `BookPage` (type arguments, then the declaration name), or the name of an alias | both |
180
+ | named alias of `Partial`, `Required`, `Readonly`, `Pick`, `Omit` of an object type | object or input type named after the alias | both |
181
+ | named alias of a union of object types | union | output |
182
+ | `T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>` | `[T]` | both |
183
+ | `Set<T>`, `ReadonlySet<T>`, `Iterable<T>` | `[T]` | output |
184
+ | `AsyncIterable<T>`, `AsyncGenerator<T>`, `AsyncIterableIterator<T>` (also in a `Promise`) | the field type `T` | `@Subscription` return only |
185
+ | `T \| null`, `T \| undefined`, an optional property or parameter | nullable `T` | both |
186
+ | anything else (`any`, `unknown`, `object`, `void`, functions, tuples, `Map`, `Record`, ...) | [ZNIGHT060](#diagnostics), with a hint | |
187
+
188
+ Non-null is the default: a type without `null`, `undefined` or `?` is `T!`, and list items follow
189
+ the same rule (`readonly (Book | null)[]` is `[Book]!`). `ID`, `Int`, `Float` and `JSONValue` are
190
+ plain aliases (`type Int = number`): values need no cast, and the compiler recognises them by
191
+ identity.
192
+
193
+ **Object and input types.** Their fields are the public, non-static properties (constructor
194
+ parameter properties included), getters (output only), and the members of analysable base classes
195
+ and extended interfaces, in declaration order, base members first. Methods, function-typed
196
+ properties, `#private`, `private` and `protected` members, symbol and computed keys and `@gqlHidden`
197
+ members are left out. Only types reachable from a root field are part of the schema.
198
+
199
+ **Input or output, not both.** A declaration reached in both positions is ZNIGHT063, with the fix
200
+ `export interface MoneyInput extends Money {}`. Input types take no getters, unions, sets or async
201
+ iterables, and a cycle of non-null input fields is ZNIGHT063 (no value could be written).
202
+
203
+ **Naming.** A type's GraphQL name is its declaration name, the name of the alias the annotation goes
204
+ through, or its `@gqlName`. Nothing is case-converted: enum member names, literals and properties
205
+ are used as written. `Query`, `Mutation`, `Subscription`, names starting with `__`, the specified
206
+ scalar names, invalid names and the enum values `true`, `false` and `null` are ZNIGHT061, and so are
207
+ two declarations with one GraphQL name.
208
+
209
+ **JSDoc** is read on types, properties, enum members and resolver methods:
210
+
211
+ | Tag / text | On | Effect |
212
+ | -------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- |
213
+ | Comment text | type, property, enum member, resolver method | `description` |
214
+ | `@param name text` | resolver method | description of the argument `name` |
215
+ | `@deprecated reason` | property, enum member, resolver method, argument | `@deprecated(reason:)` (on arguments and input fields: only when nullable) |
216
+ | `@default value` | property of an args or input type | default value (a JSON literal checked against the type, ZNIGHT063) |
217
+ | `@gqlHidden` | property, getter | not part of the GraphQL type |
218
+ | `@gqlName Name` | type declaration | GraphQL name of the type |
219
+
220
+ ```ts
221
+ import type { ID, Int } from '@znight/graphql'
222
+
223
+ /** A book of the catalog. */
224
+ export interface Book {
225
+ readonly kind: 'book'
226
+ readonly id: ID
227
+ readonly title: string
228
+ readonly genre: Genre
229
+ readonly tags: readonly string[]
230
+ readonly createdAt: Date
231
+ /** @deprecated Use `tags`. */
232
+ readonly category?: string
233
+ /** @gqlHidden Exposed as `author` by BookResolver. */
234
+ readonly authorId: ID
235
+ }
236
+
237
+ /** Literary genres. */
238
+ export enum Genre {
239
+ Fantasy = 'fantasy',
240
+ ScienceFiction = 'science-fiction',
241
+ }
242
+
243
+ /** A page of results. */
244
+ export interface Page<T> {
245
+ readonly items: readonly T[]
246
+ readonly total: Int
247
+ readonly hasMore: boolean
248
+ }
249
+
250
+ /** Sort orders of the book list. */
251
+ export type BookSort = 'title' | 'newest'
252
+ ```
253
+
254
+ ## Resolvers
255
+
256
+ ```ts
257
+ import {
258
+ Args,
259
+ Ctx,
260
+ type GraphQLContext,
261
+ type ID,
262
+ Loader,
263
+ Mutation,
264
+ Parent,
265
+ PubSub,
266
+ Query,
267
+ ResolveField,
268
+ Resolver,
269
+ Subscription,
270
+ } from '@znight/graphql'
271
+
272
+ @Resolver()
273
+ export class BookResolver {
274
+ constructor(
275
+ private readonly catalog: CatalogService,
276
+ private readonly events: PubSub<CatalogEvents>,
277
+ ) {}
278
+
279
+ /** Books of the catalog, filtered and paginated. */
280
+ @Query()
281
+ books(@Args() filter: BookFilter): Promise<Page<Book>> {
282
+ return this.catalog.list(filter)
283
+ }
284
+
285
+ /** Adds a book to the catalog. */
286
+ @Mutation()
287
+ async addBook(@Args('input', NewBookInput) input: NewBookInput): Promise<Book> {
288
+ const book = await this.catalog.add(input)
289
+ await this.events.publish('bookAdded', book)
290
+ return book
291
+ }
292
+
293
+ /** Who wrote the book. Batched: one repository call per execution. */
294
+ @ResolveField()
295
+ author(@Parent() book: Book, @Loader() authors: Loader<AuthorsById>): Promise<Author> {
296
+ return authors.load(book.authorId)
297
+ }
298
+
299
+ /** Books added from now on, optionally of one genre. */
300
+ @Subscription()
301
+ async *bookAdded(@Args('genre') genre: Genre | undefined, @Ctx() ctx: GraphQLContext): AsyncGenerator<Book> {
302
+ for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal })) {
303
+ if (genre === undefined || book.genre === genre) yield book
304
+ }
305
+ }
306
+ }
307
+ ```
308
+
309
+ - `@Resolver({ scope? })` registers the class as a provider, a singleton by default; `request`
310
+ creates one resolver per operation. A class is the provider of one extension: `@Resolver` and
311
+ `@Controller` on one class is ZNIGHT005 (both may call the same service).
312
+ - `@Query(name?)`, `@Mutation(name?)` and `@Subscription(name?)` declare root fields named after the
313
+ method unless a name is given. Two root fields with one name, across classes, are ZNIGHT061.
314
+ - `@ResolveField(name?)` adds a field to the type of the `@Parent()` parameter, which is mandatory
315
+ (ZNIGHT062), or replaces the property of the same name: it must then resolve the same GraphQL
316
+ type (ZNIGHT061). Field resolvers come after the properties, ordered by resolver class then source
317
+ order. A field resolver whose parent type no root field reaches is a ZNIGHT062 warning.
318
+ - Field methods are public, non-static, non-generic instance methods with an **explicit return
319
+ type** (the compiler does not infer; ZNIGHT060 otherwise). They are not inherited: a field
320
+ decorator on a class without `@Resolver` is ZNIGHT062.
321
+ - Every parameter carries exactly one GraphQL parameter decorator: `@Args`, `@Parent()`, `@Ctx()`,
322
+ `@Info()` (graphql-js `GraphQLResolveInfo`, an escape hatch) or `@Loader()`. An undecorated
323
+ parameter, or `Ctx` / `Query` imported from `@znight/http` by mistake, is ZNIGHT005 with the
324
+ import to use.
325
+ - A `@Subscription` method returns an async iterable: an `async *` generator, or
326
+ `pubsub.subscribe(...)`.
327
+ - Resolvers in a compiled provider library (`library: true`) are ZNIGHT062: the schema is derived
328
+ from sources, so publish such a package as a source library.
329
+
330
+ ## Arguments and validation
331
+
332
+ ```ts
333
+ import type { Int, StandardSchemaV1 } from '@znight/graphql'
334
+ import { z } from 'zod'
335
+
336
+ /** Criteria of `Query.books`: each property becomes an argument. */
337
+ export interface BookFilter {
338
+ /** Case-insensitive search in titles. */
339
+ readonly search?: string
340
+ readonly genre?: Genre
341
+ /** @default "title" */
342
+ readonly sort?: BookSort
343
+ /** @default 20 */
344
+ readonly limit?: Int
345
+ }
346
+
347
+ /** A new book. The interface is the GraphQL input type, the schema of the same name validates it. */
348
+ export interface NewBookInput {
349
+ readonly title: string
350
+ readonly isbn: string
351
+ readonly authorId: string
352
+ readonly genre: Genre
353
+ readonly publishedYear?: Int | null | undefined
354
+ }
355
+
356
+ export const NewBookInput: StandardSchemaV1<unknown, NewBookInput> = z.object({
357
+ title: z.string().trim().min(1, 'A title is required'),
358
+ isbn: z.string().regex(/^\d{13}$/),
359
+ authorId: z.string(),
360
+ genre: z.enum(Genre), // receives the internal value ('fantasy'): GraphQL already mapped the name
361
+ publishedYear: z.number().int().nullish(),
362
+ })
363
+ ```
364
+
365
+ - `@Args()` without name spreads the properties of the parameter type into arguments:
366
+ `books(search: String, genre: Genre, sort: BookSort = title, limit: Int = 20)`. It is not a
367
+ GraphQL type. `@Args('name')` declares one argument; mixing both is fine when names differ.
368
+ - `@Args('input', Schema)` (or `@Args(Schema)` for the whole arguments object) validates the value
369
+ with any [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType...) **after** GraphQL
370
+ coerced it. The resolver receives the schema output (transforms applied); a failure is a
371
+ `BAD_USER_INPUT` error with `extensions.issues: [{ path, message }]`. The annotation stays the
372
+ GraphQL type: the compiler never executes schemas. A schema and an interface may share a name
373
+ (`export interface NewBookInput` + `export const NewBookInput`).
374
+ - Defaults: a `@default` JSDoc tag or a statically evaluable parameter default
375
+ (`@Args('limit') limit: Int = 20`, which gives `limit: Int! = 20`) is the schema default, checked
376
+ against the type (ZNIGHT063), printed in the SDL and applied by graphql-js. A default the compiler
377
+ cannot evaluate leaves the argument nullable, and TypeScript applies it.
378
+ - **`null` versus `undefined`**: an argument or input field whose TypeScript type does not accept
379
+ `null` (`limit?: Int`) receives `undefined` when the client sends `null`; types that accept `null`
380
+ receive it as is. The resolver always gets values of its declared type.
381
+
382
+ ## Loaders
383
+
384
+ ```ts
385
+ import { Injectable } from '@znight/core'
386
+ import { type BatchLoad, type ID, orderByKeys } from '@znight/graphql'
387
+
388
+ @Injectable()
389
+ export class AuthorsById implements BatchLoad<ID, Author> {
390
+ /** Optional: DataLoader options, read once per loader class. */
391
+ readonly options = { maxBatchSize: 100 }
392
+
393
+ constructor(private readonly authors: AuthorRepository) {}
394
+
395
+ async load(ids: readonly ID[]): Promise<readonly (Author | Error)[]> {
396
+ return orderByKeys(ids, await this.authors.findMany(ids), author => author.id)
397
+ }
398
+ }
399
+ ```
400
+
401
+ - A loader is a provider implementing `BatchLoad<K, V>`: `load(keys)` returns one value (or an
402
+ `Error` failing that key only) per key, in the order of the keys. Another number of values fails
403
+ every key of the batch with ZNIGHT065.
404
+ - `@Loader() authors: Loader<AuthorsById>` injects the DataLoader of the **current execution**:
405
+ created on first use, cached for the operation (or for one subscription event), then dropped. The
406
+ compiler binds `AuthorsById` like a constructor dependency (ZNIGHT001 when it is not a provider,
407
+ ZNIGHT065 when it does not implement `BatchLoad` or the parameter is not typed `Loader<L>`). The
408
+ edge is lazy: a singleton resolver may use a request-scoped loader.
409
+ - `ctx.loader(AuthorsById)` on the `GraphQLContext` returns the same DataLoader to services called
410
+ by a resolver.
411
+ - `options` (`LoaderOptions`): `maxBatchSize`, `cache` (default `true`), `cacheKeyFn` (for object
412
+ keys). Batches are scheduled per tick; `batchScheduleFn` is not exposed.
413
+ - `orderByKeys(keys, rows, keyOf, { missing? })` reorders rows to match the keys; a missing key gets
414
+ a `NOT_FOUND` `GraphQLException` (`missing: 'error'`, the default) or `null` (`missing: 'null'`).
415
+
416
+ ## Subscriptions and PubSub
417
+
418
+ A `@Subscription` method returns an `AsyncIterable<T>`; each value is the field value of one event.
419
+ Clients subscribe over Server-Sent Events (GraphQL over SSE, distinct connections mode):
420
+
421
+ ```sh
422
+ curl -N -H 'accept: text/event-stream' -H 'content-type: application/json' \
423
+ -d '{"query":"subscription { bookAdded(genre: Fantasy) { title } }"}' http://localhost:3000/graphql
424
+ # event: next
425
+ # data: {"data":{"bookAdded":{"title":"The Hobbit"}}}
426
+ ```
427
+
428
+ `PubSub<Events>` is an injectable abstract class (singleton, provided by `GraphQLPlugin`), where
429
+ `Events` maps topics to payload types:
430
+
431
+ ```ts
432
+ /** Topics of the application PubSub and their payloads. */
433
+ export interface CatalogEvents {
434
+ bookAdded: Book
435
+ }
436
+
437
+ // in a provider: constructor(private readonly events: PubSub<CatalogEvents>) {}
438
+ await this.events.publish('bookAdded', book)
439
+ for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal })) yield book
440
+ ```
441
+
442
+ - The default is a `MemoryPubSub`: in-process fan-out, one bounded `EventChannel` (RFC 0006) per
443
+ subscriber (`subscriptions.bufferSize`, 100 events). A subscriber that falls behind gets a final
444
+ `SUBSCRIPTION_OVERFLOW` error and its stream ends, rather than losing events silently or buffering
445
+ without bound. The plugin closes the `MemoryPubSub` it created when the application is disposed.
446
+ - Several instances need a shared implementation (Redis, a broker): pass any object with
447
+ `publish(topic, payload)` and `subscribe(topic, { signal })` as the `pubsub` option.
448
+ - `subscribe()` ends when its `signal` aborts: the client left, the application shuts down, the
449
+ stream reached `subscriptions.maxDuration`, or (`plugin.subscribe()`) its `return()` was called.
450
+ Always pass `ctx.signal`.
451
+ - The source starts with the subscription: the plugin requests its first event before the SSE
452
+ response starts (or `plugin.subscribe()` resolves), so a generator that calls `subscribe()` before
453
+ its first `await` has subscribed to the PubSub by the time the client is connected, and an event
454
+ published right after is not lost. The stream reads
455
+ that one event ahead; the next ones are read as the client consumes them.
456
+ - The request scope of a subscription lasts as long as its stream: request-scoped providers live
457
+ for the subscription, while loaders are renewed for every event. Root subscription guards run
458
+ once, when the subscription starts (per-event authorization is the generator's job).
459
+ - `subscriptions.maxActive` (10 000) caps active subscriptions on every transport: beyond it, a new
460
+ subscription is answered 503 (`SERVICE_UNAVAILABLE`). `plugin.activeSubscriptions` gives the
461
+ count.
462
+
463
+ ## Unions, generic types and scalars
464
+
465
+ ```ts
466
+ /** A search hit: told apart at runtime by `kind`, found by the compiler. */
467
+ export type SearchResult = Book | Author
468
+ // search(text: String!): [SearchResult!]! union SearchResult = Author | Book
469
+ ```
470
+
471
+ A union is a **named** alias of a union of object types. The compiler picks the runtime
472
+ discriminator: `instanceof` when every member is an exported class (subclasses first), otherwise
473
+ `__typename` or a property whose literal type differs in every member (`kind` here). Neither is
474
+ ZNIGHT064.
475
+
476
+ Generic object types are named after their type arguments: `Page<Book>` is `BookPage`,
477
+ `Page<Edge<Book>>` is `BookEdgePage`. An alias wins: `export type BookList = Page<Book>` names it
478
+ `BookList`. Generic instances count per instance for the input/output rule.
479
+
480
+ ```ts
481
+ import { scalar } from '@znight/graphql'
482
+
483
+ export type Isbn = string & { readonly __brand: 'Isbn' }
484
+
485
+ export const IsbnScalar = scalar<Isbn>('Isbn', {
486
+ description: 'An ISBN-13, thirteen digits.',
487
+ specifiedByURL: 'https://www.isbn-international.org/content/what-isbn',
488
+ serialize: value => value,
489
+ parseValue: value => {
490
+ if (typeof value !== 'string' || !/^[0-9]{13}$/.test(value)) throw new TypeError('An ISBN has 13 digits')
491
+ return value as Isbn
492
+ },
493
+ })
494
+ ```
495
+
496
+ `scalar<T>(name, definition)` maps the TypeScript type `T` (a branded alias, or a global such as
497
+ `URL`) to the GraphQL scalar `name` wherever it appears. `name` is static; the result must be an
498
+ exported `const` (the generated module imports it). The definition is a `ScalarDefinition`
499
+ (`serialize`, `parseValue`, optional `parseLiteral`, `description`, `specifiedByURL`, mapped to the
500
+ graphql-js 17 coercers) or a graphql-js `GraphQLScalarType` such as those of `graphql-scalars`,
501
+ whose name must be `name` (ZNIGHT066 at boot otherwise).
502
+
503
+ Built-in scalars appear in the schema only when used: `String`, `Float`, `Int`, `ID`, `Boolean`,
504
+ `DateTime` (`Date`; RFC 3339 with a mandatory offset, `@specifiedBy` RFC 3339), `BigInt`
505
+ (`bigint`; a decimal string on the wire) and `JSON` (`JSONValue`).
506
+
507
+ ## Context and injection
508
+
509
+ `GraphQLContext` is the context of one operation, a request-scoped external
510
+ (`@znight/graphql#GraphQLContext`, `GraphQLTokens.Context`):
511
+
512
+ | Member | What |
513
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
514
+ | `requestId` | Id of the request carrying the operation (`HttpContext.requestId` over HTTP, a UUID or `requestId` option otherwise) |
515
+ | `transport` | `'http'`, `'sse'` or `'direct'` (`plugin.execute()` / `subscribe()`) |
516
+ | `http` | The `HttpContext` of the request, `undefined` for direct executions outside a request |
517
+ | `operationType` / `operationName` | `'query'`, `'mutation'` or `'subscription'`; the selected operation name |
518
+ | `document` / `variables` | The parsed document (shared by the document cache: never mutate it) and the coerced variables |
519
+ | `signal` | Over HTTP, the request's `ctx.signal`: client gone, SSE stream closed; passed to graphql-js as `abortSignal` |
520
+ | `state` | A `Map` for per-operation storage |
521
+ | `loader(Class \| token)` | The DataLoader of the current execution for a loader |
522
+
523
+ - Resolvers receive it with `@Ctx()`; request-scoped providers inject `GraphQLContext`, singletons
524
+ `Lazy<GraphQLContext>`, exactly like `HttpContext`.
525
+ - The context is not a bag for user data: the authenticated user is a request-scoped provider
526
+ filled by a guard (see [Authorization](#authorization)).
527
+ - Over HTTP, one request scope per HTTP request, which carries exactly one operation (no batching).
528
+ `plugin.execute()` and `plugin.subscribe()` run in the current scope when one is active (a
529
+ controller calling GraphQL), otherwise in a scope of their own.
530
+
531
+ ## Authorization
532
+
533
+ The route decorators of `@znight/http` (`createRouteDecorator`, with RFC 0001's groups) apply to
534
+ resolver classes and methods, and one guard class can implement `CanActivate` (routes) and
535
+ `GraphQLGuard` (fields):
536
+
537
+ ```ts
538
+ // src/auth/access.ts: shared by controllers and resolvers
539
+ import { createRouteDecorator } from '@znight/http'
540
+
541
+ export type Role = 'reader' | 'editor'
542
+
543
+ export const Public = createRouteDecorator('public', { group: 'access', required: true })
544
+ export const Authenticated = createRouteDecorator('authenticated', { group: 'access', required: true })
545
+ export const Roles = createRouteDecorator<readonly Role[]>('roles', { group: 'access', required: true })
546
+ ```
547
+
548
+ ```ts
549
+ // src/auth/access.guard.ts
550
+ import { Injectable, type Lazy } from '@znight/core'
551
+ import type { GraphQLGuard, ResolverContext } from '@znight/graphql'
552
+ import type { CanActivate, HttpContext, RouteMetaCarrier } from '@znight/http'
553
+
554
+ import { Authenticated, Public, Roles } from './access.js'
555
+ import type { CurrentUser } from './current-user.js'
556
+
557
+ @Injectable()
558
+ export class AccessGuard implements CanActivate, GraphQLGuard {
559
+ /** This guard enforces the "access" group, on routes and on root fields. */
560
+ static readonly enforces = [Public, Authenticated, Roles]
561
+
562
+ constructor(private readonly user: Lazy<CurrentUser>) {}
563
+
564
+ canActivate(ctx: HttpContext): boolean {
565
+ return this.#decide(ctx)
566
+ }
567
+
568
+ canResolve(ctx: ResolverContext): boolean {
569
+ return this.#decide(ctx)
570
+ }
571
+
572
+ #decide(ctx: RouteMetaCarrier): boolean {
573
+ if (Public.get(ctx)) return true
574
+ const principal = this.user.get().principal // UnauthorizedException → 401 / UNAUTHENTICATED
575
+ const roles = Roles.get(ctx)
576
+ if (roles !== undefined) return roles.some(role => principal.roles.includes(role))
577
+ return Authenticated.get(ctx) === true
578
+ }
579
+ }
580
+ ```
581
+
582
+ ```ts
583
+ HttpPlugin.configure({ guards: [AuthenticationGuard, AccessGuard] })
584
+ GraphQLPlugin.configure({
585
+ guards: [AccessGuard],
586
+ http: { meta: [Public.meta()] }, // the endpoint itself is open: decisions are taken per field
587
+ })
588
+ ```
589
+
590
+ - **The endpoint** is an HTTP route: the global HTTP guards run on it, `http.meta` and `http.guards`
591
+ give it route metadata and guards, and with a required group it needs a decision like any route.
592
+ - **Fields** follow RFC 0001: the decorators of a group are alternatives, the method's decision
593
+ replaces the class's, a `required` group must be decided on every **root field** (ZNIGHT023 at
594
+ build time, the code HTTP routes use), and every decided group must be enforced by a guard of the
595
+ field's chain (`static enforces`, checked at boot: ZNIGHT023, or a warning with
596
+ `enforcement: 'warn'`).
597
+ - `ResolverContext` satisfies `RouteMetaCarrier`, so `Roles.get(ctx)` works: `route: { meta,
598
+ coordinate }` (effective metadata, schema coordinate `Query.books`), `operation`
599
+ (`GraphQLContext`), `parentType`, `field`, `args`, `parent`, `info`, `http`.
600
+ - Guards run global (`GraphQLPlugin.configure({ guards })`), then class `@UseGuard`, then method
601
+ `@UseGuard`. `false` fails the field with `FORBIDDEN`; a thrown error is mapped like any error
602
+ ([Errors](#errors)). Guards stay synchronous until one returns a promise.
603
+ - Global guards run on **root fields** and on **field resolvers carrying their own metadata** (a
604
+ route decorator on the method or its class). Other field resolvers are covered by the decision
605
+ of the root field the selection descends from; plain property fields never run guards.
606
+ - Guards run once per resolver call (a guard on a field resolver of a list runs once per item): keep
607
+ them cheap. `@UseGuard` classes are bound with lazy edges, so a singleton resolver may use a
608
+ request-scoped guard.
609
+
610
+ ## Errors
611
+
612
+ ```ts
613
+ import { Injectable } from '@znight/core'
614
+ import { GraphQLException, type GraphQLErrorMapper } from '@znight/graphql'
615
+
616
+ @Injectable()
617
+ export class CatalogErrorMapper implements GraphQLErrorMapper {
618
+ map(error: unknown): GraphQLException | undefined {
619
+ if (error instanceof BookNotFoundError) {
620
+ return new GraphQLException(error.message, { code: 'NOT_FOUND', extensions: { bookId: error.bookId } })
621
+ }
622
+ return undefined
623
+ }
624
+ }
625
+ ```
626
+
627
+ Every error of `errors[]` is resolved in this order:
628
+
629
+ 1. a `GraphQLException` (or a graphql-js `GraphQLError` thrown by user code) is shown as is:
630
+ `{ message, locations, path, extensions: { ...extensions, code } }`; its `cause`
631
+ (`new GraphQLException(message, { code, cause })`) is never sent, and logged once at `error`
632
+ level with the request id, the schema coordinate, the path and the code;
633
+ 2. the `errorMappers`, in order (synchronous: `map(error, ctx)`; the first exception wins, a mapper
634
+ that throws is logged and skipped);
635
+ 3. an `HttpException` of `@znight/http` is mapped by status: a `ValidationException` is
636
+ `BAD_USER_INPUT` with `extensions.issues`, 400 `BAD_USER_INPUT`, 401 `UNAUTHENTICATED`, 403
637
+ `FORBIDDEN`, 404 `NOT_FOUND`, 409 `CONFLICT`, 429 `TOO_MANY_REQUESTS`, another 4xx `BAD_REQUEST`,
638
+ 5xx masked;
639
+ 4. anything else is masked as `Unexpected error.` with `INTERNAL_SERVER_ERROR`, and logged once at
640
+ `error` level with the request id, the schema coordinate, the path and the cause (with
641
+ `exposeErrors: true`, the original message is sent). This includes the errors graphql-js raises
642
+ while completing a resolver's value, whose message holds that value: a built-in scalar or an enum
643
+ that cannot represent it (`String cannot represent value: { passwordHash: ... }`), a list value
644
+ that is not iterable, a union value that resolves to no member.
645
+
646
+ `HttpException` headers (`www-authenticate`) are not sent from field errors: one response carries
647
+ many fields. Request errors (parse, validation, variables, limits) are never logged above `debug`.
648
+
649
+ | `GraphQLErrorCode` | `extensions.code` | When |
650
+ | ---------------------- | --------------------------- | ------------------------------------------------------------------------ |
651
+ | `ParseFailed` | `GRAPHQL_PARSE_FAILED` | The document does not parse (syntax, `limits.maxTokens`) |
652
+ | `ValidationFailed` | `GRAPHQL_VALIDATION_FAILED` | The document is not valid against the schema |
653
+ | `BadUserInput` | `BAD_USER_INPUT` | Invalid variables, a schema of `@Args` rejecting a value, HTTP 400 |
654
+ | `QueryTooComplex` | `QUERY_TOO_COMPLEX` | `limits.maxDepth`, `maxAliases` or `maxDirectives` exceeded |
655
+ | `BadRequest` | `BAD_REQUEST` | Unknown operation name, several operations without a name, another 4xx |
656
+ | `Unauthenticated` | `UNAUTHENTICATED` | HTTP 401 |
657
+ | `Forbidden` | `FORBIDDEN` | A guard returned `false`, HTTP 403 |
658
+ | `NotFound` | `NOT_FOUND` | HTTP 404, a key missing from `orderByKeys` |
659
+ | `Conflict` | `CONFLICT` | HTTP 409 |
660
+ | `TooManyRequests` | `TOO_MANY_REQUESTS` | HTTP 429 |
661
+ | `SubscriptionOverflow` | `SUBSCRIPTION_OVERFLOW` | A subscriber fell behind its buffer: its stream ends with this error |
662
+ | `ServiceUnavailable` | `SERVICE_UNAVAILABLE` | `subscriptions.maxActive` reached (HTTP 503) |
663
+ | `InternalServerError` | `INTERNAL_SERVER_ERROR` | An unexpected error (masked), or an error graphql-js raised on execution |
664
+
665
+ Applications may use their own codes: `new GraphQLException(message, { code: 'OUT_OF_STOCK' })`.
666
+
667
+ ### Errors as data
668
+
669
+ Errors a client is expected to handle can be **values** of the field instead of entries of
670
+ `errors[]` (the "result union" pattern, RFC 0008). The field says which ones it can return through
671
+ its TypeScript return type, a union; a result mapper turns errors into those values:
672
+
673
+ ```ts
674
+ import { Injectable } from '@znight/core'
675
+ import {
676
+ Args,
677
+ GraphQLErrorCode,
678
+ GraphQLException,
679
+ GraphQLPlugin,
680
+ type GraphQLResultMapper,
681
+ Query,
682
+ Resolver,
683
+ } from '@znight/graphql'
684
+ import { NotFoundException } from '@znight/http'
685
+
686
+ export interface NotFoundRejection {
687
+ readonly __typename: 'NotFoundRejection'
688
+ readonly message: string
689
+ }
690
+
691
+ export interface ValidationRejection {
692
+ readonly __typename: 'ValidationRejection'
693
+ readonly issues: readonly string[]
694
+ }
695
+
696
+ export type BookResult = Book | NotFoundRejection | ValidationRejection
697
+
698
+ @Resolver()
699
+ export class BookResolver {
700
+ constructor(private readonly books: BookService) {}
701
+
702
+ @Query()
703
+ book(@Args('id') id: string): Promise<BookResult> {
704
+ return this.books.get(id) // throws NotFoundException
705
+ }
706
+ }
707
+
708
+ @Injectable()
709
+ export class Rejections implements GraphQLResultMapper {
710
+ /** The object types this mapper produces: only fields whose union holds one of them use it. */
711
+ static readonly types = ['NotFoundRejection', 'ValidationRejection']
712
+
713
+ toResult(error: unknown): NotFoundRejection | ValidationRejection | undefined {
714
+ if (error instanceof NotFoundException) return { __typename: 'NotFoundRejection', message: error.message }
715
+ if (error instanceof GraphQLException && error.code === GraphQLErrorCode.BadUserInput) {
716
+ const issues = (error.extensions?.['issues'] ?? []) as readonly { readonly message: string }[]
717
+ return { __typename: 'ValidationRejection', issues: issues.map(issue => issue.message) }
718
+ }
719
+ return undefined
720
+ }
721
+ }
722
+
723
+ GraphQLPlugin.configure({ resultMappers: [Rejections] })
724
+ ```
725
+
726
+ `{ book(id: "7") { ... on Book { title } ... on NotFoundRejection { message } } }` answers
727
+ `{ "data": { "book": { "message": "No book 7" } } }`, without `errors`.
728
+
729
+ - **Which fields.** A resolver-backed field whose type, without `!`, is a union (not a list) holding
730
+ one of the mapper's `types`; nothing else per field. This is decided at boot: every other field
731
+ keeps its resolve function, with no wrapper and no cost.
732
+ - **Which errors.** Everything the field's pipeline throws or rejects: its guards (a guard returning
733
+ `false` is a `GraphQLException` with code `FORBIDDEN`, a thrown `UnauthorizedException` is passed
734
+ as is), the Standard Schema validation of `@Args` (`BAD_USER_INPUT`, with `extensions.issues`),
735
+ the method (synchronously or as a rejected promise). Guards run before validation, so input
736
+ errors are only shown to clients allowed to call the field. `toResult(error, ctx)` receives the
737
+ raw error and the `ResolverContext` guards receive (the field's coordinate and metadata, the
738
+ arguments, the parent, the operation).
739
+ - **The value.** The first mapper returning a value that the union resolves to one of its members
740
+ (its discriminator, then `__typename`) wins. `undefined` passes to the next mapper; a value of
741
+ another object type is skipped (logged at `debug`), a value of no type is skipped (`warn`), a
742
+ mapper that throws is skipped (`error`). `toResult()` is synchronous: a returned promise is
743
+ skipped (`error`). When no mapper produces a member, the error goes to `errors[]` as above.
744
+ - **Per field.** An error of a child field stays an error of the child, even when its parent's
745
+ union holds the rejection; give the child a union to return a value there. A non-null union
746
+ field resolved to a value is no longer nulled.
747
+ - **Masking and logs.** Values are data chosen by the application: never masked, and an error
748
+ resolved as a value is logged at `debug` only. A mapper turning unexpected errors into values (an
749
+ `InternalErrorRejection`) must log them itself; prefer leaving them in `errors[]`.
750
+ - **Hook and transports.** A value is data: an operation whose only failure became a value has
751
+ the `success` outcome in `graphql:operation`, with no `errors`. HTTP, SSE and `plugin.execute()`
752
+ send it like any result. On a root subscription field, an error when the subscription starts
753
+ (a guard, or the method before it returns its stream) becomes a stream of that single event (one
754
+ `next`, then `complete`). The body of an `async *` method runs in the stream, after the start: its
755
+ errors stay in `errors[]`; check before returning the generator to map them.
756
+ - **At boot.** A result mapper is a provider of any scope (a class that is not a provider is
757
+ ZNIGHT001) or an instance (its `types` property, or its class's `static types`), with a
758
+ `toResult()` method. Its `types` must name
759
+ object types of the schema: a typo is ZNIGHT066.
760
+
761
+ The thrown error can be the value itself (Pothos style) when its class is a member of the union:
762
+ `toResult: error => (error instanceof OutOfStock ? error : undefined)` with
763
+ `static types = ['OutOfStock']`.
764
+
765
+ ## Plugin options
766
+
767
+ `GraphQLPlugin.configure(options | (container) => options)`, name `graphql`. A function is evaluated
768
+ once, when first needed after the container is initialised. Unknown keys and invalid values are
769
+ ZNIGHT066.
770
+
771
+ | Option | Default | Description |
772
+ | --------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
773
+ | `path` | `'/graphql'` | Path of the endpoint, joined with the `HttpPlugin` prefix |
774
+ | `http` | `{}` | `false`: no HTTP transport (schema, `execute()` and `subscribe()` only). Object: `{ meta?, guards?, bodyLimit = '100kb', get = true }` |
775
+ | `guards` | `[]` | Global GraphQL guards (classes or instances implementing `GraphQLGuard`) |
776
+ | `errorMappers` | `[]` | `GraphQLErrorMapper` classes or instances, tried in order |
777
+ | `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)) |
778
+ | `exposeErrors` | `false` | Unmasked messages for unexpected errors (development) |
779
+ | `introspection` | `NODE_ENV !== 'production'` | Off: graphql-js `NoSchemaIntrospectionCustomRule`, and suggestions are hidden |
780
+ | `graphiql` | `NODE_ENV === 'development'` | `GET path` with `accept: text/html` serves GraphiQL |
781
+ | `limits` | `{ maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 }` | Request limits (introspection fields exempt from `maxDepth`); `false` disables one |
782
+ | `documentCache` | `1000` | Distinct documents kept parsed and validated (LRU); `0` disables |
783
+ | `subscriptions` | `{ maxActive: 10_000, bufferSize: 100 }` | `false` disables subscriptions over SSE. `heartbeat` (ms or `false`) and `maxDuration` (ms) are passed to `HttpResponse.sse()` (default: the `HttpPlugin` `sse` options) |
784
+ | `pubsub` | a `MemoryPubSub` | The `PubSub` provided to the container |
785
+ | `enforcement` | `'error'` | How the boot check of field decisions reports a problem: `'error'` fails the boot (ZNIGHT023), `'warn'` logs |
786
+
787
+ `HttpPlugin` must be registered unless `http: false` (ZNIGHT066). The plugin instance exposes:
788
+
789
+ | Member | What |
790
+ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
791
+ | `schema` | The graphql-js `GraphQLSchema` (after bootstrap) |
792
+ | `printSchema()` | Its SDL (graphql-js `printSchema`), byte-identical to the compiler's SDL artifact |
793
+ | `execute(request, options?)` | A query or mutation outside of HTTP; returns the result directly when every resolver is synchronous (`await` it) |
794
+ | `subscribe(request, options?)` | A stream of results, one per event, until the source ends, `options.signal` aborts, `return()` is called or the application stops |
795
+ | `pubsub` | The `PubSub` of the application |
796
+ | `activeSubscriptions` | Active subscriptions, every transport |
797
+ | `initRoots()` / `httpRoutes()` | The `PluginInitRoots` contract (`TestApp`) and the `PluginHttpRoutes` contract of `@znight/http` |
798
+
799
+ `request` is `{ query, operationName?, variables?, extensions? }`; `options` is `{ signal?,
800
+ requestId? }`. A direct execution has no HTTP guards.
801
+
802
+ ```ts
803
+ const plugin = app.getPlugin<GraphQLPlugin>('graphql')
804
+ const result = await plugin?.execute({ query: '{ books { total } }' })
805
+ ```
806
+
807
+ `onStart` logs `GraphQL endpoint ready` with the path, the number of types and the schema hash
808
+ (`GraphQL schema ready` with `http: false`). On shutdown, `onStop` ends the direct subscriptions;
809
+ `HttpPlugin` drains the requests in flight and closes the SSE streams (their `ctx.signal` aborts with
810
+ `'shutdown'`, then the stream ends with a jittered `retry` and **no** `complete` event, so clients
811
+ reconnect to another instance). Plugin order does not matter.
812
+
813
+ ## GraphQL over HTTP and SSE
814
+
815
+ `GraphQLPlugin` contributes two routes to `HttpPlugin` on `path`: `GET` (when `http.get` or
816
+ `graphiql` is on) and `POST`, with the route metadata, guards and body limit of the `http` option.
817
+ They run through the HTTP pipeline like a controller route (request scope, request id,
818
+ `http:request` hooks, global then endpoint guards, exception mappers, CORS and security headers,
819
+ drain), without interceptors. A path colliding with an HTTP route is ZNIGHT007 at boot. The
820
+ endpoint follows the GraphQL-over-HTTP specification draft:
821
+
822
+ | Request | Answer |
823
+ | ------------------------------------------------------ | ---------------------------------------------------------------------- |
824
+ | `POST` with `content-type: application/json` | `{ query, operationName?, variables?, extensions? }` executed |
825
+ | `POST` with another content type or a content encoding | 415 (no form or text body: cross-site requests are always preflighted) |
826
+ | `POST` with an array body (batching) | 400 |
827
+ | `POST` with malformed JSON, a non-object, no `query` | 400 |
828
+ | `POST` past the body limit | 413, with a GraphQL error body |
829
+ | `GET ?query=...&variables=...` (JSON parameters) | queries only; a mutation or a subscription is 405 with `allow: POST` |
830
+ | `GET` with `http.get: false` | 405 with `allow: POST` |
831
+ | `GET` with `accept: text/html` and `graphiql` on | the GraphiQL page, with its own `content-security-policy` |
832
+ | A subscription without `accept: text/event-stream` | a request error with a hint: 400, or 200 in `application/json` |
833
+ | `accept` that lists none of the produced types | 406 |
834
+
835
+ The `accept` header picks the answer: `application/graphql-response+json` when listed (200 with
836
+ `data`, 400 for parse, validation, variable and limit errors), otherwise `application/json` (200 for
837
+ every well-formed request; no `accept` header, `*/*` and `application/*` mean `application/json`),
838
+ or `text/event-stream` for GraphQL over SSE (distinct connections mode): one `next` event per
839
+ result, then `complete`. An event stream preferred to the JSON types (a higher `q`), or the only
840
+ type listed, streams any operation; listed at the same quality as a JSON type (urql lists
841
+ `application/graphql-response+json, application/json, text/event-stream`), it streams subscriptions
842
+ only, and queries and mutations get one JSON body. The stream is RFC 0006's `HttpResponse.sse()` with the
843
+ `subscriptions` options (`heartbeat`, `maxDuration`): a client that leaves aborts `ctx.signal` and
844
+ ends the source, a stream closed by the shutdown ends without `complete`, and past
845
+ `subscriptions.maxActive` a new subscription is 503. An asynchronous query whose client leaves is
846
+ aborted (graphql-js `abortSignal`). The endpoint passes the `serverAudits()` of `graphql-http` on
847
+ the four adapters, on Node.js and Bun.
848
+
849
+ Adapters need nothing specific. With Express, do not mount `express.json()` before Znight routes
850
+ (the body would be consumed). In tests, `TestApp.inject()` runs queries and mutations through every
851
+ adapter in memory and `injectStream()` / `injectSse()` stream subscriptions.
852
+
853
+ ## Security defaults and limits
854
+
855
+ - **Introspection** off in production (`NODE_ENV === 'production'`), and suggestions hidden with it
856
+ (`hideSuggestions`), so validation errors do not leak field names. graphql-js 17's
857
+ `MaxIntrospectionDepthRule` is always on.
858
+ - **Error masking** on: unexpected errors are `Unexpected error.` unless `exposeErrors`.
859
+ - **Limits**: `maxTokens` (2 000) is passed to `parse`; `maxDepth` (12, introspection fields exempt),
860
+ `maxAliases` (30) and `maxDirectives` (50) are checked in one pass before the validation rules, so
861
+ an abusive document is rejected before the expensive rules run (`QUERY_TOO_COMPLEX`). Aliases count
862
+ as written and as executed: a fragment spread several times counts its aliases each time, so a
863
+ chain of fragments cannot multiply aliased fields past the limit.
864
+ - **Transport**: no batching, JSON-only `POST` (CSRF-safe), `GET` for queries only, body limit 100 KiB,
865
+ active subscriptions capped.
866
+ - **Document cache** keyed by the exact query text, bounded in entries (LRU); documents rejected by
867
+ the limits or by validation are not cached.
868
+ - **GraphiQL** only when enabled (development by default), loaded from a pinned version on
869
+ `cdn.jsdelivr.net`, with a `content-security-policy` for that origin that overrides the strict API
870
+ default of `@znight/http` on that response only.
871
+ - **Deny by default** for fields is opt-in through a `required` route decorator group
872
+ ([Authorization](#authorization)), like HTTP routes.
873
+
874
+ ## Testing
875
+
876
+ Resolvers are providers: `TestBed` unit-tests them with auto-mocks, and `fakeLoader<L>()` builds a
877
+ `Loader<L>` whose `load` and `loadMany` are spies.
878
+
879
+ ```ts
880
+ import { expect, test } from 'bun:test'
881
+ import { fakeLoader } from '@znight/graphql/testing'
882
+ import { TestBed } from '@znight/testing'
883
+
884
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
885
+
886
+ test('author() goes through the loader', async () => {
887
+ const { instance } = await TestBed.create(BookResolver, { metadata })
888
+ const authors = fakeLoader<AuthorsById>({ [herbert.id]: herbert })
889
+ expect(await instance.author(dune, authors)).toBe(herbert)
890
+ expect(authors.load).toHaveBeenCalledWith(herbert.id)
891
+ })
892
+ ```
893
+
894
+ Operations run through the whole stack with `TestApp` (which boots the resolvers, their guards and
895
+ loaders) and `@znight/graphql/testing`:
896
+
897
+ ```ts
898
+ import { expect, test } from 'bun:test'
899
+ import { GraphQLPlugin } from '@znight/graphql'
900
+ import { executeOperation, subscribeOperation } from '@znight/graphql/testing'
901
+ import { HttpPlugin } from '@znight/http'
902
+ import { TestApp } from '@znight/testing'
903
+
904
+ import { __ZNIGHT_METADATA__ } from '../src/.znight/container.generated.js'
905
+
906
+ const ADD_BOOK = `mutation Add($input: NewBookInput!) { addBook(input: $input) { id title } }`
907
+ const DUNE = { title: 'Dune', isbn: '9780441013593', authorId: 'a1', genre: 'ScienceFiction' } // enum names on the wire
908
+
909
+ test('bookAdded streams new books', async () => {
910
+ await using app = await TestApp.create({
911
+ metadata: __ZNIGHT_METADATA__,
912
+ plugins: [HttpPlugin.configure({ port: 0 }), GraphQLPlugin.configure()],
913
+ })
914
+ await using events = await subscribeOperation(app, { query: 'subscription { bookAdded { title } }' })
915
+ const res = await executeOperation(app, { query: ADD_BOOK, variables: { input: DUNE } })
916
+ expect(res.status).toBe(200)
917
+ expect(res.errors).toBeUndefined()
918
+ expect((await events.next()).value).toEqual({ data: { bookAdded: { title: 'Dune' } } })
919
+ })
920
+ ```
921
+
922
+ - `executeOperation(app, { query, variables?, operationName?, extensions?, headers? })` sends a
923
+ `POST` through `app.inject()` (the whole HTTP pipeline, with
924
+ `accept: application/graphql-response+json, application/json;q=0.9`) and resolves with
925
+ `{ status, headers, body, data, errors, extensions }`. Without HTTP transport it calls
926
+ `GraphQLPlugin.execute()` and reports the status the endpoint would answer.
927
+ - `subscribeOperation(app, request)` streams the operation over GraphQL over SSE in memory
928
+ (`app.injectStream()`), or through `GraphQLPlugin.subscribe()` without HTTP transport (no HTTP
929
+ guards then). It resolves once the subscription is established (its source started: a generator
930
+ resolver that calls `subscribe()` before its first `await` has subscribed to the PubSub), so an event published right after, before any `next()`,
931
+ is received. Its `next({ timeoutMs? })` rejects
932
+ when no result arrives in time (2 s by default), and `return()` (or `await using`) disconnects,
933
+ which the server sees as a client abort.
934
+ - `fakeLoader<L>(values | map | fn)` answers from a record by key, a `Map` or a function of the key;
935
+ an `Error` value fails that key, a missing key rejects.
936
+
937
+ ## The generated SDL and the compiler options
938
+
939
+ Options are given in `znight.config.ts`; the configured copy replaces the discovered extension:
940
+
941
+ ```ts
942
+ import graphql from '@znight/graphql/compiler'
943
+ import type { ZnightOptions } from '@znight/compiler'
944
+
945
+ export default {
946
+ extensions: [graphql.configure({ sdl: 'schema.graphql' })],
947
+ } satisfies ZnightOptions
948
+ ```
949
+
950
+ | Option | Default | Description |
951
+ | -------- | --------- | ------------------------------------------------------------------------------------------------------------------- |
952
+ | `sdl` | `false` | Path of the SDL artifact, relative to the root, written with the generated module and checked by `generate --check` |
953
+ | `number` | `'Float'` | GraphQL type of `number` (`'Int'` suits applications whose numbers are counts and identifiers) |
954
+ | `schema` | `false` | Glob(s) of the `.graphql` files of the [schema-first mode](#schema-first), relative to the root |
955
+
956
+ An invalid option is a `TypeError` when `znight.config.ts` is loaded. The SDL artifact is
957
+ byte-identical to graphql-js `printSchema()` of the schema the plugin builds (descriptions on one
958
+ line print as `"""text"""`), types sorted by name. Commit it: every pull request then shows its
959
+ schema diff, and `znight generate --check` fails when it is stale. Without the option,
960
+ `plugin.printSchema()` gives the same text at runtime.
961
+
962
+ The committed file is compared byte for byte with the compiler's output: keep formatters away from
963
+ it. With Biome, exclude it in `biome.jsonc` (as this repository does for `examples/graphql-api`):
964
+
965
+ ```jsonc
966
+ {
967
+ "files": {
968
+ "includes": ["**", "!schema.graphql"]
969
+ }
970
+ }
971
+ ```
972
+
973
+ ## Schema-first
974
+
975
+ With `schema`, the SDL files are the schema ([RFC 0007](../../rfcs/0007-graphql-schema-first.md)):
976
+
977
+ ```ts
978
+ import graphql from '@znight/graphql/compiler'
979
+ import type { ZnightOptions } from '@znight/compiler'
980
+
981
+ export default {
982
+ extensions: [graphql.configure({ schema: 'src/**/*.graphql', sdl: 'schema.graphql' })],
983
+ } satisfies ZnightOptions
984
+ ```
985
+
986
+ - **Files.** The globs (a string or a list, `!` negations allowed) are relative to the project
987
+ root; `node_modules` and dot-directories are excluded unless a pattern names them; the files are
988
+ read in path order, files without definitions skipped, and the `sdl` artifact is never read back.
989
+ They are inputs of the compile (`watchFiles`): editing, creating or deleting one recompiles in
990
+ `znight dev`, the Vite plugin and `znight generate --watch` (the Bun preload recompiles on edits,
991
+ and picks a new file up at its next start).
992
+ - **One source of truth.** TypeScript types never add to the schema: JSDoc, `@gqlName`,
993
+ `@gqlHidden`, `@default` and `number` are ignored. Descriptions, deprecations and default values
994
+ come from the SDL. The runtime is the code-first one: same metadata, `schemaHash` and `sdl`
995
+ artifact (the served schema, semantically equal to the SDL without its custom directives).
996
+ - **SDL.** Parsed, merged (`extend` of every kind, across files) and validated with graphql-js at
997
+ build time (`graphql/language`, `graphql/type`, `graphql/utilities`, loaded lazily in this mode
998
+ only), with the SDL rules graphql-js keeps internal (directive locations, arguments and
999
+ repetitions, unique names) checked too. Object types, interfaces (implementing interfaces too), unions, enums, input objects,
1000
+ `schema { ... }` root names, `@deprecated` and `@specifiedBy` are supported. Custom directives
1001
+ are a ZNIGHT067 warning and left out; `@oneOf` inputs are not supported yet (ZNIGHT067).
1002
+ - **Scalars.** A scalar of the SDL binds the `scalar<T>('Name', definition)` declaration of its
1003
+ name (its description and `@specifiedBy` come from the SDL, emitted in the metadata with
1004
+ `sdl: true`: they replace the definition's, absent ones included, so the served schema is the
1005
+ artifact). Without one, `DateTime`, `BigInt` and `JSON` are the built-in scalars;
1006
+ any other scalar is ZNIGHT067.
1007
+ - **Binding by name.** `@Query()`, `@Mutation()`, `@Subscription()` bind the root field named by the
1008
+ decorator, else by the method. `@ResolveField()` binds a field of the object type named by the
1009
+ class decorator: `@Resolver('Book')` (`@Resolver('Book', { scope: 'request' })` with a scope);
1010
+ `@Parent()` is optional. A class may hold both. `@Args('name')` receives one argument, `@Args()`
1011
+ every argument as an object, `@Args('name', Schema)` validates with a Standard Schema.
1012
+ - **Enums and abstract types.** An enum value is its SDL name on both sides (graphql-codegen's
1013
+ enums compare by value, so any `namingConvention` works). Unions and interfaces resolve their
1014
+ members with `instanceof` for the exported classes the resolvers return (a class is matched to a
1015
+ member by its name, or its `__typename`), then from `__typename`. graphql-codegen declares
1016
+ `__typename` optional: the check accepts it, but the returned objects must carry it.
1017
+
1018
+ ```graphql
1019
+ type Book {
1020
+ id: ID!
1021
+ title: String!
1022
+ author: Author!
1023
+ }
1024
+
1025
+ extend type Query {
1026
+ book(id: ID!): Book
1027
+ }
1028
+ ```
1029
+
1030
+ ```ts
1031
+ import { Args, Loader, Parent, Query, ResolveField, Resolver } from '@znight/graphql'
1032
+
1033
+ @Resolver('Book')
1034
+ export class BookResolver {
1035
+ constructor(private readonly catalog: CatalogService) {}
1036
+
1037
+ @Query()
1038
+ book(@Args('id') id: QueryBookArgs['id']): Promise<Maybe<Book>> {
1039
+ return this.catalog.find(id)
1040
+ }
1041
+
1042
+ @ResolveField()
1043
+ author(@Parent() book: BookModel, @Loader() authors: Loader<AuthorsById>): Promise<Author> {
1044
+ return authors.load(book.authorId)
1045
+ }
1046
+ }
1047
+ ```
1048
+
1049
+ **Coverage (ZNIGHT068).** A root field without resolver; a method naming no field (with a "did you
1050
+ mean", or the root type that declares it); `@Resolver('X')` naming no type, an interface, a
1051
+ non-object type, or a root type with field resolvers; `@Args('x')` or an `@Args()` property naming
1052
+ no argument; an object field without resolver that the TypeScript type returned for its parent
1053
+ lacks. Two methods bound to one field are ZNIGHT061; `@ResolveField` without a type name is
1054
+ ZNIGHT062.
1055
+
1056
+ **Type conformance (ZNIGHT068).** Types are read with the compiler's type shapes. From every
1057
+ binding, the return type (`Promise<T>` unwrapped, the events of a `@Subscription`), the `@Args`
1058
+ parameters and the `@Parent()` type are compared with the SDL, following the fields without
1059
+ resolver through the properties of the returned types:
1060
+
1061
+ | SDL | Output (return types, properties): assignable | Input (arguments, input fields): accepts every value |
1062
+ | ---------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
1063
+ | `T!` / `T` | `T!` rejects `null`, `undefined`, optional properties | `T` without default: accept `undefined`; with one: `null` or `undefined`; a nullable item: `null` |
1064
+ | `[T]` | arrays, tuples, `Set`, `ReadonlySet`, `Iterable` | arrays |
1065
+ | `String`, `ID` | strings, string literals and enums, branded strings (`ID`: numbers too) | `string` (a literal union is narrower) |
1066
+ | `Int`, `Float` | numbers, number literals and enums, `Int` / `Float` | `number` |
1067
+ | `Boolean` | `boolean`, `true`, `false` | `boolean` |
1068
+ | `DateTime`, `BigInt`, `JSON` | `Date`; `bigint` or number; anything | `Date`; `bigint`; anything |
1069
+ | custom scalar | the `T` of its `scalar<T>()` | the `T` of its `scalar<T>()` |
1070
+ | enum | literals or an enum whose values are SDL values | `string`, or literals / an enum covering every value |
1071
+ | object | an object type with a conforming property for each field without resolver | – |
1072
+ | union, interface | each member matched by its `__typename` literal, else by name | – |
1073
+ | input object | – | an object type whose properties are fields of the input |
1074
+
1075
+ When a nullable argument or input field is read by TypeScript types that accept `undefined` but not
1076
+ `null`, the runtime turns `null` into `undefined` (`nullAsUndefined`), as in code-first (never for
1077
+ list items). The `T` of another `scalar<T>()` declaration (a branded id for an `ID`) is accepted for
1078
+ `String` and `ID`.
1079
+ `any`, `unknown`, opaque types, missing annotations and arguments validated by a Standard Schema are
1080
+ not checked. Each mismatch is one diagnostic on the TypeScript annotation, the SDL definition
1081
+ related:
1082
+
1083
+ ```txt
1084
+ ✗ ZNIGHT068: BookResolver.book returns Book | null, which may be null, but Query.book is Book! (non-null)
1085
+ --> src/book.resolver.ts:7:33
1086
+ |
1087
+ 7 | book(@Args('id') id: string): Promise<Book | null> {
1088
+ | ^^^^^^^^^^^^^^^^^^^^
1089
+ |
1090
+ ::: src/schema.graphql:7:3
1091
+ |
1092
+ 7 | book(id: ID!): Book!
1093
+ | ---- Query.book is declared here
1094
+ = hint: Return a value in every case, or make Query.book nullable in the SDL (Book)
1095
+ ```
1096
+
1097
+ **graphql-codegen.** The `typescript` plugin of GraphQL Code Generator writes the TypeScript types
1098
+ of the SDL; map every custom scalar to the `T` of its `scalar<T>()` declaration:
1099
+
1100
+ ```yaml
1101
+ # codegen.yml
1102
+ schema: src/**/*.graphql
1103
+ generates:
1104
+ src/graphql.generated.ts:
1105
+ plugins: [ typescript ]
1106
+ config:
1107
+ scalars:
1108
+ DateTime: Date
1109
+ EventId: ./ids.ts#EventId
1110
+ namingConvention:
1111
+ enumValues: keep
1112
+ useTypeImports: true
1113
+ ```
1114
+
1115
+ Its output (`Scalars['X']['output']`, `Maybe<T>`, `InputMaybe<T>`, the `QueryXxxArgs` types,
1116
+ `__typename` on object types, intersections with interface types) passes the checks as is; the
1117
+ repository tests it with the verbatim output of graphql-codegen 7.4.6
1118
+ ([`test/fixtures/codegen`](./test/fixtures/codegen)). The `typescript-resolvers` plugin is optional:
1119
+ decorated methods are checked directly. Models that replace the fields a field resolver computes
1120
+ (`authorId` instead of `author`) conform, since only the fields without resolver must be properties.
1121
+
1122
+ **Performance.** The SDL is parsed and validated once per compile; the conformance walk visits each
1123
+ (SDL type, TypeScript declaration) pair once, on shapes the compiler caches. An SDL of about 560
1124
+ types (80 SDL files, 160 TypeScript files) compiles in about 200 ms on Bun
1125
+ (`test/compiler/schema-first-performance.test.ts`).
1126
+ Boot and execution are those of code-first.
1127
+
1128
+ ## Observability
1129
+
1130
+ Every operation goes through the `graphql:operation` hook of `@znight/core` (`GraphQLOperationEvent`:
1131
+ `operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`,
1132
+ `requestId`, `context`, `startTime`, then `outcome` (`success`, `partial`, `error`, `rejected`) and
1133
+ `errors` with their codes). A subscription produces one event for its whole life. The event is built
1134
+ only when the hook has wrappers or listeners, so it costs nothing otherwise.
1135
+
1136
+ ```ts
1137
+ import { Hooks, Injectable, type OnStart } from '@znight/core'
1138
+
1139
+ @Injectable()
1140
+ export class OperationLog implements OnStart {
1141
+ constructor(private readonly hooks: Hooks) {}
1142
+
1143
+ onStart(): void {
1144
+ this.hooks.on('graphql:operation', event => {
1145
+ if (event.outcome === 'error') console.warn(event.operationName ?? 'anonymous', event.errors)
1146
+ })
1147
+ }
1148
+ }
1149
+ ```
1150
+
1151
+ The endpoint is an HTTP route, so `@znight/observability` logs, measures and traces it like any
1152
+ request (route `/graphql`), and it also wraps this hook: a span `<type> <name>` per operation (child
1153
+ of the HTTP span), the metrics `graphql_operations_total{type, name, outcome}` and
1154
+ `graphql_operation_duration_seconds{type, name}` (operation names capped at `maxOperationNames`,
1155
+ then `_OTHER`), and one log entry per operation with `logging: { graphql: true }` (off by default).
1156
+ See [GraphQL operation spans](../observability/README.md#graphql-operation-spans).
1157
+
1158
+ ## Performance
1159
+
1160
+ The plugin decides everything it can at boot: the schema is built from data, singleton resolvers
1161
+ are resolved once, and each field gets the cheapest resolve function that fits it (a field without
1162
+ guards nor null handling calls `invoke` directly, plain properties keep graphql-js' default
1163
+ resolver). Per request, on the hot path (a cached query, synchronous resolvers): a memoised `accept`
1164
+ lookup, the body or the query string read once, one document cache lookup, one context object,
1165
+ graphql-js `execute`, and one `JSON.stringify`. Nothing is awaited that is not a promise, and
1166
+ `invoke` is synchronous unless a schema validates asynchronously. Every answer stays on the fast
1167
+ lane of `@znight/http` unless the application adds CORS, global guards or `http:request` hooks.
1168
+
1169
+ Two non-gating micro-benchmarks, run by hand from `packages/graphql` (`bun test` does not run them;
1170
+ on Node.js, use `node --conditions=@znight/source` instead of `bun`):
1171
+
1172
+ - `bun --conditions=@znight/source test/bench/execute.bench.ts`: the per-operation overhead of
1173
+ `GraphQLPlugin.execute()` over graphql-js `execute()` for a cached 50-field query (target: under
1174
+ 10 %; measured 0–9 % on Bun 1.4, 1–7 % on Node.js 24).
1175
+ - `bun --conditions=@znight/source test/bench/http.bench.ts`: the endpoint through the `HttpPlugin`
1176
+ pipeline in process (`GET` and `POST`, both JSON media types), next to bare graphql-js and to a
1177
+ controller route answering a constant. `ABORT_REASON=1` removes the cost graphql-js 17.0 adds to
1178
+ every execution (a `DOMException` built when it aborts its own `AbortController`, about 3 µs on
1179
+ Node.js): compare with other servers at the same graphql-js version.
1180
+
1181
+ ## Diagnostics
1182
+
1183
+ Build-time diagnostics carry a location, a hint and, for types, the path from the root field
1184
+ (`reached from Query.book → Book`). Details and examples: the
1185
+ [error reference](../../docs/reference/error-codes.md#znight060).
1186
+
1187
+ | Code | When |
1188
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1189
+ | ZNIGHT001 | A resolver dependency, a `@UseGuard` class or a `Loader<L>` that is not a provider; at boot, a global guard or mapper class that is not a provider |
1190
+ | ZNIGHT005 | Method shape (static, `#private`, non-public, accessor, generic, computed name), parameters without or with several GraphQL decorators, `@znight/http` look-alikes, `@Resolver` on a controller, `@UseGuard` outside a resolver, unexported scalars |
1191
+ | ZNIGHT007 | The GraphQL path collides with an HTTP route (boot) |
1192
+ | ZNIGHT023 | A root field without a decision of a required route decorator group (build); a decision no guard of the field's chain enforces (boot) |
1193
+ | ZNIGHT060 | A type without GraphQL mapping (`any`, `Map`, `Uint8Array`, `URL` without scalar, tuples, records, anonymous objects and unions, `Promise` or async iterables out of place), a missing return type |
1194
+ | ZNIGHT061 | Two declarations or root fields with one name, reserved or invalid names, a field resolver changing the type of the property it replaces, two field resolvers adding one field, duplicate arguments |
1195
+ | ZNIGHT062 | `@ResolveField` without `@Parent()`, `@Parent()` on a root field, a subscription not returning an async iterable, no `@Query`, field methods outside a `@Resolver`, resolvers in compiled libraries; a field resolver whose parent is unreachable (warning) |
1196
+ | ZNIGHT063 | A declaration used as input and output (with the `extends` fix-it), getters, unions and sets in inputs, cycles of non-null input fields, defaults not matching their type |
1197
+ | ZNIGHT064 | Union members that cannot be told apart (no exported classes, no `__typename`, no literal property with a distinct value per member) |
1198
+ | 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 |
1199
+ | 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 |
1200
+ | 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) |
1201
+ | 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 |
1202
+
1203
+ ## Compiler contract
1204
+
1205
+ The compiler extension generates `metadata.extensions.graphql` (`GraphQLMetadata`, declared in
1206
+ [`src/metadata.ts`](./src/metadata.ts)): the schema as data, with one `invoke` function per resolver
1207
+ method. The section is omitted when the application has no resolver.
1208
+
1209
+ ```ts
1210
+ interface GraphQLMetadata {
1211
+ version: 1 // GRAPHQL_METADATA_VERSION; another version is ZNIGHT066 at boot
1212
+ schemaHash: string // first 16 hex characters of the SHA-256 of the SDL printed by src/sdl.ts
1213
+ roots: { query: string; mutation?: string; subscription?: string }
1214
+ types: GraphQLTypeDefinition[] // every named type except the specified scalars, sorted by name
1215
+ groups?: Record<string, { keys: string[]; required: boolean }> // route decorator groups the runtime needs
1216
+ }
1217
+
1218
+ type GraphQLTypeDefinition =
1219
+ | { kind: 'object'; name: string; description?: string; interfaces?: string[]; fields: GraphQLFieldDefinition[] }
1220
+ | {
1221
+ kind: 'interface' // schema-first only; its fields have no resolver
1222
+ name: string
1223
+ description?: string
1224
+ interfaces?: string[]
1225
+ fields: GraphQLFieldDefinition[]
1226
+ discriminator: GraphQLDiscriminator
1227
+ }
1228
+ | { kind: 'input'; name: string; description?: string; fields: GraphQLInputFieldDefinition[] }
1229
+ | { kind: 'enum'; name: string; description?: string; values: GraphQLEnumValueDefinition[] }
1230
+ | { kind: 'union'; name: string; description?: string; members: string[]; discriminator: GraphQLDiscriminator }
1231
+ | { kind: 'scalar'; name: string; description?: string; builtin: true } // DateTime, BigInt, JSON
1232
+ | {
1233
+ kind: 'scalar'
1234
+ name: string
1235
+ scalar: ScalarDefinition<unknown> | GraphQLScalarType // imported from user code
1236
+ description?: string // wins over the definition's
1237
+ specifiedByURL?: string
1238
+ sdl?: true // schema-first: description and specifiedByURL replace the definition's, absent ones included
1239
+ }
1240
+
1241
+ interface GraphQLFieldDefinition {
1242
+ name: string
1243
+ type: string // graphql-js canonical type reference: 'Book!', '[Book!]!', 'Int'
1244
+ description?: string
1245
+ deprecationReason?: string
1246
+ args?: GraphQLArgumentDefinition[]
1247
+ resolver?: GraphQLResolverBinding // absent: graphql-js default resolver (parent[name])
1248
+ }
1249
+
1250
+ interface GraphQLArgumentDefinition /* = GraphQLInputFieldDefinition */ {
1251
+ name: string
1252
+ type: string
1253
+ description?: string
1254
+ deprecationReason?: string
1255
+ defaultValue?: unknown // external value: JSON-compatible, enum values by GraphQL name
1256
+ nullAsUndefined?: true // the TypeScript type does not accept null
1257
+ }
1258
+
1259
+ type GraphQLDiscriminator =
1260
+ | { by: 'property'; property: string; values: Record<string, string> } // literal value → member name
1261
+ | { by: 'class'; classes: [Class, string][] } // subclasses first
1262
+ | { by: 'typename' }
1263
+
1264
+ interface GraphQLResolverBinding {
1265
+ token: Token // the resolver provider
1266
+ method: string
1267
+ operation: 'query' | 'mutation' | 'subscription' | 'field'
1268
+ guards: Token[] // class @UseGuard guards, then method ones
1269
+ meta: Record<string, unknown> // effective route metadata (method over class); {}: none
1270
+ loaders?: Token[] // informational: loaders the method injects
1271
+ invoke: (resolver, parent, args: GraphQLArgs, context: GraphQLContext, info: GraphQLResolveInfo) => unknown
1272
+ }
1273
+ ```
1274
+
1275
+ Type references are strings in graphql-js' canonical form, parsed by
1276
+ [`src/type-ref.ts`](./src/type-ref.ts). `schemaHash` covers the SDL printed by
1277
+ [`src/sdl.ts`](./src/sdl.ts) (import-free, shared with the runtime), byte-identical to graphql-js
1278
+ `printSchema()` of the schema the plugin builds. `groups` (the shape of RFC 0001's
1279
+ `extensions.http.groups`) is emitted only when a group is required or holds several keys.
1280
+
1281
+ `invoke` maps the graphql-js arguments to the method parameters: `@Args('x')` → `a['x']`,
1282
+ `@Args()` → `a`, `@Parent()` → `p`, `@Ctx()` → `c`, `@Info()` → `i`, `@Loader()` →
1283
+ `c.loader(token)`. Unused middle parameters are `_`-prefixed and trailing ones omitted, so the code
1284
+ type-checks with `noUnusedParameters`. It calls these runtime helpers, kept stable:
1285
+
1286
+ - `validateArgs(schema, value, label)`, exported from `@znight/graphql`: validates an argument with
1287
+ a Standard Schema and returns its output, synchronously when the schema validates synchronously
1288
+ (a promise otherwise); a failure is a `GraphQLException` `BAD_USER_INPUT` whose
1289
+ `extensions.issues` lists `{ path, message }`; a value that is not a Standard Schema v1 is a
1290
+ `TypeError`. `label` is the argument name, or `'arguments'` for `@Args(Schema)` without name.
1291
+ The generated code calls the method directly and resumes in an `async` arrow only when
1292
+ `isThenable()` (exported from `@znight/http`) says so:
1293
+
1294
+ ```ts
1295
+ // generated
1296
+ ;(r: BookResolver, _p: unknown, a: GraphQLArgs) => {
1297
+ const v0 = validateArgs(NewBookInput, a['input'], 'input')
1298
+ if (isThenable(v0)) return (async () => r.addBook((await v0) as never))()
1299
+ return r.addBook(v0 as never)
1300
+ }
1301
+ ```
1302
+
1303
+ - `GraphQLContext.loader(token)`: the DataLoader of the current execution (one operation, or one
1304
+ subscription event) for a loader provider token, with no class lookup.
1305
+
1306
+ Everything else in the section is data the plugin interprets at boot: it checks the section
1307
+ structurally (version, roots, names, kinds, type references, positions, bindings, discriminators,
1308
+ scalars, groups), builds the schema with graphql-js constructors (every type passed in metadata
1309
+ order, defaults as `default: { value }`), runs graphql-js schema validation, and fails with one
1310
+ ZNIGHT066 error listing every problem. Null handling (`nullAsUndefined`) is applied before
1311
+ `invoke`, by normalisers built at boot only for the fields that need one. Hand-written metadata is
1312
+ accepted.
1313
+
1314
+ The extension is deterministic: types sorted by name (code-unit order), fields in declaration order
1315
+ with field resolvers after the properties, enum values in declaration order, the same output in
1316
+ `build` and `serve`, from sources or `dist`. Guards (`@UseGuard`) and loaders (`Loader<L>`) are
1317
+ bound with lazy edges (no ZNIGHT003). The extension imports no `graphql`, `dataloader` nor runtime
1318
+ entry; of `@znight/http` it only imports `@znight/http/compiler` (`readRouteMeta`,
1319
+ `effectiveRouteMeta`, `requiredRouteGroups`). In schema-first mode only, it loads
1320
+ `graphql/language`, `graphql/type` and `graphql/utilities` lazily (never the executor). Interfaces
1321
+ are emitted by schema-first only: code-first sections, hashes and artifacts are unchanged.
1322
+
1323
+ ## Limitations
1324
+
1325
+ - **Code-first maps no TypeScript type to a GraphQL interface**: unions cover polymorphic results
1326
+ (the schema-first mode supports interfaces). No custom directives, no `@oneOf`.
1327
+ - Schema-first: no `__resolveType` methods (return objects carrying `__typename`, or classes), no
1328
+ custom internal enum values (an enum value is its SDL name).
1329
+ - **Subscriptions over SSE only.** No `graphql-ws` (WebSocket), no SSE single connection mode, no
1330
+ multipart subscriptions.
1331
+ - No `@defer` / `@stream`, no persisted or trusted documents, no query cost analysis, no response
1332
+ cache, no federation or schema stitching, no file uploads (use an HTTP upload route), no operation
1333
+ batching.
1334
+ - `MemoryPubSub` serves one process; a multi-instance `PubSub` is the application's (`pubsub`
1335
+ option).
1336
+ - Types must be written: the compiler reads annotations, it never infers return or property types.
1337
+ - The SDL artifact and `schemaHash` see the `description` and `specifiedByURL` of a
1338
+ `ScalarDefinition` written as literals only; a graphql-js `GraphQLScalarType` (`graphql-scalars`)
1339
+ is printed without them at build time, so its runtime `printSchema()` may differ.
1340
+ - `@znight/observability` observes operations, not resolvers: resolver-level tracing is planned on
1341
+ graphql-js tracing channels.
1342
+ - Two concurrent `plugin.execute()` calls inside one request scope share the scope's
1343
+ `GraphQLContext` entry: `@Ctx()` always gets its own operation, a `GraphQLContext` injected
1344
+ through DI sees the latest.
1345
+ - graphql-js 17.0 creates and aborts an `AbortController` per execution, which costs about 3 µs on
1346
+ Node.js (0.4 µs on Bun); bare graphql-js pays it too.