Skip to content

Commit 065418a

Browse files
authored
feat: first class abort errors (#1061)
1 parent 1596711 commit 065418a

18 files changed

+304
-146
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ coverage
77
tsconfig.vitest-temp.json
88
website/.vitepress/dist
99
website/.vitepress/cache
10+
legacy
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
'This operation was aborted'

examples/transport-http_abort.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* It is possible to cancel a request using an `AbortController` signal.
3+
*/
4+
5+
import { gql, Graffle } from '../src/entrypoints/main.js'
6+
import { publicGraphQLSchemaEndpoints, show } from './$helpers.js'
7+
8+
const abortController = new AbortController()
9+
10+
const graffle = Graffle.create({
11+
schema: publicGraphQLSchemaEndpoints.SocialStudies,
12+
})
13+
14+
const resultPromise = graffle
15+
.with({ request: { signal: abortController.signal } })
16+
.raw({
17+
document: gql`
18+
{
19+
countries {
20+
name
21+
}
22+
}
23+
`,
24+
})
25+
26+
abortController.abort()
27+
28+
const result = await resultPromise.catch((error: unknown) => (error as Error).message)
29+
30+
show(result)
31+
32+
// todo .with(...) variant

examples/transport-http_fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable */
22
import { Graffle } from '../src/entrypoints/main.js'
3-
import { show, showJson } from './$helpers.js'
3+
import { showJson } from './$helpers.js'
44
import { publicGraphQLSchemaEndpoints } from './$helpers.js'
55

66
const graffle = Graffle

src/layers/5_core/core.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,6 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
312312
// 1. Generate a map of possible custom scalar paths (tree structure)
313313
// 2. When traversing the result, skip keys that are not in the map
314314
const dataDecoded = Result.decode(getRootIndexOrThrow(input.context, input.rootTypeName), input.result.data)
315-
// console.log(8, Object.keys({ ...input.result, data: dataDecoded }))
316315
switch (input.transport) {
317316
case `memory`: {
318317
return { ...input.result, data: dataDecoded }

src/layers/6_client/RootTypeMethods.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ export type RootTypeMethods<$Config extends Config, $Index extends Schema.Index,
6565
// dprint-ignore
6666
type RootMethod<$Config extends Config, $Index extends Schema.Index, $RootTypeName extends Schema.RootTypeName> =
6767
<$SelectionSet extends object>(selectionSet: Exact<$SelectionSet, SelectionSet.Root<$Index, $RootTypeName>>) =>
68-
Promise<ResolveOutputReturnRootType<$Config, $Index, ResultSet.Root<AugmentRootTypeSelectionWithTypename<$Config,$Index,$RootTypeName,$SelectionSet>, $Index, $RootTypeName>>>
68+
Promise<
69+
ResolveOutputReturnRootType<$Config, $Index, ResultSet.Root<AugmentRootTypeSelectionWithTypename<$Config,$Index,$RootTypeName,$SelectionSet>, $Index, $RootTypeName>>
70+
>
6971

7072
// dprint-ignore
7173
type RootTypeFieldMethod<$Context extends RootTypeFieldContext> =

src/layers/6_client/Settings/Config.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { GraphQLError } from 'graphql'
22
import type { Simplify } from 'type-fest'
33
import type { GraphQLExecutionResultError } from '../../../lib/graphql.js'
4-
import type { ConfigManager, StringKeyof, Values } from '../../../lib/prelude.js'
4+
import type { ConfigManager, SimplifyExceptError, StringKeyof, Values } from '../../../lib/prelude.js'
55
import type { Schema } from '../../1_Schema/__.js'
66
import type { GlobalRegistry } from '../../2_generator/globalRegistry.js'
77
import type { SelectionSet } from '../../3_SelectionSet/__.js'
88
import type { Transport } from '../../5_core/types.js'
9+
import type { ErrorsOther } from '../client.js'
910
import type { InputStatic } from './Input.js'
1011
import type { RequestInputOptions } from './inputIncrementable/request.js'
1112

@@ -116,25 +117,29 @@ export type Config = {
116117

117118
// dprint-ignore
118119
export type ResolveOutputReturnRootType<$Config extends Config, $Index extends Schema.Index, $Data> =
119-
| Simplify<IfConfiguredGetOutputErrorReturns<$Config>>
120-
| (
121-
$Config['output']['envelope']['enabled'] extends true
122-
? Envelope<$Config, IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
123-
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
124-
)
120+
SimplifyExceptError<
121+
| IfConfiguredGetOutputErrorReturns<$Config>
122+
| (
123+
$Config['output']['envelope']['enabled'] extends true
124+
? Envelope<$Config, IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
125+
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
126+
)
127+
>
125128

126129
// dprint-ignore
127130
export type ResolveOutputReturnRootField<$Config extends Config, $Index extends Schema.Index, $Data, $DataRaw = undefined> =
128-
| IfConfiguredGetOutputErrorReturns<$Config>
129-
| (
130-
$Config['output']['envelope']['enabled'] extends true
131-
// todo: a typed execution result that allows for additional error types.
132-
// currently it is always graphql execution error however envelope configuration can put more errors into that.
133-
? Envelope<$Config, $DataRaw extends undefined
134-
? Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
135-
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $DataRaw>>>
136-
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
137-
)
131+
SimplifyExceptError<
132+
| IfConfiguredGetOutputErrorReturns<$Config>
133+
| (
134+
$Config['output']['envelope']['enabled'] extends true
135+
// todo: a typed execution result that allows for additional error types.
136+
// currently it is always graphql execution error however envelope configuration can put more errors into that.
137+
? Envelope<$Config, $DataRaw extends undefined
138+
? Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
139+
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $DataRaw>>>
140+
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
141+
)
142+
>
138143

139144
type ObjMap<T = unknown> = {
140145
[key: string]: T
@@ -193,7 +198,7 @@ type ConfigGetOutputError<$Config extends Config, $ErrorCategory extends ErrorCa
193198
// dprint-ignore
194199
type IfConfiguredGetOutputErrorReturns<$Config extends Config> =
195200
| (ConfigGetOutputError<$Config, 'execution'> extends 'return' ? GraphQLExecutionResultError : never)
196-
| (ConfigGetOutputError<$Config, 'other'> extends 'return' ? Error : never)
201+
| (ConfigGetOutputError<$Config, 'other'> extends 'return' ? ErrorsOther : never)
197202
| (ConfigGetOutputError<$Config, 'schema'> extends 'return' ? Error : never)
198203

199204
// dprint-ignore

src/layers/6_client/Settings/client.create.config.output.test-d.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
/* eslint-disable */
22
import { type ExecutionResult } from 'graphql'
3-
import type { ObjMap } from 'graphql/jsutils/ObjMap.js'
43
import { describe } from 'node:test'
5-
import type { Simplify } from 'type-fest'
6-
import type { ConditionalSimplify } from 'type-fest/source/conditional-simplify.js'
74
import { expectTypeOf, test } from 'vitest'
85
import { Graffle } from '../../../../tests/_/schema/generated/__.js'
96
import { schema } from '../../../../tests/_/schema/schema.js'
107
import { type GraphQLExecutionResultError } from '../../../lib/graphql.js'
11-
import { type Envelope, type OutputConfigDefault } from './Config.js'
8+
import type { ErrorsOther } from '../client.js'
9+
import { type Envelope } from './Config.js'
1210

1311
const G = Graffle.create
1412

@@ -104,15 +102,15 @@ describe('.envelope', () => {
104102
describe('.errors', () => {
105103
test('defaults to execution errors in envelope', () => {
106104
const g = G({ schema, output: { defaults: { errorChannel: 'return' }, envelope: true } })
107-
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<ExecutionResult<{ __typename: 'Query' }> | Error>()
105+
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<ExecutionResult<{ __typename: 'Query' }> | ErrorsOther>()
108106
})
109107
test('.execution:false restores errors to return', async () => {
110108
const g = G({
111109
schema,
112110
output: { defaults: { errorChannel: 'return' }, envelope: { errors: { execution: false } } },
113111
})
114112
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<
115-
Omit<ExecutionResult<{ __typename: 'Query' }>, 'errors'> | Error | GraphQLExecutionResultError
113+
Omit<ExecutionResult<{ __typename: 'Query' }>, 'errors'> | ErrorsOther | GraphQLExecutionResultError
116114
>()
117115
})
118116
test('.other:true raises them to envelope', () => {
@@ -140,7 +138,7 @@ describe('defaults.errorChannel: "return"', () => {
140138
describe('puts errors into return type', () => {
141139
const g = G({ schema, output: { defaults: { errorChannel: 'return' } } })
142140
test('query.<fieldMethod>', () => {
143-
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<'Query' | Error | GraphQLExecutionResultError>()
141+
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<'Query' | ErrorsOther | GraphQLExecutionResultError>()
144142
})
145143
})
146144
describe('with .errors', () => {
@@ -149,7 +147,7 @@ describe('defaults.errorChannel: "return"', () => {
149147
schema,
150148
output: { defaults: { errorChannel: 'return' }, errors: { execution: 'throw' } },
151149
})
152-
expectTypeOf(await g.query.__typename()).toEqualTypeOf<'Query' | Error>()
150+
expectTypeOf(await g.query.__typename()).toEqualTypeOf<'Query' | ErrorsOther>()
153151
})
154152
test('.other: throw', async () => {
155153
const g = G({

src/layers/6_client/client.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,42 @@ describe(`transport`, () => {
5959
const request = fetch.mock.calls[0]?.[0]
6060
expect(request?.headers.get(`x-foo`)).toEqual(`bar`)
6161
})
62-
63-
test(`sends well formed request`, async ({ fetch, graffle }) => {
62+
test(`sends spec compliant request`, async ({ fetch, graffle }) => {
6463
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { greetings: `Hello World` } })))
6564
await graffle.rawString({ document: `query { greetings }` })
6665
const request = fetch.mock.calls[0]?.[0]
6766
expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_JSON)
6867
expect(request?.headers.get(`accept`)).toEqual(CONTENT_TYPE_GQL)
6968
})
69+
describe(`signal`, () => {
70+
// JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers.
71+
const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/
72+
test(`AbortController at instance level works`, async () => {
73+
const abortController = new AbortController()
74+
const graffle = Graffle.create({
75+
schema: endpoint,
76+
request: { signal: abortController.signal },
77+
})
78+
const resultPromise = graffle.rawString({ document: `query { id }` })
79+
abortController.abort()
80+
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
81+
caughtError: Error
82+
}
83+
expect(caughtError.message).toMatch(abortErrorMessagePattern)
84+
})
85+
test(`AbortController at method level works`, async () => {
86+
const abortController = new AbortController()
87+
const graffle = Graffle.create({
88+
schema: endpoint,
89+
}).with({ request: { signal: abortController.signal } })
90+
const resultPromise = graffle.rawString({ document: `query { id }` })
91+
abortController.abort()
92+
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
93+
caughtError: Error
94+
}
95+
expect(caughtError.message).toMatch(abortErrorMessagePattern)
96+
})
97+
})
7098
})
7199
describe(`memory`, () => {
72100
test(`anyware hooks are typed to memory transport`, () => {

0 commit comments

Comments
 (0)