@leaven-graphql/plugins 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
@@ -10,19 +10,66 @@ bun add @leaven-graphql/plugins
10
10
 
11
11
  ## Quick Start
12
12
 
13
+ > **Wiring is manual today.** `ExecutorConfig` has no `plugins` option, and no
14
+ > package in Leaven — core, http, ws, or nestjs — dispatches plugin hooks for
15
+ > you. To run plugins you construct a `PluginManager` yourself and call its
16
+ > hooks around `executor.execute`, as shown below.
17
+
13
18
  ```typescript
14
- import { createLoggingPlugin, createDepthLimitPlugin } from '@leaven-graphql/plugins';
15
- import { LeavenExecutor } from '@leaven-graphql/core';
19
+ import { parse } from 'graphql';
20
+ import { LeavenExecutor, type GraphQLRequest } from '@leaven-graphql/core';
21
+ import {
22
+ createPluginManager,
23
+ createLoggingPlugin,
24
+ createDepthLimitPlugin,
25
+ } from '@leaven-graphql/plugins';
26
+
27
+ const executor = new LeavenExecutor({ schema });
16
28
 
17
- const executor = new LeavenExecutor({
29
+ const plugins = createPluginManager({
18
30
  schema,
19
31
  plugins: [
20
- createLoggingPlugin({ logger: console }),
32
+ createLoggingPlugin({ level: 'info' }),
21
33
  createDepthLimitPlugin({ maxDepth: 10 }),
22
34
  ],
23
35
  });
36
+
37
+ // Optional: run the deferred `onRegister` hooks up front so initialization
38
+ // failures surface here instead of on the first request.
39
+ await plugins.init();
40
+
41
+ async function handleRequest(request: GraphQLRequest, context: unknown) {
42
+ const pluginContext = plugins.createContext(request, context);
43
+
44
+ try {
45
+ const query = await plugins.beforeParse(request.query, pluginContext);
46
+ const document = await plugins.afterParse(parse(query), pluginContext);
47
+
48
+ // A `beforeExecute` hook may return a response to short-circuit execution
49
+ // (this is how the caching plugin serves a hit).
50
+ const cached = await plugins.beforeExecute(document, pluginContext);
51
+ if (cached) {
52
+ return await plugins.afterExecute(cached, pluginContext);
53
+ }
54
+
55
+ const { response } = await executor.execute({ ...request, query }, context);
56
+ return await plugins.afterExecute(response, pluginContext);
57
+ } catch (error) {
58
+ throw await plugins.onError(error as Error, pluginContext);
59
+ }
60
+ }
24
61
  ```
25
62
 
63
+ Two caveats of this arrangement:
64
+
65
+ - `LeavenExecutor.execute` parses and validates internally, so the document you
66
+ hand to `afterParse` is parsed a second time by the executor. Hooks that
67
+ *rewrite* the document (`afterParse` returning a new `DocumentNode`) cannot
68
+ reach the executor this way; hooks that *inspect and reject* it — depth limit,
69
+ complexity — work exactly as intended.
70
+ - `beforeValidate` / `afterValidate` are only dispatched if you validate the
71
+ document yourself. The pipeline above skips them.
72
+
26
73
  ## Built-in Plugins
27
74
 
28
75
  ### Logging Plugin
