@znight/graphql 0.2.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.
Files changed (42) hide show
  1. package/README.md +288 -5
  2. package/dist/compiler.d.mts +24 -3
  3. package/dist/compiler.mjs +1876 -109
  4. package/dist/compiler.mjs.map +1 -1
  5. package/dist/index.d.mts +49 -8
  6. package/dist/index.mjs +10 -4
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/names-BlzDM5cF.mjs.map +1 -1
  9. package/dist/{plugin-BdBxr5NQ.mjs → plugin-CR_wVfRU.mjs} +247 -24
  10. package/dist/plugin-CR_wVfRU.mjs.map +1 -0
  11. package/dist/testing.d.mts +1 -1
  12. package/dist/testing.mjs +1 -1
  13. package/dist/{types-CgbV9LjD.d.mts → types-DQ713UgI.d.mts} +34 -2
  14. package/package.json +8 -8
  15. package/src/compiler/analyze.ts +129 -10
  16. package/src/compiler/builder.ts +6 -85
  17. package/src/compiler/checks.ts +105 -0
  18. package/src/compiler/conformance.ts +1068 -0
  19. package/src/compiler/constants.ts +6 -0
  20. package/src/compiler/data.ts +10 -0
  21. package/src/compiler/generate.ts +41 -27
  22. package/src/compiler/graphql-js.ts +42 -0
  23. package/src/compiler/index.ts +8 -3
  24. package/src/compiler/locate.ts +39 -1
  25. package/src/compiler/model.ts +16 -1
  26. package/src/compiler/options.ts +39 -8
  27. package/src/compiler/schema-first.ts +595 -0
  28. package/src/compiler/sdl-source.ts +529 -0
  29. package/src/compiler/sdl-view.ts +3 -1
  30. package/src/compiler/suggest.ts +39 -0
  31. package/src/decorators.ts +9 -3
  32. package/src/index.ts +5 -0
  33. package/src/metadata.ts +28 -1
  34. package/src/options.ts +23 -1
  35. package/src/pipeline.ts +66 -5
  36. package/src/plugin.ts +27 -5
  37. package/src/results.ts +206 -0
  38. package/src/scalars.ts +10 -3
  39. package/src/schema.ts +90 -12
  40. package/src/sdl.ts +17 -2
  41. package/src/types.ts +35 -0
  42. package/dist/plugin-BdBxr5NQ.mjs.map +0 -1
