@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/src/testing.ts
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@znight/graphql/testing`: test helpers for GraphQL applications (RFC 0005 §5.12).
|
|
3
|
+
*
|
|
4
|
+
* - `executeOperation()` sends an operation through the whole HTTP pipeline of a `TestApp`
|
|
5
|
+
* (`app.inject()`: guards, hooks, every adapter) or, without HTTP transport, through
|
|
6
|
+
* `GraphQLPlugin.execute()`;
|
|
7
|
+
* - `subscribeOperation()` streams an operation over GraphQL over SSE in memory, or through
|
|
8
|
+
* `GraphQLPlugin.subscribe()` without HTTP transport;
|
|
9
|
+
* - `fakeLoader()` builds a `Loader<L>` whose `load` / `loadMany` are spies of the running test
|
|
10
|
+
* framework (`bun:test`, Vitest, Jest, or the built-in spy of `@znight/testing`).
|
|
11
|
+
*
|
|
12
|
+
* Needs `@znight/testing` (an optional peer dependency of `@znight/graphql`).
|
|
13
|
+
*/
|
|
14
|
+
import type { Plugin } from '@znight/core'
|
|
15
|
+
import type { SseItem } from '@znight/http'
|
|
16
|
+
import type { MockFunction, TestApp } from '@znight/testing'
|
|
17
|
+
import type { FormattedExecutionResult, GraphQLFormattedError } from 'graphql'
|
|
18
|
+
import type { GraphQLRequest, GraphQLResultStream } from './execution.ts'
|
|
19
|
+
import type { BatchLoad, BatchLoader, LoaderKey, LoaderValue } from './types.ts'
|
|
20
|
+
|
|
21
|
+
import { parseSseStream } from '@znight/http'
|
|
22
|
+
import { resolveMockFn } from '@znight/testing'
|
|
23
|
+
|
|
24
|
+
import { endpointOf, executorOf, type GraphQLPlugin } from './plugin.ts'
|
|
25
|
+
|
|
26
|
+
/** An operation for {@link executeOperation} and {@link subscribeOperation}. */
|
|
27
|
+
export interface OperationRequest {
|
|
28
|
+
readonly query: string
|
|
29
|
+
readonly variables?: Readonly<Record<string, unknown>>
|
|
30
|
+
readonly operationName?: string
|
|
31
|
+
readonly extensions?: Readonly<Record<string, unknown>>
|
|
32
|
+
/**
|
|
33
|
+
* Request headers (`x-api-key`, `accept`...), merged over the defaults (`content-type:
|
|
34
|
+
* application/json`, and `accept: application/graphql-response+json, application/json;q=0.9`
|
|
35
|
+
* or `text/event-stream`). Ignored without HTTP transport.
|
|
36
|
+
*/
|
|
37
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A GraphQL result: `data`, `errors` and `extensions` when present. */
|
|
41
|
+
export interface OperationResult<TData = Record<string, unknown>> {
|
|
42
|
+
readonly data?: TData | null
|
|
43
|
+
readonly errors?: readonly GraphQLFormattedError[]
|
|
44
|
+
readonly extensions?: Readonly<Record<string, unknown>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What {@link executeOperation} resolves with. */
|
|
48
|
+
export interface OperationResponse<TData = Record<string, unknown>> extends OperationResult<TData> {
|
|
49
|
+
/**
|
|
50
|
+
* The HTTP status: 200 for an executed operation (field errors included), 400 for a request
|
|
51
|
+
* error (the default `accept` is `application/graphql-response+json`), 403 for an HTTP guard...
|
|
52
|
+
* Without HTTP transport, the status the endpoint would answer.
|
|
53
|
+
*/
|
|
54
|
+
readonly status: number
|
|
55
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
|
|
56
|
+
/** The raw body (an HTTP error body is not a GraphQL result: `data` and `errors` are then absent). */
|
|
57
|
+
readonly body: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Options of {@link OperationStream.next}. */
|
|
61
|
+
export interface OperationStreamWaitOptions {
|
|
62
|
+
/** Maximum wait for the next result (ms). Default 2 000. */
|
|
63
|
+
readonly timeoutMs?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The results of {@link subscribeOperation}, one per `next` event. Ends after `complete` (or the
|
|
68
|
+
* single result of a query, or a request error). `return()` disconnects: the server sees a client
|
|
69
|
+
* abort (`ctx.signal`, then the source's `return()`).
|
|
70
|
+
*/
|
|
71
|
+
export interface OperationStream<TData = Record<string, unknown>>
|
|
72
|
+
extends AsyncIterableIterator<OperationResult<TData>>,
|
|
73
|
+
AsyncDisposable {
|
|
74
|
+
/** HTTP status of the stream (200), or of a request error (400, 405, 503...). */
|
|
75
|
+
readonly status: number
|
|
76
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
|
|
77
|
+
/** The next result. Rejects when none arrives within `timeoutMs` (2 000 ms by default). */
|
|
78
|
+
next(options?: OperationStreamWaitOptions): Promise<IteratorResult<OperationResult<TData>, undefined>>
|
|
79
|
+
/** Ends the stream (disconnects). */
|
|
80
|
+
return(): Promise<IteratorResult<OperationResult<TData>, undefined>>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The application `executeOperation()` and `subscribeOperation()` run against: a `TestApp`. */
|
|
84
|
+
export type GraphQLTestApp = Pick<TestApp, 'app' | 'inject' | 'injectStream'>
|
|
85
|
+
|
|
86
|
+
const DEFAULT_TIMEOUT_MS = 2_000
|
|
87
|
+
const RESPONSE_ACCEPT = 'application/graphql-response+json, application/json;q=0.9'
|
|
88
|
+
const DONE: IteratorResult<never, undefined> = Object.freeze({ done: true, value: undefined })
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Executes a query or a mutation through the whole HTTP pipeline (`app.inject()`: `POST` on the
|
|
92
|
+
* endpoint), or through `GraphQLPlugin` without HTTP transport.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* const res = await executeOperation(app, { query: '{ books { title } }', headers: { 'x-api-key': KEY } })
|
|
96
|
+
* expect(res.status).toBe(200)
|
|
97
|
+
* expect(res.errors).toBeUndefined()
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export async function executeOperation<TData = Record<string, unknown>>(
|
|
101
|
+
app: GraphQLTestApp,
|
|
102
|
+
request: OperationRequest,
|
|
103
|
+
): Promise<OperationResponse<TData>> {
|
|
104
|
+
const plugin = graphqlPlugin(app, 'executeOperation()')
|
|
105
|
+
const body = operationBody(request, 'executeOperation()')
|
|
106
|
+
const endpoint = endpointOf(plugin)
|
|
107
|
+
if (endpoint === undefined) {
|
|
108
|
+
const response = await executorOf(plugin).execute(body)
|
|
109
|
+
return {
|
|
110
|
+
...pick<TData>(response.result),
|
|
111
|
+
status: response.status,
|
|
112
|
+
headers: { ...response.headers },
|
|
113
|
+
body: JSON.stringify(response.result),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const response = await app.inject({
|
|
117
|
+
method: 'POST',
|
|
118
|
+
url: endpoint,
|
|
119
|
+
headers: headers(request, RESPONSE_ACCEPT),
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
})
|
|
122
|
+
return {
|
|
123
|
+
...parseResult<TData>(response.body),
|
|
124
|
+
status: response.status,
|
|
125
|
+
headers: response.headers,
|
|
126
|
+
body: response.body,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Starts an operation as a stream: GraphQL over SSE in memory (`app.injectStream()`: `POST` with
|
|
132
|
+
* `accept: text/event-stream`), or `GraphQLPlugin.subscribe()` without HTTP transport (no HTTP
|
|
133
|
+
* guards then). Resolves once the subscription is established: its source has started (a resolver
|
|
134
|
+
* written as an async generator has subscribed to the PubSub, when it subscribes before its first
|
|
135
|
+
* `await`), so events published from then on
|
|
136
|
+
* are received, even before the first `next()`.
|
|
137
|
+
*
|
|
138
|
+
* ```ts
|
|
139
|
+
* await using events = await subscribeOperation(app, { query: 'subscription { bookAdded { title } }' })
|
|
140
|
+
* await executeOperation(app, { query: ADD_DUNE })
|
|
141
|
+
* expect((await events.next()).value).toEqual({ data: { bookAdded: { title: 'Dune' } } })
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export async function subscribeOperation<TData = Record<string, unknown>>(
|
|
145
|
+
app: GraphQLTestApp,
|
|
146
|
+
request: OperationRequest,
|
|
147
|
+
): Promise<OperationStream<TData>> {
|
|
148
|
+
const plugin = graphqlPlugin(app, 'subscribeOperation()')
|
|
149
|
+
const body = operationBody(request, 'subscribeOperation()')
|
|
150
|
+
const endpoint = endpointOf(plugin)
|
|
151
|
+
const label = describeOperation(body.query)
|
|
152
|
+
if (endpoint === undefined) {
|
|
153
|
+
const outcome = await executorOf(plugin).subscribe(body)
|
|
154
|
+
if ('status' in outcome) return new SingleResult(outcome.status, {}, pick<TData>(outcome.result))
|
|
155
|
+
return new Results<TData>(200, {}, directSource(outcome), label)
|
|
156
|
+
}
|
|
157
|
+
const response = await app.injectStream({
|
|
158
|
+
method: 'POST',
|
|
159
|
+
url: endpoint,
|
|
160
|
+
headers: headers(request, 'text/event-stream'),
|
|
161
|
+
body: JSON.stringify(body),
|
|
162
|
+
})
|
|
163
|
+
const type = response.headers['content-type']
|
|
164
|
+
if (typeof type !== 'string' || !type.startsWith('text/event-stream')) {
|
|
165
|
+
const text = await new Response(response.body).text()
|
|
166
|
+
return new SingleResult(response.status, response.headers, parseResult<TData>(text))
|
|
167
|
+
}
|
|
168
|
+
return new Results<TData>(response.status, response.headers, sseSource(parseSseStream(response.body)), label)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Options of {@link fakeLoader}: what a key resolves to. */
|
|
172
|
+
export type FakeLoaderValues<K, V> =
|
|
173
|
+
| Readonly<Record<string, V | Error>>
|
|
174
|
+
| ReadonlyMap<K, V | Error>
|
|
175
|
+
| ((key: K) => V | Error | Promise<V | Error>)
|
|
176
|
+
|
|
177
|
+
/** A `Loader<L>` built by {@link fakeLoader}: `load` and `loadMany` are spies. */
|
|
178
|
+
export interface FakeLoader<K, V> extends BatchLoader<K, V> {
|
|
179
|
+
readonly load: MockFunction<(key: K) => Promise<V>> & ((key: K) => Promise<V>)
|
|
180
|
+
readonly loadMany: MockFunction<(keys: readonly K[]) => Promise<(V | Error)[]>> &
|
|
181
|
+
((keys: readonly K[]) => Promise<(V | Error)[]>)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A `Loader<L>` for unit tests of resolvers (with `TestBed`), answering from `values` (a record by
|
|
186
|
+
* key, a map, or a function of the key; an `Error` value fails that key). `load` and `loadMany` are
|
|
187
|
+
* spies; a missing key rejects.
|
|
188
|
+
*
|
|
189
|
+
* ```ts
|
|
190
|
+
* const authors = fakeLoader<AuthorsById>({ [herbert.id]: herbert })
|
|
191
|
+
* expect(await instance.author(dune, authors)).toBe(herbert)
|
|
192
|
+
* expect(authors.load).toHaveBeenCalledWith(herbert.id)
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
export function fakeLoader<L extends BatchLoad<unknown, unknown>>(
|
|
196
|
+
values: FakeLoaderValues<LoaderKey<L>, LoaderValue<L>>,
|
|
197
|
+
): FakeLoader<LoaderKey<L>, LoaderValue<L>> {
|
|
198
|
+
type K = LoaderKey<L>
|
|
199
|
+
type V = LoaderValue<L>
|
|
200
|
+
const resolve = lookup<K, V>(values)
|
|
201
|
+
const cache = new Map<unknown, Promise<V>>()
|
|
202
|
+
const loadOne = (key: K): Promise<V> => {
|
|
203
|
+
const cached = cache.get(key)
|
|
204
|
+
if (cached !== undefined) return cached
|
|
205
|
+
const loaded = Promise.resolve()
|
|
206
|
+
.then(() => resolve(key))
|
|
207
|
+
.then(value => (value instanceof Error ? Promise.reject(value) : value))
|
|
208
|
+
cache.set(key, loaded)
|
|
209
|
+
return loaded
|
|
210
|
+
}
|
|
211
|
+
const create = resolveMockFn().create
|
|
212
|
+
const load = create((key: K) => loadOne(key)) as FakeLoader<K, V>['load']
|
|
213
|
+
const loadMany = create((keys: readonly K[]) =>
|
|
214
|
+
Promise.all(keys.map(key => loadOne(key).catch((error: unknown) => asError(error)))),
|
|
215
|
+
) as FakeLoader<K, V>['loadMany']
|
|
216
|
+
const loader: FakeLoader<K, V> = {
|
|
217
|
+
load,
|
|
218
|
+
loadMany,
|
|
219
|
+
clear(key: K) {
|
|
220
|
+
cache.delete(key)
|
|
221
|
+
return this
|
|
222
|
+
},
|
|
223
|
+
clearAll() {
|
|
224
|
+
cache.clear()
|
|
225
|
+
return this
|
|
226
|
+
},
|
|
227
|
+
prime(key: K, value: V | PromiseLike<V> | Error) {
|
|
228
|
+
if (!cache.has(key)) {
|
|
229
|
+
const primed = value instanceof Error ? Promise.reject(value) : Promise.resolve(value)
|
|
230
|
+
primed.catch(() => undefined)
|
|
231
|
+
cache.set(key, primed)
|
|
232
|
+
}
|
|
233
|
+
return this
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
return loader
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ------------------------------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
function graphqlPlugin(app: GraphQLTestApp, what: string): GraphQLPlugin {
|
|
242
|
+
const plugin = app?.app?.getPlugin<Plugin>('graphql')
|
|
243
|
+
if (plugin === undefined) {
|
|
244
|
+
throw new TypeError(`${what} expects a TestApp whose plugins include GraphQLPlugin.configure()`)
|
|
245
|
+
}
|
|
246
|
+
return plugin as GraphQLPlugin
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function operationBody(request: OperationRequest, what: string): GraphQLRequest {
|
|
250
|
+
if (request === null || typeof request !== 'object' || typeof request.query !== 'string') {
|
|
251
|
+
throw new TypeError(`${what} expects { query, variables?, operationName?, extensions?, headers? }`)
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
query: request.query,
|
|
255
|
+
...(request.variables === undefined ? {} : { variables: request.variables }),
|
|
256
|
+
...(request.operationName === undefined ? {} : { operationName: request.operationName }),
|
|
257
|
+
...(request.extensions === undefined ? {} : { extensions: request.extensions }),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The request headers: the defaults, then the operation's (names lower-cased, so they replace the defaults). */
|
|
262
|
+
function headers(request: OperationRequest, accept: string): Record<string, string> {
|
|
263
|
+
const result: Record<string, string> = { 'content-type': 'application/json', accept }
|
|
264
|
+
for (const [name, value] of Object.entries(request.headers ?? {})) result[name.toLowerCase()] = value
|
|
265
|
+
return result
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function pick<TData>(result: FormattedExecutionResult): OperationResult<TData> {
|
|
269
|
+
return {
|
|
270
|
+
...('data' in result ? { data: result.data as TData | null } : {}),
|
|
271
|
+
...(result.errors === undefined ? {} : { errors: result.errors }),
|
|
272
|
+
...(result.extensions === undefined ? {} : { extensions: result.extensions }),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The GraphQL result of a body; `{}` when it is not one (an HTTP error body, a page). */
|
|
277
|
+
function parseResult<TData>(body: string): OperationResult<TData> {
|
|
278
|
+
let parsed: unknown
|
|
279
|
+
try {
|
|
280
|
+
parsed = JSON.parse(body)
|
|
281
|
+
} catch {
|
|
282
|
+
return {}
|
|
283
|
+
}
|
|
284
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
285
|
+
const result = parsed as FormattedExecutionResult
|
|
286
|
+
if (!('data' in result) && !Array.isArray(result.errors)) return {}
|
|
287
|
+
return pick<TData>(result)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function describeOperation(query: string): string {
|
|
291
|
+
const text = query.replace(/\s+/g, ' ').trim()
|
|
292
|
+
return text.length > 60 ? `${text.slice(0, 60)}…` : text
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function asError(error: unknown): Error {
|
|
296
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function lookup<K, V>(values: FakeLoaderValues<K, V>): (key: K) => V | Error | Promise<V | Error> {
|
|
300
|
+
if (typeof values === 'function') return values
|
|
301
|
+
if (values instanceof Map) {
|
|
302
|
+
return key => (values.has(key) ? (values.get(key) as V | Error) : missing(key))
|
|
303
|
+
}
|
|
304
|
+
const record = values as Readonly<Record<string, V | Error>>
|
|
305
|
+
return key => (Object.hasOwn(record, String(key)) ? (record[String(key)] as V | Error) : missing(key))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function missing(key: unknown): Error {
|
|
309
|
+
return new Error(`fakeLoader(): no value for the key ${JSON.stringify(key) ?? String(key)}`)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A source of results: `undefined` when it ended. */
|
|
313
|
+
interface ResultSource {
|
|
314
|
+
next(): Promise<FormattedExecutionResult | undefined>
|
|
315
|
+
close(): Promise<void>
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function directSource(stream: GraphQLResultStream): ResultSource {
|
|
319
|
+
return {
|
|
320
|
+
next: async () => {
|
|
321
|
+
const step = await stream.next()
|
|
322
|
+
return step.done === true ? undefined : step.value
|
|
323
|
+
},
|
|
324
|
+
close: async () => {
|
|
325
|
+
await stream.return()
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The `next` events of an event stream, until `complete` (comments, `retry` and other events skipped). */
|
|
331
|
+
function sseSource(items: AsyncIterableIterator<SseItem>): ResultSource {
|
|
332
|
+
return {
|
|
333
|
+
next: async () => {
|
|
334
|
+
for (;;) {
|
|
335
|
+
const step = await items.next()
|
|
336
|
+
if (step.done === true) return undefined
|
|
337
|
+
const item = step.value
|
|
338
|
+
if (item.type !== 'event') continue
|
|
339
|
+
if (item.event === 'complete') return undefined
|
|
340
|
+
if (item.event === 'next') return JSON.parse(item.data) as FormattedExecutionResult
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
close: async () => {
|
|
344
|
+
await items.return?.()
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The stream of a subscription (or of a query sent as a stream). */
|
|
350
|
+
class Results<TData> implements OperationStream<TData> {
|
|
351
|
+
readonly status: number
|
|
352
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
|
|
353
|
+
readonly #source: ResultSource
|
|
354
|
+
readonly #label: string
|
|
355
|
+
/** A read still pending after a timeout: the next call waits for it, so no result is lost. */
|
|
356
|
+
#pending: Promise<FormattedExecutionResult | undefined> | undefined
|
|
357
|
+
#ended = false
|
|
358
|
+
|
|
359
|
+
constructor(
|
|
360
|
+
status: number,
|
|
361
|
+
headers: Readonly<Record<string, string | readonly string[] | undefined>>,
|
|
362
|
+
source: ResultSource,
|
|
363
|
+
label: string,
|
|
364
|
+
) {
|
|
365
|
+
this.status = status
|
|
366
|
+
this.headers = headers
|
|
367
|
+
this.#source = source
|
|
368
|
+
this.#label = label
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async next(options: OperationStreamWaitOptions = {}): Promise<IteratorResult<OperationResult<TData>, undefined>> {
|
|
372
|
+
if (this.#ended) return DONE
|
|
373
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
374
|
+
this.#pending ??= this.#source.next()
|
|
375
|
+
const pending = this.#pending
|
|
376
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
377
|
+
const timeout = new Promise<typeof TIMEOUT>(resolve => {
|
|
378
|
+
timer = setTimeout(() => resolve(TIMEOUT), timeoutMs)
|
|
379
|
+
})
|
|
380
|
+
let result: FormattedExecutionResult | undefined | typeof TIMEOUT
|
|
381
|
+
try {
|
|
382
|
+
result = await Promise.race([pending, timeout])
|
|
383
|
+
} catch (error) {
|
|
384
|
+
this.#pending = undefined
|
|
385
|
+
await this.return()
|
|
386
|
+
throw error
|
|
387
|
+
} finally {
|
|
388
|
+
clearTimeout(timer)
|
|
389
|
+
}
|
|
390
|
+
if (result === TIMEOUT) {
|
|
391
|
+
throw new Error(`subscribeOperation(): no result within ${timeoutMs} ms for ${this.#label}`)
|
|
392
|
+
}
|
|
393
|
+
this.#pending = undefined
|
|
394
|
+
if (result === undefined) {
|
|
395
|
+
await this.return()
|
|
396
|
+
return DONE
|
|
397
|
+
}
|
|
398
|
+
return { done: false, value: pick<TData>(result) }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async return(): Promise<IteratorResult<OperationResult<TData>, undefined>> {
|
|
402
|
+
if (!this.#ended) {
|
|
403
|
+
this.#ended = true
|
|
404
|
+
await this.#source.close()
|
|
405
|
+
}
|
|
406
|
+
return DONE
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
[Symbol.asyncIterator](): OperationStream<TData> {
|
|
410
|
+
return this
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
[Symbol.asyncDispose](): Promise<void> {
|
|
414
|
+
return this.return().then(() => undefined)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const TIMEOUT: unique symbol = Symbol('timeout')
|
|
419
|
+
|
|
420
|
+
/** A request error (or a query's result without HTTP transport): one result, then the end. */
|
|
421
|
+
class SingleResult<TData> implements OperationStream<TData> {
|
|
422
|
+
readonly status: number
|
|
423
|
+
readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
|
|
424
|
+
#result: OperationResult<TData> | undefined
|
|
425
|
+
|
|
426
|
+
constructor(
|
|
427
|
+
status: number,
|
|
428
|
+
headers: Readonly<Record<string, string | readonly string[] | undefined>>,
|
|
429
|
+
result: OperationResult<TData>,
|
|
430
|
+
) {
|
|
431
|
+
this.status = status
|
|
432
|
+
this.headers = headers
|
|
433
|
+
this.#result = result
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
next(): Promise<IteratorResult<OperationResult<TData>, undefined>> {
|
|
437
|
+
const result = this.#result
|
|
438
|
+
this.#result = undefined
|
|
439
|
+
return Promise.resolve(result === undefined ? DONE : { done: false, value: result })
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return(): Promise<IteratorResult<OperationResult<TData>, undefined>> {
|
|
443
|
+
this.#result = undefined
|
|
444
|
+
return Promise.resolve(DONE)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
[Symbol.asyncIterator](): OperationStream<TData> {
|
|
448
|
+
return this
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
[Symbol.asyncDispose](): Promise<void> {
|
|
452
|
+
return Promise.resolve()
|
|
453
|
+
}
|
|
454
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TypedToken } from '@znight/core'
|
|
2
|
+
import type { GraphQLPlugin } from './plugin.ts'
|
|
3
|
+
import type { PubSub } from './pubsub.ts'
|
|
4
|
+
import type { GraphQLContext } from './types.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tokens of the values `GraphQLPlugin` supplies to the container (declared in the package
|
|
8
|
+
* manifest). `GraphQLContext` is a request-scoped external: inject it in request-scoped providers
|
|
9
|
+
* or through `Lazy<GraphQLContext>`.
|
|
10
|
+
*/
|
|
11
|
+
export const GraphQLTokens: {
|
|
12
|
+
/** The {@link GraphQLContext} of the current operation (request scope). */
|
|
13
|
+
readonly Context: TypedToken<GraphQLContext>
|
|
14
|
+
/** The application {@link PubSub} (singleton). */
|
|
15
|
+
readonly PubSub: TypedToken<PubSub>
|
|
16
|
+
/** The {@link GraphQLPlugin} instance (singleton). */
|
|
17
|
+
readonly Plugin: TypedToken<GraphQLPlugin>
|
|
18
|
+
} = Object.freeze({
|
|
19
|
+
Context: '@znight/graphql#GraphQLContext',
|
|
20
|
+
PubSub: '@znight/graphql#PubSub',
|
|
21
|
+
Plugin: '@znight/graphql#GraphQLPlugin',
|
|
22
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deferred abort signal for graphql-js (RFC 0005 §8.3, "Cancellation").
|
|
3
|
+
*
|
|
4
|
+
* `HttpContext.signal` is created on first read, and creating it costs an `AbortController` plus a
|
|
5
|
+
* close listener on the connection; graphql-js then adds and removes an abort listener per
|
|
6
|
+
* execution. A query whose resolvers are synchronous cannot be cancelled anyway, so the transport
|
|
7
|
+
* passes this stand-in instead: it records the listener graphql-js registers and starts following
|
|
8
|
+
* `ctx.signal` only when the execution turned asynchronous ({@link DeferredAbort.follow}).
|
|
9
|
+
*
|
|
10
|
+
* It implements the part of `AbortSignal` graphql-js 17 reads (`aborted`, `reason`,
|
|
11
|
+
* `throwIfAborted()`, `addEventListener('abort')`, `removeEventListener('abort')`); the transport
|
|
12
|
+
* tests check that a client leaving during an asynchronous query aborts it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type Listener = () => void
|
|
16
|
+
|
|
17
|
+
/** The stand-in passed as graphql-js `abortSignal` by the HTTP transport (one per request). */
|
|
18
|
+
export class DeferredAbort {
|
|
19
|
+
aborted = false
|
|
20
|
+
reason: unknown = undefined
|
|
21
|
+
/** graphql-js registers one listener per execution; more are kept in {@link #others}. */
|
|
22
|
+
#listener: Listener | undefined = undefined
|
|
23
|
+
#others: Listener[] | undefined = undefined
|
|
24
|
+
|
|
25
|
+
throwIfAborted(): void {
|
|
26
|
+
if (this.aborted) throw this.reason
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
addEventListener(type: string, listener: Listener): void {
|
|
30
|
+
if (type !== 'abort') return
|
|
31
|
+
if (this.#listener === undefined) {
|
|
32
|
+
this.#listener = listener
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
this.#others ??= []
|
|
36
|
+
this.#others.push(listener)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
removeEventListener(type: string, listener: Listener): void {
|
|
40
|
+
if (type !== 'abort') return
|
|
41
|
+
if (this.#listener === listener) {
|
|
42
|
+
this.#listener = undefined
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const others = this.#others
|
|
46
|
+
const index = others === undefined ? -1 : others.indexOf(listener)
|
|
47
|
+
if (index !== -1) others?.splice(index, 1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The execution is asynchronous: from now on, `signal` aborting aborts the execution. Reads the
|
|
52
|
+
* signal only when graphql-js still listens (a pending execution).
|
|
53
|
+
*/
|
|
54
|
+
follow(read: () => AbortSignal): void {
|
|
55
|
+
if (this.#listener === undefined && (this.#others === undefined || this.#others.length === 0)) return
|
|
56
|
+
const signal = read()
|
|
57
|
+
if (signal.aborted) {
|
|
58
|
+
this.#abort(signal.reason)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
signal.addEventListener('abort', () => this.#abort(signal.reason), { once: true })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** This stand-in, typed as the `AbortSignal` graphql-js expects. */
|
|
65
|
+
asSignal(): AbortSignal {
|
|
66
|
+
return this as unknown as AbortSignal
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#abort(reason: unknown): void {
|
|
70
|
+
if (this.aborted) return
|
|
71
|
+
this.aborted = true
|
|
72
|
+
this.reason = reason
|
|
73
|
+
const listener = this.#listener
|
|
74
|
+
const others = this.#others
|
|
75
|
+
this.#listener = undefined
|
|
76
|
+
this.#others = undefined
|
|
77
|
+
listener?.()
|
|
78
|
+
if (others !== undefined) for (const other of others) other()
|
|
79
|
+
}
|
|
80
|
+
}
|