@@ -31,15 +78,41 @@ const executor = new LeavenExecutor({
31
78
  import { createLoggingPlugin } from '@leaven-graphql/plugins';
32
79
 
33
80
  const loggingPlugin = createLoggingPlugin({
81
+ // Minimum severity to emit (default: 'info')
82
+ level: 'info',
83
+ // Any object with debug/info/warn/error methods (default: console)
34
84
  logger: console,
35
- logLevel: 'info',
36
- includeVariables: false,
37
- includeResult: false,
85
+ });
86
+ ```
87
+
88
+ `level` is the only filter; there are no options for including variables or
89
+ results. Messages are emitted with a structured payload as the second argument:
90
+
91
+ ```typescript
92
+ // level: 'debug' — before execution
93
+ logger.debug('Executing GraphQL operation', {
94
+ operationName: 'getUser',
95
+ query: '{ user(id: "1") { name } }',
38
96
  });
39
97
 
40
- // Output:
41
- // [GraphQL] Query getUser started
42
- // [GraphQL] Query getUser completed in 15ms
98
+ // level: 'info' or lower — after a successful execution
99
+ logger.info('GraphQL operation completed', {
100
+ operationName: 'getUser',
101
+ duration: '15.23ms',
102
+ });
103
+
104
+ // after an execution that produced errors
105
+ logger.error('GraphQL operation completed with errors', {
106
+ operationName: 'getUser',
107
+ duration: '15.23ms',
108
+ errors: [/* ... */],
109
+ });
110
+
111
+ // from the onError hook
112
+ logger.error('GraphQL operation failed', {
113
+ operationName: 'getUser',
114
+ error: 'Something went wrong',
115
+ });
43
116
  ```
44
117
 
45
118
  ### Tracing Plugin
@@ -47,21 +120,26 @@ const loggingPlugin = createLoggingPlugin({
47
120
  ```typescript
48
121
  import { createTracingPlugin } from '@leaven-graphql/plugins';
49
122
 
50
- const tracingPlugin = createTracingPlugin({
51
- includeResolvers: true,
52
- includeValidation: true,
53
- });
123
+ const tracingPlugin = createTracingPlugin();
124
+ ```
54
125
 
55
- // Adds tracing to response extensions:
126
+ Takes no options. It measures each phase from its own hooks, so a phase whose
127
+ hooks were never dispatched is omitted rather than reported as a false zero.
128
+ Durations and offsets are nanoseconds, Apollo Tracing style:
129
+
130
+ ```typescript
56
131
  // {
57
132
  // extensions: {
58
133
  // tracing: {
59
134
  // version: 1,
60
- // startTime: "2026-01-01T12:00:00.000Z",
135
+ // startTime: '2026-01-01T12:00:00.000Z',
136
+ // endTime: '2026-01-01T12:00:00.015Z',
61
137
  // duration: 15000000,
62
- // execution: { ... }
63
- // }
64
- // }
138
+ // parsing: { startOffset: 1000, duration: 250000 },
139
+ // validation: { startOffset: 260000, duration: 400000 },
140
+ // execution: { startOffset: 680000, duration: 14300000 },
141
+ // },
142
+ // },
65
143
  // }
66
144
  ```
67
145
 
@@ -70,175 +148,393 @@ const tracingPlugin = createTracingPlugin({
70
148
  ```typescript
71
149
  import { createDepthLimitPlugin } from '@leaven-graphql/plugins';
72
150
 
73
- const depthLimitPlugin = createDepthLimitPlugin({
74
- maxDepth: 10,
75
- ignoreIntrospection: true,
76
- });
151
+ // Bare number form
152
+ const depthLimitPlugin = createDepthLimitPlugin(10);
77
153
 
78
- // Rejects queries that are too deeply nested
154
+ // Equivalent options-object form
155
+ const alsoDepthLimit = createDepthLimitPlugin({ maxDepth: 10 });
79
156
  ```
80
157
 
158
+ `maxDepth` is the only setting — there is no `ignoreIntrospection`. Only field
159
+ selections add a level of depth. Fragment spreads are resolved through their
160
+ definitions and each fragment is measured once per document, so repeated spreads
161
+ cannot fan out exponentially and recursive fragments terminate.
162
+
163
+ Throws `DepthLimitError` (HTTP 400, `extensions.code: 'DEPTH_LIMIT'`) when a
164
+ query is too deep, or when analyzing the document would exceed the 100,000-node
165
+ analysis budget (`extensions.reason: 'ANALYSIS_BUDGET_EXCEEDED'`).
166
+
81
167
  ### Complexity Plugin
82
168
 
83
169
  ```typescript
84
170
  import { createComplexityPlugin } from '@leaven-graphql/plugins';
85
171
 
86
172
  const complexityPlugin = createComplexityPlugin({
173
+ // Required: reject operations scoring above this
87
174
  maxComplexity: 1000,
88
- defaultFieldComplexity: 1,
89
- scalarCost: 0,
90
- objectCost: 1,
91
- listFactor: 10,
175
+ // Cost of a single field (default: 1)
176
+ defaultComplexity: 1,
177
+ // Page size assumed for a first/last/limit argument whose value cannot be
178
+ // determined — an unsupplied variable that also declares no default.
179
+ // Deliberately pessimistic, since the value is client-controlled (default: 100)
180
+ unknownMultiplier: 100,
92
181
  });
182
+ ```
183
+
184
+ By default a field scores `defaultComplexity` plus its children, and the whole
185
+ subtree is multiplied by any `first` / `last` / `limit` page size, resolved from
186
+ a literal, the request variables, or the operation's declared variable default.
93
187
 
94
- // Calculates and enforces query complexity
188
+ Supply a `calculator` to score fields yourself. Its return value is the field's
189
+ **total** complexity, so incorporate `childComplexity` explicitly:
190
+
191
+ ```typescript
192
+ const customComplexity = createComplexityPlugin({
193
+ maxComplexity: 1000,
194
+ calculator: (node, childComplexity) => {
195
+ const name = (node as { name?: { value: string } }).name?.value;
196
+ return (name === 'search' ? 10 : 1) + childComplexity;
197
+ },
198
+ });
95
199
  ```
96
200
 
201
+ Throws `ComplexityError` (HTTP 400, `extensions.code: 'COMPLEXITY_LIMIT'`) when
202
+ an operation exceeds `maxComplexity`, or when analysis exceeds the node budget.
203
+
97
204
  ### Caching Plugin
98
205
 
99
206
  ```typescript
100
207
  import { createCachingPlugin } from '@leaven-graphql/plugins';
101
208
 
102
209
  const cachingPlugin = createCachingPlugin({
103
- ttl: 60000, // Cache TTL in ms
104
- maxSize: 100, // Max cached responses
105
- keyGenerator: (request) => {
106
- return `${request.query}:${JSON.stringify(request.variables)}`;
210
+ ttl: 60000, // Cache TTL in ms (default: 60000)
211
+ maxSize: 100, // Max cached responses, LRU-evicted (default: 100)
212
+ // Derives the cache key. Incorporate the caller's identity here so
213
+ // responses are never served across users.
214
+ keyFn: (context) => {
215
+ const userId = (context.context as { user?: { id: string } })?.user?.id ?? 'anonymous';
216
+ return `${userId}:${context.request.query}:${JSON.stringify(context.request.variables)}`;
107
217
  },
108
218
  });
109
219
  ```
110
220
 
221
+ `createCachingPlugin` requires either a `keyFn` or an explicit
222
+ `allowSharedCache: true`. Without a `keyFn` the cache key contains no user or
223
+ tenant identity, so entries are shared across every caller — opt in only for
224
+ data that is genuinely public:
225
+
226
+ ```typescript
227
+ const publicCache = createCachingPlugin({ ttl: 60000, allowSharedCache: true });
228
+ ```
229
+
230
+ A cache hit short-circuits `beforeExecute`, so resolvers — and any field-level
231
+ authorization they perform — do not run.
232
+
111
233
  ## Creating Custom Plugins
112
234
 
235
+ `createPlugin` takes **two** arguments: the metadata, then the hooks.
236
+
113
237
  ```typescript
114
238
  import { createPlugin } from '@leaven-graphql/plugins';
115
239
 
116
- const metricsPlugin = createPlugin({
117
- name: 'metrics',
118
- version: '1.0.0',
119
-
120
- beforeParse: (query, context) => {
121
- context.startTime = performance.now();
122
- return query;
240
+ const metricsPlugin = createPlugin(
241
+ {
242
+ name: 'metrics',
243
+ version: '1.0.0',
244
+ description: 'Records operation timings',
123
245
  },
246
+ {
247
+ beforeParse: (query, context) => {
248
+ // Per-request scratch space lives on `context.state`, a Map
249
+ context.state.set('startTime', performance.now());
250
+ return query;
251
+ },
252
+
253
+ afterExecute: (response, context) => {
254
+ const startTime = context.state.get('startTime') as number;
255
+
256
+ metrics.record({
257
+ operation: context.request.operationName,
258
+ duration: performance.now() - startTime,
259
+ errors: response.errors?.length ?? 0,
260
+ });
261
+
262
+ return response;
263
+ },
264
+
265
+ onError: (error, context) => {
266
+ metrics.recordError({
267
+ operation: context.request.operationName,
268
+ error: error.message,
269
+ });
270
+ return error;
271
+ },
272
+ }
273
+ );
274
+ ```
124
275
 
125
- afterExecute: (result, context) => {
126
- const duration = performance.now() - context.startTime;
276
+ A plugin is a plain object, so you can also write one by hand:
127
277
 
128
- metrics.record({
129
- operation: context.operationName,
130
- duration,
131
- errors: result.errors?.length ?? 0,
132
- });
278
+ ```typescript
279
+ import type { Plugin } from '@leaven-graphql/plugins';
133
280
 
134
- return result;
281
+ const auditPlugin: Plugin = {
282
+ metadata: { name: 'audit', version: '1.0.0' },
283
+ onRegister: async (schema) => {
284
+ await auditLog.open(schema);
135
285
  },
136
-
137
- onError: (error, context) => {
138
- metrics.recordError({
139
- operation: context.operationName,
140
- error: error.message,
141
- });
286
+ onUnregister: async () => {
287
+ await auditLog.close();
142
288
  },
143
- });
289
+ afterExecute: (response) => response,
290
+ };
144
291
  ```
145
292
 
146
293
  ## Plugin Manager
147
294
 
148
295
  ```typescript
149
- import { PluginManager, createPluginManager } from '@leaven-graphql/plugins';
296
+ import {
297
+ createPluginManager,
298
+ createLoggingPlugin,
299
+ createTracingPlugin,
300
+ createDepthLimitPlugin,
301
+ } from '@leaven-graphql/plugins';
150
302
 
151
303
  const manager = createPluginManager({
152
304
  schema,
153
305
  plugins: [
154
- createLoggingPlugin({ logger: console }),
306
+ createLoggingPlugin({ level: 'info' }),
155
307
  createTracingPlugin(),
156
- createDepthLimitPlugin({ maxDepth: 10 }),
308
+ createDepthLimitPlugin(10),
157
309
  ],
158
310
  });
159
311
 
160
- // Register additional plugins
161
- manager.register(myCustomPlugin);
312
+ // Constructor plugins are inserted synchronously (duplicate names and
313
+ // unsatisfied dependencies throw from the constructor), but their async
314
+ // `onRegister` hooks are deferred. `init()` runs them; the manager also awaits
315
+ // it lazily before the first registration change or hook dispatch.
316
+ await manager.init();
317
+
318
+ // Register additional plugins — async, and rejects if `onRegister` throws
319
+ await manager.register(myCustomPlugin);
320
+
321
+ // Unregister — resolves to false if no such plugin is registered
322
+ const removed = await manager.unregister('logging'); // true
162
323
 
163
- // Unregister plugins
164
- manager.unregister('logging');
324
+ // Inspect
325
+ manager.has('tracing'); // boolean
326
+ manager.get('tracing'); // Plugin | undefined
327
+ manager.getPluginNames(); // string[], in registration order
328
+ manager.size; // number
165
329
 
166
- // Get registered plugin names
167
- const plugins = manager.getPluginNames();
330
+ // Remove everything, in reverse registration order
331
+ await manager.clear();
168
332
  ```
169
333
 
334
+ Registration is transactional: if a plugin's `onRegister` rejects, the plugin is
335
+ removed from the registry and every hook index before the error propagates, so
336
+ its request hooks never run and the registration can be retried.
337
+
338
+ `init()` is likewise all-or-nothing — a failing deferred `onRegister` unwinds
339
+ the hooks that already succeeded, and the failure is not cached, so a later call
340
+ retries the whole run.
341
+
170
342
  ## Composing Plugins
171
343
 
172
- ```typescript
173
- import { composePlugins } from '@leaven-graphql/plugins';
344
+ `composePlugins` is **name-first and variadic** — the composite needs its own
345
+ name, since it is registered like any other plugin.
174
346
 
175
- const combinedPlugin = composePlugins([
176
- createLoggingPlugin({ logger: console }),
347
+ ```typescript
348
+ import {
349
+ composePlugins,
350
+ createPluginManager,
351
+ createLoggingPlugin,
352
+ createTracingPlugin,
353
+ createDepthLimitPlugin,
354
+ createComplexityPlugin,
355
+ } from '@leaven-graphql/plugins';
356
+
357
+ const combinedPlugin = composePlugins(
358
+ 'guardrails',
359
+ createLoggingPlugin({ level: 'info' }),
177
360
  createTracingPlugin(),
178
- createDepthLimitPlugin({ maxDepth: 10 }),
179
- createComplexityPlugin({ maxComplexity: 500 }),
180
- ]);
361
+ createDepthLimitPlugin(10),
362
+ createComplexityPlugin({ maxComplexity: 500 })
363
+ );
181
364
 
182
- const executor = new LeavenExecutor({
183
- schema,
184
- plugins: [combinedPlugin],
185
- });
365
+ const manager = createPluginManager({ schema, plugins: [combinedPlugin] });
186
366
  ```
187
367
 
368
+ The composite delegates every hook, plus `onRegister` / `onUnregister`, to its
369
+ members in order — except `afterExecute`, which runs in reverse, mirroring
370
+ `PluginManager`. Passing an array instead of spread arguments does **not** work:
371
+ the array becomes the composite's `name` and no plugin is delegated to.
372
+
188
373
  ## Plugin Hooks
189
374
 
190
- | Hook | Description |
191
- |------|-------------|
192
- | `beforeParse` | Called before parsing the query string |
193
- | `afterParse` | Called after parsing, receives DocumentNode |
194
- | `beforeValidate` | Called before schema validation |
195
- | `afterValidate` | Called after validation with results |
196
- | `beforeExecute` | Called before resolver execution |
197
- | `afterExecute` | Called after execution with results |
198
- | `onError` | Called when an error occurs |
375
+ | Hook | Signature | Return |
376
+ |------|-----------|--------|
377
+ | `beforeParse` | `(query: string, context)` | new query string, or nothing |
378
+ | `afterParse` | `(document: DocumentNode, context)` | new document, or nothing |
379
+ | `beforeValidate` | `(document: DocumentNode, context)` | nothing |
380
+ | `afterValidate` | `(result: { valid, errors }, context)` | nothing |
381
+ | `beforeExecute` | `(document: DocumentNode, context)` | a `GraphQLResponse` to short-circuit execution, or nothing |
382
+ | `afterExecute` | `(response: GraphQLResponse, context)` | replacement response, or nothing |
383
+ | `onError` | `(error: Error, context)` | replacement error, or nothing |
384
+
385
+ Every hook may return a promise. `beforeExecute` receives the **document**, not
386
+ just the context.
199
387
 
200
388
  ## API Reference
201
389
 
202
- ### Plugin Interface
390
+ ### Plugin
203
391
 
204
392
  ```typescript
205
- interface Plugin {
393
+ interface PluginMetadata {
394
+ /** Unique plugin name */
206
395
  name: string;
207
396
  version?: string;
397
+ description?: string;
398
+ /** Names of plugins that must already be registered */
399
+ dependencies?: string[];
400
+ }
208
401
 
209
- beforeParse?(query: string, context: PluginContext): string | void;
210
- afterParse?(document: DocumentNode, context: PluginContext): DocumentNode | void;
211
- beforeValidate?(document: DocumentNode, context: PluginContext): void;
212
- afterValidate?(result: ValidationResult, context: PluginContext): void;
213
- beforeExecute?(context: PluginContext): void | Promise<void>;
214
- afterExecute?(result: ExecutionResult, context: PluginContext): ExecutionResult | void;
215
- onError?(error: Error, context: PluginContext): void;
402
+ interface PluginHooks {
403
+ beforeParse?: BeforeParseHook;
404
+ afterParse?: AfterParseHook;
405
+ beforeValidate?: BeforeValidateHook;
406
+ afterValidate?: AfterValidateHook;
407
+ beforeExecute?: BeforeExecuteHook;
408
+ afterExecute?: AfterExecuteHook;
409
+ onError?: OnErrorHook;
216
410
  }
411
+
412
+ interface Plugin extends PluginHooks {
413
+ metadata: PluginMetadata;
414
+ onRegister?: (schema: GraphQLSchema) => void | Promise<void>;
415
+ onUnregister?: () => void | Promise<void>;
416
+ }
417
+ ```
418
+
419
+ ### PluginContext
420
+
421
+ ```typescript
422
+ interface PluginContext<TContext = unknown> {
423
+ schema: GraphQLSchema;
424
+ request: GraphQLRequest;
425
+ /** User context */
426
+ context: TContext;
427
+ /** Per-request scratch space shared between hooks */
428
+ state: Map<string, unknown>;
429
+ }
430
+ ```
431
+
432
+ ### Hook types
433
+
434
+ ```typescript
435
+ type BeforeParseHook = (
436
+ query: string,
437
+ context: PluginContext
438
+ ) => string | void | Promise<string | void>;
439
+
440
+ type AfterParseHook = (
441
+ document: DocumentNode,
442
+ context: PluginContext
443
+ ) => DocumentNode | void | Promise<DocumentNode | void>;
444
+
445
+ type BeforeValidateHook = (
446
+ document: DocumentNode,
447
+ context: PluginContext
448
+ ) => void | Promise<void>;
449
+
450
+ type AfterValidateHook = (
451
+ result: { valid: boolean; errors: readonly GraphQLError[] },
452
+ context: PluginContext
453
+ ) => void | Promise<void>;
454
+
455
+ type BeforeExecuteHook = (
456
+ document: DocumentNode,
457
+ context: PluginContext
458
+ ) => GraphQLResponse | void | Promise<GraphQLResponse | void>;
459
+
460
+ type AfterExecuteHook = (
461
+ response: GraphQLResponse,
462
+ context: PluginContext
463
+ ) => GraphQLResponse | void | Promise<GraphQLResponse | void>;
464
+
465
+ type OnErrorHook = (
466
+ error: Error,
467
+ context: PluginContext
468
+ ) => Error | void | Promise<Error | void>;
217
469
  ```
218
470
 
219
471
  ### createPlugin
220
472
 
221
473
  ```typescript
222
- function createPlugin(config: Plugin): Plugin;
474
+ function createPlugin(metadata: PluginMetadata, hooks: PluginHooks): Plugin;
223
475
  ```
224
476
 
225
477
  ### composePlugins
226
478
 
227
479
  ```typescript
228
- function composePlugins(plugins: Plugin[]): Plugin;
480
+ function composePlugins(name: string, ...plugins: Plugin[]): Plugin;
229
481
  ```
230
482
 
231
483
  ### PluginManager
232
484
 
233
485
  ```typescript
486
+ interface PluginManagerConfig {
487
+ schema: GraphQLSchema;
488
+ plugins?: Plugin[];
489
+ }
490
+
234
491
  class PluginManager {
235
- register(plugin: Plugin): void;
236
- unregister(name: string): void;
492
+ constructor(config: PluginManagerConfig);
493
+
494
+ /** Run deferred constructor `onRegister` hooks. Idempotent; retries on failure. */
495
+ init(): Promise<void>;
496
+
497
+ register(plugin: Plugin): Promise<void>;
498
+ /** Resolves false when no plugin by that name is registered. */
499
+ unregister(name: string): Promise<boolean>;
500
+
501
+ get(name: string): Plugin | undefined;
502
+ has(name: string): boolean;
237
503
  getPluginNames(): string[];
238
- execute<T>(hookName: string, ...args: unknown[]): T;
504
+ get size(): number;
505
+ clear(): Promise<void>;
506
+
507
+ createContext<TContext>(
508
+ request: GraphQLRequest,
509
+ userContext: TContext
510
+ ): PluginContext<TContext>;
511
+
512
+ // Hook dispatch — call these around your own execution pipeline
513
+ beforeParse(query: string, context: PluginContext): Promise<string>;
514
+ afterParse(document: DocumentNode, context: PluginContext): Promise<DocumentNode>;
515
+ beforeValidate(document: DocumentNode, context: PluginContext): Promise<void>;
516
+ afterValidate(
517
+ result: { valid: boolean; errors: readonly GraphQLError[] },
518
+ context: PluginContext
519
+ ): Promise<void>;
520
+ /** Resolves to a response when a hook short-circuits, otherwise undefined. */
521
+ beforeExecute(
522
+ document: DocumentNode,
523
+ context: PluginContext
524
+ ): Promise<GraphQLResponse | undefined>;
525
+ afterExecute(
526
+ response: GraphQLResponse,
527
+ context: PluginContext
528
+ ): Promise<GraphQLResponse>;
529
+ onError(error: Error, context: PluginContext): Promise<Error>;
239
530
  }
531
+
532
+ function createPluginManager(config: PluginManagerConfig): PluginManager;
240
533
  ```
241
534
 
535
+ There is no generic `execute(hookName, ...args)` method — each hook has its own
536
+ typed dispatch method above.
537
+
242
538
  ## License
243
539
 
244
- Apache 2.0 - Pegasus Heavy Industries LLC
540
+ Apache 2.0 - Joseph Quinn