@leaven-graphql/nestjs 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -175,7 +175,7 @@
175
175
 
176
176
  END OF TERMS AND CONDITIONS
177
177
 
178
- Copyright 2026 Pegasus Heavy Industries LLC
178
+ Copyright 2026 Joseph Quinn
179
179
 
180
180
  Licensed under the Apache License, Version 2.0 (the "License");
181
181
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -2,14 +2,38 @@
2
2
 
3
3
  NestJS integration for Leaven GraphQL - a high-performance GraphQL execution engine for Bun.
4
4
 
5
- > **Note:** This package assumes you're using [@pegasusheavy/nestjs-platform-bun](https://github.com/PegasusHeavyIndustries/nestjs-platform-bun) as your NestJS HTTP adapter for Bun runtime support.
5
+ > **Note:** This package assumes you're using `@lexmata/nestjs-platform-bun` as your NestJS HTTP adapter for Bun runtime support.
6
6
 
7
7
  ## Installation
8
8
 
9
9
  ```bash
10
- bun add @leaven-graphql/nestjs @leaven-graphql/core @leaven-graphql/context @leaven-graphql/errors @pegasusheavy/nestjs-platform-bun
10
+ bun add @leaven-graphql/nestjs @leaven-graphql/core @leaven-graphql/context @leaven-graphql/errors @lexmata/nestjs-platform-bun
11
11
  ```
12
12
 
13
+ ### Required TypeScript configuration
14
+
15
+ NestJS reads its metadata through `reflect-metadata`, and Bun only emits that
16
+ metadata when both decorator flags are on:
17
+
18
+ ```jsonc
19
+ // tsconfig.json
20
+ {
21
+ "compilerOptions": {
22
+ "experimentalDecorators": true,
23
+ "emitDecoratorMetadata": true
24
+ }
25
+ }
26
+ ```
27
+
28
+ Without them Bun fails at *import* time with an error that names neither
29
+ decorators nor NestJS:
30
+
31
+ ```
32
+ TypeError: undefined is not an object (evaluating 'descriptor.value')
33
+ ```
34
+
35
+ If you see that, check these two flags before anything else.
36
+
13
37
  ## Quick Start
14
38
 
15
39
  ### Bootstrap with Bun
@@ -17,7 +41,7 @@ bun add @leaven-graphql/nestjs @leaven-graphql/core @leaven-graphql/context @lea
17
41
  ```typescript
18
42
  // main.ts
19
43
  import { NestFactory } from '@nestjs/core';
20
- import { BunAdapter } from '@pegasusheavy/nestjs-platform-bun';
44
+ import { BunAdapter } from '@lexmata/nestjs-platform-bun';
21
45
  import { AppModule } from './app.module';
22
46
 
23
47
  async function bootstrap() {
@@ -89,18 +113,64 @@ export class AppModule {}
89
113
 
90
114
  ## Features
91
115
 
116
+ > ### ⚠️ Two paths, and only one of them runs the NestJS pipeline
117
+ >
118
+ > Read this before the sections below. Which path you pick decides whether the
119
+ > guards, interceptors and parameter decorators documented here execute.
120
+ >
121
+ > **`GraphQLModule.forRoot({ driver: LeavenGraphQLDriver })` — the pipeline runs.**
122
+ > `@nestjs/graphql` builds the schema from your `@Resolver()` classes and wraps
123
+ > every field resolver in NestJS's `ExternalContextCreator`, so `@UseGuards`,
124
+ > `@UseInterceptors`, pipes, filters and parameter decorators all fire; Leaven
125
+ > executes the finished schema. See
126
+ > [Integration with @nestjs/graphql](#integration-with-nestjsgraphql).
127
+ >
128
+ > **`LeavenModule.forRoot({ schema })` or `{ typeDefs, resolvers }` — it does not.**
129
+ > In that path **your resolvers are plain functions invoked directly by
130
+ > graphql-js.** They are not NestJS handlers, so nothing wraps them:
131
+ >
132
+ > - `@UseGuards(...)` — `AuthGuard`, `RolesGuard`, `PermissionsGuard`,
133
+ > `ComplexityGuard`, `DepthGuard` — **does not fire.**
134
+ > - `@UseInterceptors(...)` — `LoggingInterceptor`, `MetricsInterceptor`,
135
+ > `ComplexityInterceptor`, `CachingInterceptor`,
136
+ > `ErrorFormattingInterceptor` — **does not fire.**
137
+ > - Parameter decorators — `@Args()`, `@Context()`, `@Info()`, `@Root()`,
138
+ > `@Parent()`, and anything built with `createContextDecorator()` — **are not
139
+ > applied**; your resolver receives the raw graphql-js
140
+ > `(root, args, context, info)` positional arguments instead.
141
+ >
142
+ > Put authorization and argument handling inside your resolver functions or in
143
+ > the `context` factory on that path, or switch to the driver.
144
+ >
145
+ > What works on **both** paths: the schema build, the HTTP endpoint and
146
+ > playground, the `context` factory, `formatError`, the document cache, metrics,
147
+ > and — for `LeavenModule` — `@InjectPubSub` and `SubscriptionManager`'s
148
+ > graphql-ws protocol handling.
149
+ >
150
+ > The guards, interceptors, decorators and `GqlExecutionContext` documented below
151
+ > are also usable directly — they are ordinary NestJS classes and helpers, and
152
+ > they work in any NestJS pipeline you invoke them from, including unit tests.
153
+
92
154
  ### Decorators
93
155
 
94
156
  #### Parameter Decorators
95
157
 
96
158
  ```typescript
