@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.
- package/LICENSE +21 -0
- package/README.md +1063 -0
- package/dist/compiler.d.mts +93 -0
- package/dist/compiler.mjs +2832 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +542 -0
- package/dist/index.mjs +128 -0
- package/dist/index.mjs.map +1 -0
- package/dist/names-BlzDM5cF.mjs +111 -0
- package/dist/names-BlzDM5cF.mjs.map +1 -0
- package/dist/plugin-BdBxr5NQ.mjs +3222 -0
- package/dist/plugin-BdBxr5NQ.mjs.map +1 -0
- package/dist/testing.d.mts +101 -0
- package/dist/testing.mjs +306 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-CgbV9LjD.d.mts +234 -0
- package/package.json +97 -1
- package/src/compiler/analyze.ts +608 -0
- package/src/compiler/builder.ts +1640 -0
- package/src/compiler/constants.ts +74 -0
- package/src/compiler/data.ts +73 -0
- package/src/compiler/defaults.ts +92 -0
- package/src/compiler/discriminators.ts +53 -0
- package/src/compiler/docs.ts +55 -0
- package/src/compiler/generate.ts +405 -0
- package/src/compiler/ids.ts +128 -0
- package/src/compiler/index.ts +93 -0
- package/src/compiler/locate.ts +44 -0
- package/src/compiler/model.ts +122 -0
- package/src/compiler/options.ts +54 -0
- package/src/compiler/scalars.ts +138 -0
- package/src/compiler/sdl-view.ts +59 -0
- package/src/context.ts +181 -0
- package/src/decisions.ts +169 -0
- package/src/decorators.ts +196 -0
- package/src/errors.ts +89 -0
- package/src/execution.ts +1064 -0
- package/src/format.ts +285 -0
- package/src/index.ts +84 -0
- package/src/limits.ts +177 -0
- package/src/loaders.ts +149 -0
- package/src/memory-pubsub.ts +133 -0
- package/src/metadata.ts +195 -0
- package/src/names.ts +47 -0
- package/src/options.ts +309 -0
- package/src/pipeline.ts +277 -0
- package/src/plugin.ts +557 -0
- package/src/pubsub.ts +36 -0
- package/src/scalars.ts +131 -0
- package/src/schema-hash.ts +18 -0
- package/src/schema.ts +557 -0
- package/src/sdl.ts +515 -0
- package/src/testing.ts +454 -0
- package/src/tokens.ts +22 -0
- package/src/transport/abort.ts +80 -0
- package/src/transport/accept.ts +155 -0
- package/src/transport/graphiql.ts +106 -0
- package/src/transport/http.ts +373 -0
- package/src/transport/sse.ts +45 -0
- package/src/type-ref.ts +89 -0
- package/src/types.ts +190 -0
- 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.
|