package/src/metadata.ts CHANGED
@@ -43,6 +43,7 @@ export interface GraphQLRootTypes {
43
43
  /** A named type of the schema. */
44
44
  export type GraphQLTypeDefinition =
45
45
  | GraphQLObjectTypeDefinition
46
+ | GraphQLInterfaceTypeDefinition
46
47
  | GraphQLInputTypeDefinition
47
48
  | GraphQLEnumTypeDefinition
48
49
  | GraphQLUnionTypeDefinition
@@ -54,10 +55,27 @@ export interface GraphQLObjectTypeDefinition {
54
55
  readonly kind: 'object'
55
56
  readonly name: string
56
57
  readonly description?: string
58
+ /** Interfaces the type implements (schema-first, RFC 0007), in declaration order. Absent: none. */
59
+ readonly interfaces?: readonly string[]
57
60
  /** Declaration order (base members first), then field resolvers by resolver token and source order. */
58
61
  readonly fields: readonly GraphQLFieldDefinition[]
59
62
  }
60
63
 
64
+ /**
65
+ * An interface type (schema-first, RFC 0007; code-first emits none). Its fields have no
66
+ * `resolver`: graphql-js resolves the fields of the concrete object type.
67
+ */
68
+ export interface GraphQLInterfaceTypeDefinition {
69
+ readonly kind: 'interface'
70
+ readonly name: string
71
+ readonly description?: string
72
+ /** Interfaces the interface implements, in declaration order. Absent: none. */
73
+ readonly interfaces?: readonly string[]
74
+ readonly fields: readonly GraphQLFieldDefinition[]
75
+ /** How the runtime tells the implementing object types apart (`resolveType`). */
76
+ readonly discriminator: GraphQLDiscriminator
77
+ }
78
+
61
79
  /** An input object type. */
62
80
  export interface GraphQLInputTypeDefinition {
63
81
  readonly kind: 'input'
@@ -97,6 +115,15 @@ export interface GraphQLBuiltinScalarDefinition {
97
115
  export interface GraphQLCustomScalarDefinition {
98
116
  readonly kind: 'scalar'
99
117
  readonly name: string
118
+ /** Wins over the description of the definition (schema-first: the SDL's). */
119
+ readonly description?: string
120
+ /** Wins over the `specifiedByURL` of the definition (schema-first: the SDL's `@specifiedBy`). */
121
+ readonly specifiedByURL?: string
122
+ /**
123
+ * Schema-first (RFC 0007): `description` and `specifiedByURL` are the SDL's and replace the
124
+ * definition's, also when absent, so that the served schema is the SDL.
125
+ */
126
+ readonly sdl?: true
100
127
  /** `ScalarDefinition<T>` of any `T` (its methods are bivariant), or a graphql-js scalar named `name`. */
101
128
  readonly scalar: ScalarDefinition<unknown> | GraphQLScalarType
102
129
  }
@@ -141,7 +168,7 @@ export interface GraphQLEnumValueDefinition {
141
168
  readonly deprecationReason?: string
142
169
  }
143
170
 
144
- /** How the runtime tells the members of a union apart (`resolveType`). */
171
+ /** How the runtime tells the members of a union or the implementations of an interface apart (`resolveType`). */
145
172
  export type GraphQLDiscriminator =
146
173
  | {
147
174
  readonly by: 'property'
package/src/options.ts CHANGED
@@ -3,7 +3,15 @@
3
3
  */
4
4
  import type { ByteSize, GuardRef, RouteMetaEntry } from '@znight/http'
5
5
  import type { SubscribeOptions } from './pubsub.ts'
6
- import type { GraphQLErrorMapper, GraphQLErrorMapperClass, GraphQLGuard, GraphQLGuardClass } from './types.ts'
6
+ import type {
7
+ GraphQLErrorMapper,
8
+ GraphQLErrorMapperClass,
9
+ GraphQLGuard,
10
+ GraphQLGuardClass,
11
+ GraphQLResultMapper,
12
+ GraphQLResultMapperClass,
13
+ GraphQLResultTypes,
14
+ } from './types.ts'
7
15
 
8
16
  import { ErrorCode, ZnightError } from '@znight/core'
9
17
  import { parseByteSize } from '@znight/http'
@@ -23,6 +31,12 @@ export type GraphQLGuardRef = GraphQLGuardClass | GraphQLGuard
23
31
  /** An error mapper: a provider class or an instance. */
24
32
  export type GraphQLErrorMapperRef = GraphQLErrorMapperClass | GraphQLErrorMapper
25
33
 
34
+ /**
35
+ * A result mapper: a provider class with `static types`, or an instance (its `types` property, or the
36
+ * `static types` of its class).
37
+ */
38
+ export type GraphQLResultMapperRef = GraphQLResultMapperClass | (GraphQLResultMapper & Partial<GraphQLResultTypes>)
39
+
26
40
  /** Options of the GraphQL-over-HTTP endpoint. */
27
41
  export interface GraphQLHttpOptions {
28
42
  /** Route metadata of the endpoint (`[Public.meta()]`), seen by HTTP guards and the route decision check. */
@@ -72,6 +86,11 @@ export interface GraphQLPluginOptions {
72
86
  readonly guards?: readonly GraphQLGuardRef[]
73
87
  /** Error mappers (classes or instances), tried in order. */
74
88
  readonly errorMappers?: readonly GraphQLErrorMapperRef[]
89
+ /**
90
+ * Result mappers (classes or instances), tried in order: errors as data (RFC 0008), on the fields
91
+ * whose union holds one of their `types`.
92
+ */
93
+ readonly resultMappers?: readonly GraphQLResultMapperRef[]
75
94
  /** Unmasked messages for unexpected errors (development). Default `false`. */
76
95
  readonly exposeErrors?: boolean
77
96
  /** Introspection; off, suggestions are hidden too. Default `NODE_ENV !== 'production'`. */
@@ -98,6 +117,7 @@ export interface ResolvedGraphQLOptions {
98
117
  readonly http: Required<GraphQLHttpOptions> | false
99
118
  readonly guards: readonly GraphQLGuardRef[]
100
119
  readonly errorMappers: readonly GraphQLErrorMapperRef[]
120
+ readonly resultMappers: readonly GraphQLResultMapperRef[]
101
121
  readonly exposeErrors: boolean
102
122
  readonly introspection: boolean
103
123
  readonly graphiql: boolean
@@ -126,6 +146,7 @@ const KEYS: ReadonlySet<string> = new Set([
126
146
  'http',
127
147
  'guards',
128
148
  'errorMappers',
149
+ 'resultMappers',
129
150
  'exposeErrors',
130
151
  'introspection',
131
152
  'graphiql',
@@ -291,6 +312,7 @@ export function resolveGraphQLOptions(
291
312
  http,
292
313
  guards: refs<GraphQLGuardRef>('guards', options['guards'], 'canResolve'),
293
314
  errorMappers: refs<GraphQLErrorMapperRef>('errorMappers', options['errorMappers'], 'map'),
315
+ resultMappers: refs<GraphQLResultMapperRef>('resultMappers', options['resultMappers'], 'toResult'),
294
316
  exposeErrors: boolean('exposeErrors', options['exposeErrors'], false),
295
317
  introspection: boolean('introspection', options['introspection'], nodeEnv !== 'production'),
296
318
  graphiql: boolean('graphiql', options['graphiql'], nodeEnv === 'development'),
package/src/pipeline.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  *
12
12
  * A field without guards nor normaliser calls `invoke` from the closure graphql-js needs anyway:
13
13
  * no extra wrapper, no promise, no `await`. Plain property fields keep graphql-js' default resolver.
14
+ * A field that carries results (RFC 0008, `results.ts`) gets one wrapper around the whole pipeline,
15
+ * catching its errors to turn them into values; no other field does.
14
16
  */
15
17
  import type { Container, Token } from '@znight/core'
16
18
  import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql'
@@ -23,6 +25,7 @@ import type {
23
25
  GraphQLObjectTypeDefinition,
24
26
  GraphQLResolverBinding,
25
27
  } from './metadata.ts'
28
+ import type { Recover } from './results.ts'
26
29
  import type { FieldFunctions, FieldFunctionsFactory } from './schema.ts'
27
30
  import type { GraphQLTypeRef } from './type-ref.ts'
28
31
  import type { GraphQLArgs, GraphQLContext, GraphQLGuard, ResolverContext } from './types.ts'
@@ -44,6 +47,8 @@ export interface PipelineOptions {
44
47
  readonly resolver: (binding: GraphQLResolverBinding, coordinate: string) => Resolvable<object>
45
48
  /** Input object types by name (null handling of nested input values). */
46
49
  readonly inputs: ReadonlyMap<string, GraphQLInputTypeDefinition>
50
+ /** The {@link Recover} function of a field that carries results (RFC 0008), `undefined` for the others. */
51
+ readonly results?: (field: GraphQLFieldDefinition, coordinate: string) => Recover | undefined
47
52
  }
48
53
 
49
54
  /** The generated invoker of a binding. */
@@ -80,6 +85,11 @@ function fieldFunctions(
80
85
  ...binding.guards.map(token => options.guard(token, coordinate)),
81
86
  ]
82
87
  const normalize = field.args === undefined ? undefined : normalizers.args(field.args)
88
+ const recover = options.results?.(field, coordinate)
89
+ const route =
90
+ guards.length === 0 && recover === undefined ? undefined : Object.freeze({ meta: binding.meta, coordinate })
91
+ const parentType = parent.name
92
+ const fieldName = field.name
83
93
 
84
94
  let resolve: GraphQLFieldResolver<unknown, unknown>
85
95
  if (guards.length === 0) {
@@ -104,9 +114,6 @@ function fieldFunctions(
104
114
  }
105
115
  }
106
116
  } else {
107
- const route = Object.freeze({ meta: binding.meta, coordinate })
108
- const parentType = parent.name
109
- const fieldName = field.name
110
117
  const forbidden = (): never => {
111
118
  throw new GraphQLException(`Not allowed to resolve ${coordinate}`, { code: GraphQLErrorCode.Forbidden })
112
119
  }
@@ -116,7 +123,7 @@ function fieldFunctions(
116
123
  const allowed = runGuards(
117
124
  container,
118
125
  guards,
119
- new FieldContext(route, operation, parentType, fieldName, args, p, i),
126
+ new FieldContext(route as ResolverContext['route'], operation, parentType, fieldName, args, p, i),
120
127
  )
121
128
  // §8.5: guards (they see the coerced arguments), then null handling, then invoke
122
129
  const call = (): unknown => {
@@ -128,7 +135,11 @@ function fieldFunctions(
128
135
  return allowed.then(ok => (ok ? call() : forbidden()))
129
136
  }
130
137
  }
131
- if (binding.operation === 'subscription') {
138
+ const subscription = binding.operation === 'subscription'
139
+ if (recover !== undefined) {
140
+ resolve = withResults(resolve, recover, route as ResolverContext['route'], parentType, fieldName, subscription)
141
+ }
142
+ if (subscription) {
132
143
  // the method gives the source event stream; each event is the field value
133
144
  return { subscribe: resolve, resolve: identity }
134
145
  }
@@ -139,6 +150,56 @@ function identity(payload: unknown): unknown {
139
150
  return payload
140
151
  }
141
152
 
153
+ /**
154
+ * The resolve function of a field that carries results (RFC 0008): errors of the whole pipeline
155
+ * (guards, null handling, `invoke`), thrown or rejected, go through `recover`. Synchronous results
156
+ * stay synchronous; a promise gets one rejection handler. For a subscription, a value is a source
157
+ * stream of that single event.
158
+ */
159
+ function withResults(
160
+ resolve: GraphQLFieldResolver<unknown, unknown>,
161
+ recover: Recover,
162
+ route: ResolverContext['route'],
163
+ parentType: string,
164
+ fieldName: string,
165
+ subscription: boolean,
166
+ ): GraphQLFieldResolver<unknown, unknown> {
167
+ const fail = (error: unknown, p: unknown, a: unknown, c: unknown, i: GraphQLResolveInfo): unknown => {
168
+ const value = recover(
169
+ error,
170
+ new FieldContext(route, c as OperationContext, parentType, fieldName, a as GraphQLArgs, p, i),
171
+ )
172
+ return subscription ? once(value) : value
173
+ }
174
+ // captures nothing per call: the rejection handler is created in recoverAsync, on the promise path only
175
+ return (p, a, c, i) => {
176
+ let result: unknown
177
+ try {
178
+ result = resolve(p, a, c, i)
179
+ } catch (error) {
180
+ return fail(error, p, a, c, i)
181
+ }
182
+ return isThenable(result) ? recoverAsync(result, fail, p, a, c, i) : result
183
+ }
184
+ }
185
+
186
+ function recoverAsync(
187
+ result: PromiseLike<unknown>,
188
+ fail: (error: unknown, p: unknown, a: unknown, c: unknown, i: GraphQLResolveInfo) => unknown,
189
+ p: unknown,
190
+ a: unknown,
191
+ c: unknown,
192
+ i: GraphQLResolveInfo,
193
+ ): Promise<unknown> {
194
+ return Promise.resolve(result).then(undefined, error => fail(error, p, a, c, i))
195
+ }
196
+
197
+ /** A source event stream of one event. */
198
+ // biome-ignore lint/suspicious/useAwait: an async generator is the shortest AsyncIterable of one value
199
+ async function* once(value: unknown): AsyncGenerator<unknown> {
200
+ yield value
201
+ }
202
+
142
203
  /** Runs guards in order, synchronously until one returns a promise. */
143
204
  function runGuards(
144
205
  container: Container,
package/src/plugin.ts CHANGED
@@ -22,7 +22,7 @@ import type { ExecuteOptions, GraphQLRequest, GraphQLResultStream } from './exec
22
22
  import type { Resolvable } from './format.ts'
23
23
  import type { GraphQLInputTypeDefinition, GraphQLMetadata, GraphQLResolverBinding } from './metadata.ts'
24
24
  import type { GraphQLPluginOptions, ResolvedGraphQLOptions } from './options.ts'
25
- import type { GraphQLErrorMapper, GraphQLGuard } from './types.ts'
25
+ import type { GraphQLErrorMapper, GraphQLGuard, GraphQLResultMapper } from './types.ts'
26
26
 
27
27
  import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/core'
28
28
  import { printSchema } from 'graphql'
@@ -35,7 +35,8 @@ import { MemoryPubSub } from './memory-pubsub.ts'
35
35
  import { invalidOption, resolveGraphQLOptions } from './options.ts'
36
36
  import { globalGuardsApply, pipelineFactory } from './pipeline.ts'
37
37
  import { PubSub } from './pubsub.ts'
38
- import { buildGraphQLSchema, graphqlSection } from './schema.ts'
38
+ import { checkResultTypes, declaredTypes, ResultMappers } from './results.ts'
39
+ import { buildGraphQLSchema, checkGraphQLSection, graphqlSection } from './schema.ts'
39
40
  import { GraphQLTokens } from './tokens.ts'
40
41
  import { checkSseOptions, HttpTransport } from './transport/http.ts'
41
42
 
@@ -120,7 +121,7 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
120
121
  }
121
122
 
122
123
  /**
123
- * Providers `TestApp` creates at boot: the classes of the `guards` and `errorMappers` options, and
124
+ * Providers `TestApp` creates at boot: the classes of the `guards`, `errorMappers` and `resultMappers` options, and
124
125
  * the resolvers, guards and loaders of the metadata.
125
126
  */
126
127
  initRoots(context?: PluginRootsContext): readonly ProviderKey[] {
@@ -128,7 +129,7 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
128
129
  try {
129
130
  const options: unknown = this.#options
130
131
  if (options !== null && typeof options === 'object') {
131
- for (const key of ['guards', 'errorMappers'] as const) {
132
+ for (const key of ['guards', 'errorMappers', 'resultMappers'] as const) {
132
133
  const refs: unknown = (options as GraphQLPluginOptions)[key]
133
134
  if (Array.isArray(refs)) for (const ref of refs) if (typeof ref === 'function') roots.add(ref as ProviderKey)
134
135
  }
@@ -349,6 +350,10 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
349
350
  const providers = new Providers(container, metadata)
350
351
  const globalGuards = options.guards.map(ref => providers.ref<GraphQLGuard>(ref, 'guard', 'canResolve'))
351
352
  const mappers = options.errorMappers.map(ref => providers.ref<GraphQLErrorMapper>(ref, 'error mapper', 'map'))
353
+ const resultMappers = options.resultMappers.map(ref => ({
354
+ ...providers.ref<GraphQLResultMapper>(ref, 'result mapper', 'toResult'),
355
+ types: declaredTypes(ref),
356
+ }))
352
357
  const inputs = new Map<string, GraphQLInputTypeDefinition>()
353
358
  const fields: CheckedField[] = []
354
359
  const types = Array.isArray(section?.types) ? section.types : []
@@ -371,6 +376,18 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
371
376
  })
372
377
  }
373
378
  }
379
+ // a malformed section is reported by buildGraphQLSchema, not as types missing from it
380
+ if (resultMappers.length > 0 && checkGraphQLSection(section).length === 0) checkResultTypes(resultMappers, types)
381
+ const results = new ResultMappers(
382
+ container,
383
+ ctx.logger,
384
+ resultMappers.map(mapper => ({
385
+ resolvable: mapper.resolvable,
386
+ name: mapper.name,
387
+ types: mapper.types as readonly string[],
388
+ })),
389
+ types,
390
+ )
374
391
  const schema = buildGraphQLSchema(
375
392
  section,
376
393
  pipelineFactory({
@@ -380,6 +397,7 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
380
397
  resolver: (binding, coordinate) =>
381
398
  providers.token<object>(binding.token, `resolver of ${coordinate}`, binding.method),
382
399
  inputs,
400
+ results: (field, coordinate) => results.recover(field, coordinate),
383
401
  }),
384
402
  )
385
403
  checkFieldDecisions(fields, section.groups, options.enforcement, ctx.logger)
@@ -406,7 +424,11 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
406
424
  documentCache: options.documentCache,
407
425
  maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
408
426
  })
409
- ctx.logger.debug('GraphQL schema built', { types: types.length, schemaHash: section.schemaHash })
427
+ ctx.logger.debug('GraphQL schema built', {
428
+ types: types.length,
429
+ schemaHash: section.schemaHash,
430
+ ...(results.fields === 0 ? {} : { resultFields: results.fields }),
431
+ })
410
432
  return { schema, section, executor }
411
433
  }
412
434
  }
package/src/results.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Errors as data (RFC 0008): result mappers turn the error of a field into a value of its union.
3
+ *
4
+ * Decided at boot, per resolver-backed field: a field carries results when its type, without its
5
+ * non-null wrapper, is a union (not a list) holding an object type declared by a result mapper
6
+ * (`static types`). Only those fields get a wrapper (`pipeline.ts`); the others keep their resolve
7
+ * function. On an error, the applicable mappers run in order; the first value resolving to a member
8
+ * of the union is the field's value, otherwise the error is thrown back, to `errors[]`.
9
+ */
10
+ import type { Container, CoreLogger } from '@znight/core'
11
+ import type { GraphQLResolveInfo } from 'graphql'
12
+ import type { Resolvable } from './format.ts'
13
+ import type { GraphQLFieldDefinition, GraphQLTypeDefinition } from './metadata.ts'
14
+ import type { GraphQLResultMapper, ResolverContext } from './types.ts'
15
+
16
+ import { ErrorCode, ZnightError } from '@znight/core'
17
+ import { isThenable } from '@znight/http'
18
+
19
+ import { resolveIn } from './format.ts'
20
+ import { typeResolver } from './schema.ts'
21
+ import { parseTypeRef } from './type-ref.ts'
22
+
23
+ /** A result mapper resolved at boot. */
24
+ export interface ResultMapperEntry {
25
+ readonly resolvable: Resolvable<GraphQLResultMapper>
26
+ /** For logs: the class name. */
27
+ readonly name: string
28
+ /** Declared object types, checked by {@link checkResultTypes}. */
29
+ readonly types: readonly string[]
30
+ }
31
+
32
+ /** The value of a field for an error, or the error thrown back. */
33
+ export type Recover = (error: unknown, ctx: ResolverContext) => unknown
34
+
35
+ /** The object types a result mapper ref declares: `static types` of a class; for an instance, its `types` property or its class's. */
36
+ export function declaredTypes(ref: unknown): unknown {
37
+ try {
38
+ if (typeof ref === 'function') return (ref as { types?: unknown }).types
39
+ const own = (ref as { types?: unknown }).types
40
+ if (own !== undefined) return own
41
+ return ((ref as { constructor?: unknown }).constructor as { types?: unknown } | undefined)?.types
42
+ } catch {
43
+ // a throwing getter: reported as no types
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Checks the declared types of the result mappers against the schema's types: a non-empty array of
50
+ * names of object types. Every problem in one ZNIGHT066 error.
51
+ */
52
+ export function checkResultTypes(
53
+ mappers: readonly { readonly name: string; readonly types: unknown }[],
54
+ types: readonly GraphQLTypeDefinition[],
55
+ ): void {
56
+ if (mappers.length === 0) return
57
+ const kinds = new Map(types.map(type => [type?.name, type?.kind]))
58
+ const problems: string[] = []
59
+ for (const mapper of mappers) {
60
+ const declared = mapper.types
61
+ if (!Array.isArray(declared) || declared.length === 0) {
62
+ problems.push(
63
+ `the result mapper ${mapper.name} declares no types (static readonly types = [...object type names])`,
64
+ )
65
+ continue
66
+ }
67
+ for (const name of declared as unknown[]) {
68
+ if (typeof name !== 'string' || kinds.get(name) !== 'object') {
69
+ problems.push(
70
+ `the result mapper ${mapper.name} declares ${typeof name === 'string' ? name : String(name)}, which is not an object type of the schema`,
71
+ )
72
+ }
73
+ }
74
+ }
75
+ if (problems.length === 0) return
76
+ throw new ZnightError(ErrorCode.InvalidGraphQLSetup, `GraphQLPlugin: ${problems.join('; ')}`, {
77
+ hints: [
78
+ "A result mapper declares the object types its values may have: static readonly types = ['NotFoundRejection'] (RFC 0008)",
79
+ ],
80
+ })
81
+ }
82
+
83
+ /** The union of a field type and its members, by union name. */
84
+ interface Union {
85
+ readonly name: string
86
+ readonly members: ReadonlySet<string>
87
+ readonly typeOf: (value: unknown) => string | undefined
88
+ }
89
+
90
+ /** Decides which fields carry results, and builds their {@link Recover} functions. */
91
+ export class ResultMappers {
92
+ readonly #container: Container
93
+ readonly #logger: CoreLogger
94
+ readonly #mappers: readonly ResultMapperEntry[]
95
+ readonly #types: readonly GraphQLTypeDefinition[]
96
+ /** Built on the first field, once the schema construction checked the section's structure. */
97
+ #index: { readonly objects: ReadonlySet<string>; readonly unions: ReadonlyMap<string, Union> } | undefined
98
+ #fields = 0
99
+
100
+ constructor(
101
+ container: Container,
102
+ logger: CoreLogger,
103
+ mappers: readonly ResultMapperEntry[],
104
+ types: readonly GraphQLTypeDefinition[],
105
+ ) {
106
+ this.#container = container
107
+ this.#logger = logger
108
+ this.#mappers = mappers
109
+ this.#types = types
110
+ }
111
+
112
+ /** Fields that carry results so far. */
113
+ get fields(): number {
114
+ return this.#fields
115
+ }
116
+
117
+ /** The {@link Recover} function of `field`, `undefined` when it carries no result. */
118
+ recover(field: GraphQLFieldDefinition, coordinate: string): Recover | undefined {
119
+ if (this.#mappers.length === 0) return undefined
120
+ const ref = parseTypeRef(field.type)
121
+ if (ref?.kind !== 'named') return undefined
122
+ this.#index ??= index(this.#types)
123
+ const objects = this.#index.objects
124
+ const union = this.#index.unions.get(ref.name)
125
+ if (union === undefined) return undefined
126
+ const applicable = this.#mappers.filter(mapper => mapper.types.some(type => union.members.has(type)))
127
+ if (applicable.length === 0) return undefined
128
+ this.#fields++
129
+ const container = this.#container
130
+ const logger = this.#logger
131
+ return (error, ctx) => {
132
+ for (const mapper of applicable) {
133
+ let value: unknown
134
+ let type: string | undefined
135
+ try {
136
+ value = resolveIn(container, mapper.resolvable).toResult(error, ctx)
137
+ if (value === undefined || value === null) continue
138
+ if (isThenable(value)) {
139
+ // not awaited: the field cannot wait for a mapper, and its rejection must not go unhandled
140
+ Promise.resolve(value).catch(noop)
141
+ logger.error(
142
+ `GraphQLPlugin: the result mapper ${mapper.name} returned a promise: toResult() must be synchronous`,
143
+ details(ctx, coordinate, error),
144
+ )
145
+ continue
146
+ }
147
+ // the discriminator reads the value (a getter, a class check): a failure is the mapper's
148
+ type = union.typeOf(value)
149
+ } catch (failure) {
150
+ logger.error(`GraphQLPlugin: the result mapper ${mapper.name} failed`, details(ctx, coordinate, failure))
151
+ continue
152
+ }
153
+ if (type !== undefined && union.members.has(type)) {
154
+ // the business path: no request id (it may be minted on first read) nor path built for a debug line
155
+ logger.debug(`GraphQL: error resolved as ${type} in ${coordinate}`, { coordinate, type })
156
+ return value
157
+ }
158
+ if (type !== undefined && objects.has(type)) {
159
+ logger.debug(
160
+ `GraphQLPlugin: the result mapper ${mapper.name} returned a value of ${type}, not a member of ${union.name} (${coordinate}): skipped`,
161
+ details(ctx, coordinate, error),
162
+ )
163
+ } else {
164
+ logger.warn(
165
+ `GraphQLPlugin: the result mapper ${mapper.name} returned a value of no type of the schema (${coordinate}): skipped`,
166
+ details(ctx, coordinate, error),
167
+ )
168
+ }
169
+ }
170
+ throw error
171
+ }
172
+ }
173
+ }
174
+
175
+ function index(types: readonly GraphQLTypeDefinition[]): {
176
+ readonly objects: ReadonlySet<string>
177
+ readonly unions: ReadonlyMap<string, Union>
178
+ } {
179
+ const objects = new Set<string>()
180
+ const unions = new Map<string, Union>()
181
+ for (const type of types) {
182
+ if (type.kind === 'object') objects.add(type.name)
183
+ else if (type.kind === 'union') {
184
+ unions.set(type.name, {
185
+ name: type.name,
186
+ members: new Set(type.members),
187
+ typeOf: typeResolver(type.discriminator),
188
+ })
189
+ }
190
+ }
191
+ return { objects, unions }
192
+ }
193
+
194
+ function noop(): void {}
195
+
196
+ function details(ctx: ResolverContext, coordinate: string, err: unknown): Record<string, unknown> {
197
+ return { err, requestId: ctx.operation.requestId, coordinate, path: pathOf(ctx.info).join('.') }
198
+ }
199
+
200
+ function pathOf(info: GraphQLResolveInfo): (string | number)[] {
201
+ const path: (string | number)[] = []
202
+ for (let step: GraphQLResolveInfo['path'] | undefined = info.path; step !== undefined; step = step.prev) {
203
+ path.push(step.key)
204
+ }
205
+ return path.reverse()
206
+ }
package/src/scalars.ts CHANGED
@@ -115,12 +115,19 @@ export function builtinScalar(name: string): GraphQLScalarType | undefined {
115
115
  * `parseValue` → `coerceInputValue`, `parseLiteral` → `coerceInputLiteral` (default: `parseValue`
116
116
  * of the literal's JSON value). The definition's methods are called on the definition.
117
117
  */
118
- export function definitionScalar(name: string, definition: ScalarDefinition<unknown>): GraphQLScalarType {
118
+ export function definitionScalar(
119
+ name: string,
120
+ definition: ScalarDefinition<unknown>,
121
+ overrides: { readonly description?: string; readonly specifiedByURL?: string } = {},
122
+ ): GraphQLScalarType {
119
123
  const parseLiteral = definition.parseLiteral
124
+ // a key present in `overrides` wins, even undefined (schema-first: the SDL is the truth)
125
+ const description = 'description' in overrides ? overrides.description : definition.description
126
+ const specifiedByURL = 'specifiedByURL' in overrides ? overrides.specifiedByURL : definition.specifiedByURL
120
127
  return new GraphQLScalarType({
121
128
  name,
122
- ...(definition.description === undefined ? {} : { description: definition.description }),
123
- ...(definition.specifiedByURL === undefined ? {} : { specifiedByURL: definition.specifiedByURL }),
129
+ ...(description === undefined ? {} : { description }),
130
+ ...(specifiedByURL === undefined ? {} : { specifiedByURL }),
124
131
  coerceOutputValue: (value: unknown) => definition.serialize(value),
125
132
  coerceInputValue: (value: unknown) => definition.parseValue(value),
126
133
  coerceInputLiteral: