diff --git a/.changeset/breezy-feet-greet.md b/.changeset/breezy-feet-greet.md new file mode 100644 index 0000000000..33d0e2a918 --- /dev/null +++ b/.changeset/breezy-feet-greet.md @@ -0,0 +1,5 @@ +--- +'@envelop/core': minor +--- + +Handle incremental execution errors in useErrorHandler diff --git a/packages/core/package.json b/packages/core/package.json index ce74d725b7..065496a7c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,8 +63,9 @@ }, "devDependencies": { "@graphql-tools/schema": "10.0.25", - "@graphql-tools/utils": "10.9.1", "@repeaterjs/repeater": "3.0.6", + "@graphql-tools/executor": "^1.1.0", + "@graphql-tools/utils": "10.0.11", "graphql": "16.8.1", "typescript": "5.9.2" }, @@ -79,4 +80,4 @@ "typescript": { "definition": "dist/typings/index.d.ts" } -} +} \ No newline at end of file diff --git a/packages/core/src/plugins/use-error-handler.ts b/packages/core/src/plugins/use-error-handler.ts index bf00805bf9..20b1332064 100644 --- a/packages/core/src/plugins/use-error-handler.ts +++ b/packages/core/src/plugins/use-error-handler.ts @@ -1,4 +1,10 @@ -import { DefaultContext, ExecutionResult, Plugin, TypedExecutionArgs } from '@envelop/types'; +import { + DefaultContext, + ExecutionResult, + IncrementalExecutionResult, + Plugin, + TypedExecutionArgs, +} from '@envelop/types'; import { handleStreamOrSingleExecutionResult } from '../utils.js'; import { isGraphQLError, SerializableGraphQLErrorLike } from './use-masked-errors.js'; @@ -13,15 +19,24 @@ export type ErrorHandler = ({ }) => void; type ErrorHandlerCallback = { - result: ExecutionResult; + result: ExecutionResult | IncrementalExecutionResult; args: TypedExecutionArgs; }; const makeHandleResult = >(errorHandler: ErrorHandler) => ({ result, args }: ErrorHandlerCallback) => { - if (result.errors?.length) { - errorHandler({ errors: result.errors, context: args, phase: 'execution' }); + const errors = result.errors ? [...result.errors] : []; + if ('incremental' in result && result.incremental) { + for (const increment of result.incremental) { + if (increment.errors) { + errors.push(...increment.errors); + } + } + } + + if (errors.length) { + errorHandler({ errors, context: args, phase: 'execution' }); } }; diff --git a/packages/core/test/plugins/use-error-handler.spec.ts b/packages/core/test/plugins/use-error-handler.spec.ts index 942b634400..4b5c2e76bf 100644 --- a/packages/core/test/plugins/use-error-handler.spec.ts +++ b/packages/core/test/plugins/use-error-handler.spec.ts @@ -1,10 +1,12 @@ -import { useExtendContext } from '@envelop/core'; +import * as GraphQLJS from 'graphql'; +import { useEngine, useExtendContext } from '@envelop/core'; import { assertStreamExecutionValue, collectAsyncIteratorValues, createTestkit, } from '@envelop/testing'; import { Plugin } from '@envelop/types'; +import { normalizedExecutor } from '@graphql-tools/executor'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { createGraphQLError } from '@graphql-tools/utils'; import { Repeater } from '@repeaterjs/repeater'; @@ -139,4 +141,37 @@ describe('useErrorHandler', () => { }), ); }); + + it('should invoke error handler when error happens during incremental execution', async () => { + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + directive @defer on FRAGMENT_SPREAD | INLINE_FRAGMENT + + type Query { + foo: String + } + `, + resolvers: { + Query: { + foo: () => { + throw new Error('kaboom'); + }, + }, + }, + }); + + const mockHandler = jest.fn(); + const testInstance = createTestkit( + [ + useEngine({ ...GraphQLJS, execute: normalizedExecutor, subscribe: normalizedExecutor }), + useErrorHandler(mockHandler), + ], + schema, + ); + const result = await testInstance.execute(`query { ... @defer { foo } }`); + assertStreamExecutionValue(result); + await collectAsyncIteratorValues(result); + + expect(mockHandler).toHaveBeenCalledWith(expect.objectContaining({ phase: 'execution' })); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 861e9ffe53..5cb2bad79b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,12 +688,15 @@ importers: specifier: ^2.5.0 version: 2.8.1 devDependencies: + '@graphql-tools/executor': + specifier: ^1.1.0 + version: 1.4.9(graphql@16.8.1) '@graphql-tools/schema': specifier: 10.0.25 version: 10.0.25(graphql@16.8.1) '@graphql-tools/utils': - specifier: 10.9.1 - version: 10.9.1(graphql@16.8.1) + specifier: 10.0.11 + version: 10.0.11(graphql@16.8.1) '@repeaterjs/repeater': specifier: 3.0.6 version: 3.0.6 @@ -3198,6 +3201,12 @@ packages: peerDependencies: graphql: 16.8.1 + '@graphql-tools/utils@10.0.11': + resolution: {integrity: sha512-vVjXgKn6zjXIlYBd7yJxCVMYGb5j18gE3hx3Qw3mNsSEsYQXbJbPdlwb7Fc9FogsJei5AaqiQerqH4kAosp1nQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: 16.8.1 + '@graphql-tools/utils@10.9.1': resolution: {integrity: sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw==} engines: {node: '>=16.0.0'} @@ -6417,6 +6426,10 @@ packages: engines: {node: '>=20'} hasBin: true + cross-inspect@1.0.0: + resolution: {integrity: sha512-4PFfn4b5ZN6FMNGSZlyb7wUhuN8wvj8t/VQHZdM4JsDcruGJ8L2kf9zao98QIrBPFCpdk27qst/AGTl7pL3ypQ==} + engines: {node: '>=16.0.0'} + cross-inspect@1.0.1: resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} engines: {node: '>=16.0.0'} @@ -13691,6 +13704,14 @@ snapshots: tslib: 2.8.1 value-or-promise: 1.0.12 + '@graphql-tools/utils@10.0.11(graphql@16.8.1)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) + cross-inspect: 1.0.0 + dset: 3.1.4 + graphql: 16.8.1 + tslib: 2.8.1 + '@graphql-tools/utils@10.9.1(graphql@16.8.1)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) @@ -17382,6 +17403,10 @@ snapshots: '@epic-web/invariant': 1.0.0 cross-spawn: 7.0.6 + cross-inspect@1.0.0: + dependencies: + tslib: 2.8.1 + cross-inspect@1.0.1: dependencies: tslib: 2.8.1