97
159
  import { Resolver, Query } from '@nestjs/graphql';
98
- import { Context, Args, Info, Root } from '@leaven-graphql/nestjs';
160
+ import { Context, Args, Info, Root, type GqlContext } from '@leaven-graphql/nestjs';
161
+
162
+ // `GqlContext` is generic over the request type. Parameterise it with whatever
163
+ // your authentication layer attaches to the request so `ctx.req` stays typed.
164
+ interface AuthenticatedRequest extends Request {
165
+ user: User;
166
+ }
99
167
 
100
168
  @Resolver()
101
169
  export class UserResolver {
170
+ constructor(private readonly userService: UserService) {}
171
+
102
172
  @Query(() => User)
103
- async me(@Context() ctx: GqlContext) {
173
+ async me(@Context() ctx: GqlContext<AuthenticatedRequest>) {
104
174
  return ctx.req.user;
105
175
  }
106
176
 
@@ -119,6 +189,8 @@ import { Complexity, Description, CacheHint, Deprecated } from '@leaven-graphql/
119
189
 
120
190
  @Resolver()
121
191
  export class PostResolver {
192
+ constructor(private readonly postService: PostService) {}
193
+
122
194
  @Query(() => [Post])
123
195
  @Complexity(10)
124
196
  @Description('Fetch all posts')
@@ -135,13 +207,41 @@ export class PostResolver {
135
207
  }
136
208
  ```
137
209
 
210
+ `@Description` and `@Deprecated` are applied to the emitted schema when the
211
+ module builds it from `typeDefs` + `resolvers`: the annotated resolver's field
212
+ gets the description, and a deprecated field is printed with
213
+ `@deprecated(reason: ...)` so client tooling sees it. A pre-built `schema`
214
+ passed via the `schema` option is used exactly as given, so annotate its fields
215
+ there instead.
216
+
217
+ The other two carry caveats:
218
+
219
+ - **`@Complexity(n)` is observational only.** `ComplexityInterceptor` reads it
220
+ and accumulates the total onto the request context's
221
+ `_resolvedFieldComplexity` key. Nothing in this package reads that key back —
222
+ in particular it is deliberately *not* `_queryComplexity`, the key the driver
223
+ computes statically and `ComplexityGuard` enforces. NestJS runs guards before
224
+ interceptors, so a value accumulated during a request could never gate that
225
+ same request anyway. Consume `_resolvedFieldComplexity` yourself from logging
226
+ or metrics after execution. To actually *reject* expensive queries, set
227
+ `maxComplexity` — the executor enforces that one directly, before any resolver
228
+ runs — rather than annotating fields.
229
+ - **`@CacheHint(...)` is a no-op.** `CachingInterceptor` forwards the hint to
230
+ `info.cacheControl.setCacheHint(...)`, but `info.cacheControl` is an Apollo
231
+ Server construct and Leaven never attaches it — so the guarded call is always
232
+ skipped and no cache header or hint is ever produced. Treat it as deprecated:
233
+ it is retained only so existing code keeps compiling. Set cache headers in
234
+ your own middleware, or configure the `cache` option for Leaven's document
235
+ cache (which caches parsed/compiled documents, not responses).
236
+
138
237
  ### Guards
139
238
 
140
239
  #### Authentication Guard
141
240
 
142
241
  ```typescript
143
242
  import { UseGuards } from '@nestjs/common';
144
- import { AuthGuard, Public } from '@leaven-graphql/nestjs';
243
+ import { Resolver, Query } from '@nestjs/graphql';
244
+ import { AuthGuard, Context, Public, type GqlContext } from '@leaven-graphql/nestjs';
145
245
 
146
246
  @Resolver()
147
247
  @UseGuards(AuthGuard)
@@ -163,11 +263,14 @@ export class UserResolver {
163
263
 
164
264
  ```typescript
165
265
  import { UseGuards } from '@nestjs/common';
166
- import { AuthGuard, RolesGuard, Roles } from '@leaven-graphql/nestjs';
266
+ import { Resolver, Query, Mutation } from '@nestjs/graphql';
267
+ import { AuthGuard, Args, RolesGuard, Roles } from '@leaven-graphql/nestjs';
167
268
 
168
269
  @Resolver()
169
270
  @UseGuards(AuthGuard, RolesGuard)
170
271
  export class AdminResolver {
272
+ constructor(private readonly userService: UserService) {}
273
+
171
274
  @Query(() => [User])
172
275
  @Roles('admin')
173
276
  async users() {
@@ -186,11 +289,14 @@ export class AdminResolver {
186
289
 
187
290
  ```typescript
188
291
  import { UseGuards } from '@nestjs/common';
189
- import { AuthGuard, PermissionsGuard, Permissions } from '@leaven-graphql/nestjs';
292
+ import { Resolver, Mutation } from '@nestjs/graphql';
293
+ import { AuthGuard, Args, PermissionsGuard, Permissions } from '@leaven-graphql/nestjs';
190
294
 
191
295
  @Resolver()
192
296
  @UseGuards(AuthGuard, PermissionsGuard)
193
297
  export class PostResolver {
298
+ constructor(private readonly postService: PostService) {}
299
+
194
300
  @Mutation(() => Post)
195
301
  @Permissions('posts:write')
196
302
  async createPost(@Args('input') input: CreatePostInput) {
@@ -211,6 +317,7 @@ export class PostResolver {
211
317
 
212
318
  ```typescript
213
319
  import { UseInterceptors } from '@nestjs/common';
320
+ import { Resolver } from '@nestjs/graphql';
214
321
  import { LoggingInterceptor } from '@leaven-graphql/nestjs';
215
322
 
216
323
  @Resolver()
@@ -224,6 +331,7 @@ export class UserResolver {
224
331
 
225
332
  ```typescript
226
333
  import { UseInterceptors } from '@nestjs/common';
334
+ import { Resolver } from '@nestjs/graphql';
227
335
  import { MetricsInterceptor } from '@leaven-graphql/nestjs';
228
336
 
229
337
  @Resolver()
@@ -236,6 +344,7 @@ export class UserResolver {
236
344
  ### Custom Context Decorator
237
345
 
238
346
  ```typescript
347
+ import { Resolver, Query } from '@nestjs/graphql';
239
348
  import { createContextDecorator } from '@leaven-graphql/nestjs';
240
349
 
241
350
  // Create a custom decorator for accessing the current user
@@ -244,6 +353,8 @@ export const CurrentUser = createContextDecorator<User>('user');
244
353
  // Usage
245
354
  @Resolver()
246
355
  export class ProfileResolver {
356
+ constructor(private readonly profileService: ProfileService) {}
357
+
247
358
  @Query(() => Profile)
248
359
  async profile(@CurrentUser() user: User) {
249
360
  return this.profileService.getByUserId(user.id);
@@ -282,7 +393,14 @@ export class CustomGuard implements CanActivate {
282
393
 
283
394
  ### Subscriptions
284
395
 
285
- Enable WebSocket-based GraphQL subscriptions:
396
+ > **`SubscriptionManager` speaks the graphql-ws protocol; it does not own a
397
+ > server.** Importing `LeavenModule` registers the manager but does **not** open
398
+ > a WebSocket endpoint — nothing here calls `server.upgrade()`, because the HTTP
399
+ > server belongs to your Nest adapter, not to this package. Configuring
400
+ > `subscriptions` alone gets you a configured object and no listening socket.
401
+ > You must wire the transport yourself, as shown below.
402
+
403
+ Configure the protocol behaviour on the module:
286
404
 
287
405
  ```typescript
288
406
  import { Module } from '@nestjs/common';
@@ -309,20 +427,133 @@ import { LeavenModule, SubscriptionManager } from '@leaven-graphql/nestjs';
309
427
  export class AppModule {}
310
428
  ```
311
429
 
312
- Use the `@Subscription` decorator in resolvers:
430
+ #### Wiring the WebSocket transport
431
+
432
+ Two things are yours to do: **register the upgrade route**, and **hand each
433
+ socket to the manager**. `SubscriptionManager.getPath()` reports the path it was
434
+ configured for (`subscriptions.path`, falling back to `options.path`, then
435
+ `/graphql`), so route against that rather than a duplicated constant.
436
+
437
+ Under Bun, sockets arrive as `ServerWebSocket`, which has no `addEventListener`.
438
+ `getWebSocketConfig()` returns `open`/`message`/`close` handlers already bound to
439
+ the manager, ready to drop into `Bun.serve`:
440
+
441
+ ```typescript
442
+ import { NestFactory } from '@nestjs/core';
443
+ import { BunAdapter } from '@lexmata/nestjs-platform-bun';
444
+ import { SubscriptionManager } from '@leaven-graphql/nestjs';
445
+ import { AppModule } from './app.module';
446
+
447
+ async function bootstrap() {
448
+ const app = await NestFactory.create(AppModule, new BunAdapter());
449
+ await app.init();
450
+
451
+ const subscriptions = app.get(SubscriptionManager);
452
+
453
+ Bun.serve({
454
+ port: 3000,
455
+ // The upgrade route. The manager never calls server.upgrade() itself —
456
+ // it does not have the Server instance.
457
+ fetch(request, server) {
458
+ const { pathname } = new URL(request.url);
459
+ if (
460
+ pathname === subscriptions.getPath() &&
461
+ request.headers.get('upgrade')?.toLowerCase() === 'websocket'
462
+ ) {
463
+ // Bun requires the `data` option whenever the socket's data type is
464
+ // not `undefined`; nothing is stashed here.
465
+ return server.upgrade(request, { data: undefined })
466
+ ? undefined
467
+ : new Response('Upgrade failed', { status: 400 });
468
+ }
469
+ // Everything else stays on the Nest HTTP pipeline, including the
470
+ // GraphQL POST endpoint served by GraphQLMiddleware.
471
+ return app.getHttpAdapter().getInstance().fetch(request);
472
+ },
473
+ websocket: subscriptions.getWebSocketConfig(),
474
+ });
475
+ }
476
+
477
+ bootstrap();
478
+ ```
479
+
480
+ `subscriptions.path` defaults to `options.path`, so the GraphQL POST endpoint and
481
+ the WebSocket endpoint share `/graphql` by default — they are told apart by the
482
+ `Upgrade` header, as above. Set `subscriptions.path` to something else to
483
+ separate them.
484
+
485
+ **The upgrade request is not threaded through `getWebSocketConfig()`**, so
486
+ `ctx.request` is `undefined` inside `onConnect` / `context` hooks when you use it.
487
+ If a hook needs the original request (to read a cookie or the `Sec-WebSocket-Protocol`
488
+ header, say), skip the packaged config and call `handleOpen` yourself with the
489
+ request in hand:
490
+
491
+ <!-- doc-check: skip - object-literal excerpt, not a standalone module -->
492
+ ```typescript
493
+ websocket: {
494
+ open: (socket) => subscriptions.handleOpen(socket, socket.data.request),
495
+ message: (socket, message) => void subscriptions.handleMessage(socket, message),
496
+ close: (socket) => void subscriptions.handleClose(socket),
497
+ },
498
+ ```
499
+
500
+ stashing it at upgrade time with `server.upgrade(request, { data: { request } })`.
501
+ Authenticating from `connectionParams` in `onConnect` avoids the problem entirely
502
+ and is the more common graphql-ws pattern.
503
+
504
+ If your sockets are DOM-shaped `WebSocket` objects instead (a `ws`-style server,
505
+ or a test harness), hand each one to `handleConnection` and the manager attaches
506
+ its own `message`/`close`/`error` listeners:
507
+
508
+ ```typescript
509
+ await subscriptions.handleConnection(socket, request);
510
+ ```
511
+
512
+ Inject the module's shared `PubSub` with `@InjectPubSub()` and use the
513
+ `@Subscription` decorator in resolvers:
313
514
 
314
515
  ```typescript
315
516
  import { Resolver } from '@nestjs/graphql';
316
- import { Subscription } from '@leaven-graphql/nestjs';
517
+ import { InjectPubSub, Subscription } from '@leaven-graphql/nestjs';
518
+ import type { PubSub } from '@leaven-graphql/ws';
317
519
 
318
520
  @Resolver()
319
521
  export class MessageResolver {
522
+ constructor(@InjectPubSub() private readonly pubSub: PubSub) {}
523
+
320
524
  @Subscription(() => Message, {
321
- filter: (payload, variables) => payload.roomId === variables.roomId,
525
+ filter: (payload, variables) =>
526
+ (payload as { roomId: string }).roomId === (variables as { roomId: string }).roomId,
322
527
  })
323
528
  messageAdded() {
324
529
  return this.pubSub.asyncIterator('MESSAGE_ADDED');
325
530
  }
531
+
532
+ publish(roomId: string, body: string) {
533
+ this.pubSub.publish('MESSAGE_ADDED', { messageAdded: { roomId, body } });
534
+ }
535
+ }
536
+ ```
537
+
538
+ `@InjectPubSub()` resolves the module's own in-memory instance by default, so a
539
+ publisher and a subscriber in different providers share the same topics. Pass a
540
+ `pubSub` option to `forRoot()`/`forRootAsync()` to supply your own — for example
541
+ a distributed engine, so events published on one server instance reach
542
+ subscribers on another.
543
+
544
+ `@SubscriptionFilter` is the standalone equivalent of the `filter` option, and
545
+ preserves the decorated method's return kind: a method returning an async
546
+ iterable still returns one, so `for await (const event of resolver.messageAdded())`
547
+ works in tests and direct callers.
548
+
549
+ <!-- doc-check: skip - class-member excerpt, not a standalone module -->
550
+ ```typescript
551
+ import { SubscriptionFilter } from '@leaven-graphql/nestjs';
552
+
553
+ @Subscription(() => Comment)
554
+ @SubscriptionFilter((payload, variables) => payload.postId === variables.postId)
555
+ commentAdded() {
556
+ return this.pubSub.asyncIterator('COMMENT_ADDED');
326
557
  }
327
558
  ```
328
559
 
@@ -356,41 +587,195 @@ export class SchemaService {
356
587
  |--------|------|---------|-------------|
357
588
  | `path` | `string` | `/graphql` | GraphQL endpoint path |
358
589
  | `schema` | `GraphQLSchema` | - | Pre-built GraphQL schema |
590
+ | `typeDefs` | `string \| DocumentNode \| Array<string \| DocumentNode>` | - | SDL type definitions |
591
+ | `resolvers` | `Record<string, unknown> \| Array<Record<string, unknown>>` | - | Resolver map merged into `typeDefs` |
592
+ | `subscriptions` | `SubscriptionConfig` | - | GraphQL-over-WebSocket configuration: lifecycle hooks (`onConnect`, `onDisconnect`, `onSubscribe`, `onOperation`, `onComplete`), per-connection context factory, keep-alive, connection-init timeout, and max subscriptions per connection |
593
+ | `pubSub` | `PubSub` | per-module in-memory instance | Instance resolved by `@InjectPubSub()`; supply your own to share it across modules or to use a distributed engine |
359
594
  | `playground` | `boolean` | `true` (dev) | Enable GraphQL Playground |
360
595
  | `introspection` | `boolean` | `true` (dev) | Enable introspection |
361
596
  | `cache` | `DocumentCacheConfig \| boolean` | - | Document cache configuration |
362
- | `maxComplexity` | `number` | - | Maximum query complexity |
363
- | `maxDepth` | `number` | - | Maximum query depth |
597
+ | `maxComplexity` | `number` | - | Maximum query complexity. Enforced by the executor before any resolver runs — an over-budget document is rejected with a `ComplexityError` |
598
+ | `maxDepth` | `number` | - | Maximum query depth. **Only recorded on the context** (as `_queryDepth`) for `DepthGuard`, so it is not enforced on `LeavenModule`'s HTTP path — see the caveat under [Features](#features). The `LeavenGraphQLDriver` path enforces it in the executor |
364
599
  | `metrics` | `boolean` | `false` | Enable execution metrics |
365
- | `debug` | `boolean` | `false` | Enable debug mode |
366
600
  | `context` | `ContextFactory` | - | Custom context factory |
367
601
  | `formatError` | `FormatErrorFn` | - | Error formatting function |
368
- | `cors` | `boolean \| CorsOptions` | - | CORS configuration |
602
+ | `publicUrl` | `string` | `http://localhost` | Canonical origin (scheme + authority) used to build `request.url` for the context factory. The `Host` header is client-controlled, so it is not trusted: set this when a factory resolves tenancy or issues redirects from `request.url` |
603
+ | `cors` | `boolean \| CorsOptions` | *unset — no CORS headers* | CORS handling. **Opt-in:** while unset the endpoint emits no `Access-Control-*` headers and delegates `OPTIONS` preflights to the next handler. `true` (or an options object without `origin`) enables `Access-Control-Allow-Origin: *`; set `origin` to narrow it. `false` is equivalent to unset |
604
+ | `includeStacktraceInErrorResponses` | `boolean` | `false` | Include stack traces in errors (also suppresses production masking — honored in every environment, not just development) |
605
+
606
+ ### Deprecated options
607
+
608
+ These are accepted so existing configurations keep compiling, but nothing reads
609
+ them. Removal target: 0.3.0.
610
+
611
+ | Option | Type | Status |
612
+ |--------|------|--------|
613
+ | `debug` | `boolean` | Not implemented; setting it has no effect |
614
+ | `sortSchema` | `boolean` | No effect — pass `sortSchema` to `generateSchemaFile` instead |
615
+ | `buildSchemaOptions` | `BuildSchemaOptions` | No effect — `LeavenModule` has no code-first pipeline. For code-first, use `LeavenGraphQLDriver` with `GraphQLModule.forRoot()`, which honors `@nestjs/graphql`'s own `buildSchemaOptions` |
616
+ | `plugins` | `LeavenPlugin[]` | No effect — use NestJS interceptors and guards |
617
+ | `autoSchemaFile` | `boolean \| string` | Not implemented by `LeavenModule`; configuring it without `schema` or `typeDefs` throws at bootstrap. `GraphQLModule.forRoot({ driver: LeavenGraphQLDriver, autoSchemaFile: true })` supports it fully |
369
618
 
370
619
  ## Integration with @nestjs/graphql
371
620
 
372
- This package can be used alongside `@nestjs/graphql` to use Leaven as the execution engine:
621
+ `LeavenGraphQLDriver` is a real `@nestjs/graphql` driver: it extends
622
+ `AbstractGraphQLDriver` and implements `start()` and `stop()`. Pass it to
623
+ `GraphQLModule.forRoot()` and your `@Resolver()` classes become NestJS
624
+ handlers, executed by Leaven.
625
+
626
+ **On this path guards, interceptors, pipes, filters and parameter decorators
627
+ all execute.**
373
628
 
374
629
  ```typescript
375
630
  import { Module } from '@nestjs/common';
376
631
  import { GraphQLModule } from '@nestjs/graphql';
377
- import { LeavenModule, LeavenDriver } from '@leaven-graphql/nestjs';
632
+ import { LeavenGraphQLDriver, type LeavenDriverConfig } from '@leaven-graphql/nestjs';
633
+ import { UserResolver } from './user.resolver';
378
634
 
379
635
  @Module({
380
636
  imports: [
381
- LeavenModule.forRoot({
637
+ GraphQLModule.forRoot<LeavenDriverConfig>({
638
+ driver: LeavenGraphQLDriver,
639
+ // Code-first. `true` keeps the generated schema in memory; pass a path
640
+ // to also write it to disk.
641
+ autoSchemaFile: true,
382
642
  playground: true,
643
+ path: '/graphql',
644
+ // Leaven executor options
383
645
  cache: { maxSize: 1000 },
646
+ metrics: true,
647
+ maxComplexity: 1000,
648
+ maxDepth: 10,
649
+ // Runs once per request; merged over the built-in `{ req, res }`
650
+ context: ({ req }) => ({ req, user: req.user ?? null }),
384
651
  }),
385
- GraphQLModule.forRoot({
386
- driver: LeavenDriver,
387
- autoSchemaFile: true,
652
+ ],
653
+ providers: [UserResolver],
654
+ })
655
+ export class AppModule {}
656
+ ```
657
+
658
+ ```typescript
659
+ import { Resolver, Query, Args } from '@nestjs/graphql';
660
+ import { UseGuards, UseInterceptors } from '@nestjs/common';
661
+ import {
662
+ AuthGuard,
663
+ RolesGuard,
664
+ Roles,
665
+ LoggingInterceptor,
666
+ Context,
667
+ } from '@leaven-graphql/nestjs';
668
+
669
+ @Resolver()
670
+ @UseGuards(AuthGuard, RolesGuard)
671
+ @UseInterceptors(LoggingInterceptor)
672
+ export class UserResolver {
673
+ @Query(() => String)
674
+ @Roles('admin')
675
+ public adminReport(
676
+ @Args('id', { type: () => String }) id: string,
677
+ @Context() ctx: { user: { id: string } },
678
+ ): string {
679
+ // The guards ran before this body. `id` and `ctx` were injected by the
680
+ // parameter decorators.
681
+ return `${id} for ${ctx.user.id}`;
682
+ }
683
+ }
684
+ ```
685
+
686
+ ### Why it works
687
+
688
+ `GraphQLModule.onModuleInit` calls `generateSchema()` before `start()`.
689
+ `generateSchema()` is inherited from `AbstractGraphQLDriver` and delegates to
690
+ `GraphQLFactory`, which builds the schema from your `@Resolver()` classes and
691
+ wraps every field resolver in NestJS's `ExternalContextCreator` — the wrapper
692
+ that runs the execution pipeline. By the time `start()` receives
693
+ `options.schema`, that pipeline is already part of the schema; the driver
694
+ constructs a `LeavenExecutor` over it and serves `POST <path>` (plus
695
+ `GET <path>` for GraphiQL when `playground` is enabled).
696
+
697
+ ### Driver options
698
+
699
+ `LeavenDriverConfig` accepts everything `GqlModuleOptions` does, plus:
700
+
701
+ | Option | Type | Description |
702
+ |--------|------|-------------|
703
+ | `cache` | `DocumentCacheConfig \| boolean \| IDocumentCache` | Document/validation cache passed to the executor |
704
+ | `metrics` | `boolean` | Collect per-operation metrics and expose them under `extensions.metrics` |
705
+ | `maxComplexity` | `number` | Rejected by the executor before any resolver runs |
706
+ | `maxDepth` | `number` | Rejected by the executor at parse time |
707
+ | `playground` | `boolean` | Serve GraphiQL on `GET <path>` |
708
+ | `subscriptionEndpoint` | `string` | WebSocket endpoint advertised to GraphiQL |
709
+ | `formatError` | `(error) => error` | Final transformation applied to every response error |
710
+ | `includeStacktraceInErrorResponses` | `boolean` | Include stack traces; also disables production masking |
711
+
712
+ ### Errors and HTTP status
713
+
714
+ Every error leaves the driver carrying an `ErrorCode`, and the status is
715
+ derived from it (`400` validation/complexity/depth/input, `401`
716
+ authentication, `403` authorization, `404` not found, `429` rate limit, `500`
717
+ otherwise) — but **only for a total failure**. A response carrying `data`
718
+ alongside `errors` is a spec-conformant partial success and stays `200`.
719
+
720
+ NestJS `HttpException`s — what guards, pipes and filters throw — are
721
+ translated to the equivalent Leaven error, so `UnauthorizedException` reaches
722
+ the client as `401 UNAUTHENTICATED` rather than as a masked `500`.
723
+
724
+ ### The `LeavenModule` alternative
725
+
726
+ `LeavenModule.forRoot({ typeDefs, resolvers })` remains supported and is the
727
+ lighter option when you do not want the NestJS pipeline: it serves a schema
728
+ built from plain resolver functions. On that path guards, interceptors and
729
+ parameter decorators **do not run** (see the callout under
730
+ [Features](#features)); put authorization in the resolver or in the `context`
731
+ factory:
732
+
733
+ ```typescript
734
+ import { Module } from '@nestjs/common';
735
+ import { LeavenModule } from '@leaven-graphql/nestjs';
736
+ import { AuthorizationError } from '@leaven-graphql/errors';
737
+
738
+ @Module({
739
+ imports: [
740
+ LeavenModule.forRoot({
741
+ typeDefs,
742
+ resolvers,
743
+ cache: { maxSize: 1000 },
744
+ playground: true,
745
+ // Runs before execution. Throwing a Leaven error here reaches the client
746
+ // with its own status (401 for AuthenticationError), so this is the
747
+ // practical stand-in for an AuthGuard on this path.
748
+ context: async (request) => {
749
+ const token = request.headers.get('authorization');
750
+ return { user: token ? await verify(token) : null };
751
+ },
388
752
  }),
389
753
  ],
390
754
  })
391
755
  export class AppModule {}
392
756
  ```
393
757
 
758
+ <!-- doc-check: skip - defines `resolvers` for the module block above it, so checking it as a continuation would be a forward reference -->
759
+
760
+ ```typescript
761
+ const resolvers = {
762
+ Query: {
763
+ adminReport: (_root, _args, context) => {
764
+ if (!context.user?.roles.includes('admin')) {
765
+ throw new AuthorizationError('admin role required');
766
+ }
767
+ return reportService.build();
768
+ },
769
+ },
770
+ };
771
+ ```
772
+
773
+ One cost limit does not depend on which path you choose: **`maxComplexity` is
774
+ enforced by the executor itself**, which rejects an over-budget document with a
775
+ `ComplexityError` before any resolver runs. `maxDepth` under `LeavenModule` is
776
+ only recorded on the context for `DepthGuard`, so there it needs the driver
777
+ like every other guard.
778
+
394
779
  ## License
395
780
 
396
- Apache 2.0 - Pegasus Heavy Industries LLC
781
+ Apache 2.0 - Joseph Quinn