@znight/graphql 0.0.0 → 0.2.0

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