@znight/graphql 0.0.0-stage → 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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1062 -2
  3. package/dist/compiler.d.mts +93 -0
  4. package/dist/compiler.mjs +2832 -0
  5. package/dist/compiler.mjs.map +1 -0
  6. package/dist/index.d.mts +542 -0
  7. package/dist/index.mjs +128 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/names-BlzDM5cF.mjs +111 -0
  10. package/dist/names-BlzDM5cF.mjs.map +1 -0
  11. package/dist/plugin-BdBxr5NQ.mjs +3222 -0
  12. package/dist/plugin-BdBxr5NQ.mjs.map +1 -0
  13. package/dist/testing.d.mts +101 -0
  14. package/dist/testing.mjs +306 -0
  15. package/dist/testing.mjs.map +1 -0
  16. package/dist/types-CgbV9LjD.d.mts +234 -0
  17. package/package.json +95 -4
  18. package/src/compiler/analyze.ts +608 -0
  19. package/src/compiler/builder.ts +1640 -0
  20. package/src/compiler/constants.ts +74 -0
  21. package/src/compiler/data.ts +73 -0
  22. package/src/compiler/defaults.ts +92 -0
  23. package/src/compiler/discriminators.ts +53 -0
  24. package/src/compiler/docs.ts +55 -0
  25. package/src/compiler/generate.ts +405 -0
  26. package/src/compiler/ids.ts +128 -0
  27. package/src/compiler/index.ts +93 -0
  28. package/src/compiler/locate.ts +44 -0
  29. package/src/compiler/model.ts +122 -0
  30. package/src/compiler/options.ts +54 -0
  31. package/src/compiler/scalars.ts +138 -0
  32. package/src/compiler/sdl-view.ts +59 -0
  33. package/src/context.ts +181 -0
  34. package/src/decisions.ts +169 -0
  35. package/src/decorators.ts +196 -0
  36. package/src/errors.ts +89 -0
  37. package/src/execution.ts +1064 -0
  38. package/src/format.ts +285 -0
  39. package/src/index.ts +84 -0
  40. package/src/limits.ts +177 -0
  41. package/src/loaders.ts +149 -0
  42. package/src/memory-pubsub.ts +133 -0
  43. package/src/metadata.ts +195 -0
  44. package/src/names.ts +47 -0
  45. package/src/options.ts +309 -0
  46. package/src/pipeline.ts +277 -0
  47. package/src/plugin.ts +557 -0
  48. package/src/pubsub.ts +36 -0
  49. package/src/scalars.ts +131 -0
  50. package/src/schema-hash.ts +18 -0
  51. package/src/schema.ts +557 -0
  52. package/src/sdl.ts +515 -0
  53. package/src/testing.ts +454 -0
  54. package/src/tokens.ts +22 -0
  55. package/src/transport/abort.ts +80 -0
  56. package/src/transport/accept.ts +155 -0
  57. package/src/transport/graphiql.ts +106 -0
  58. package/src/transport/http.ts +373 -0
  59. package/src/transport/sse.ts +45 -0
  60. package/src/type-ref.ts +89 -0
  61. package/src/types.ts +190 -0
  62. package/src/validate-args.ts +48 -0
@@ -0,0 +1,1640 @@
1
+ /**
2
+ * The schema model (RFC 0005 §6.2–§6.6): starting from the root fields, every reachable TypeScript
3
+ * type is read through the type shapes API (`ctx.types.shape()`, members `'instance'`, aliases kept
4
+ * as named shapes) and mapped to a GraphQL type, row by row of the §6.2 table. Names follow §6.4,
5
+ * objects, inputs and arguments §6.3; every problem is a diagnostic with a location, a hint and the
6
+ * path from the root field ("reached from Query.book → Book").
7
+ *
8
+ * Built once per compile (`validate`), read by `generate`. Shapes are cached by the compiler per
9
+ * options for the whole compile, so the walk only pays for what it visits once.
10
+ */
11
+ import type {
12
+ ClassInfo,
13
+ Diagnostic,
14
+ Location,
15
+ MethodInfo,
16
+ NamedShape,
17
+ ParameterInfo,
18
+ Shape,
19
+ ShapeOptions,
20
+ ShapeProperty,
21
+ ShapeResult,
22
+ ValidateContext,
23
+ } from '@znight/compiler'
24
+ import type { MetaEntry } from '@znight/http/compiler'
25
+ import type { GraphQLTypeRef } from '../type-ref.ts'
26
+ import type { FieldData, GraphQLData, ResolverData } from './data.ts'
27
+ import type {
28
+ ArgModel,
29
+ DiscriminantProperty,
30
+ DiscriminatorModel,
31
+ EnumValueModel,
32
+ FieldModel,
33
+ GraphQLModel,
34
+ InputModel,
35
+ ObjectModel,
36
+ ScalarModel,
37
+ TypeModel,
38
+ UnionModel,
39
+ } from './model.ts'
40
+ import type { ResolvedGraphQLCompilerOptions } from './options.ts'
41
+
42
+ import { requiredRouteGroups } from '@znight/http/compiler'
43
+
44
+ import { isGraphQLName, RESERVED_ENUM_VALUES, ROOT_TYPE_NAMES, SPECIFIED_SCALARS } from '../names.ts'
45
+ import { schemaHash } from '../schema-hash.ts'
46
+ import { printSdl } from '../sdl.ts'
47
+ import { printTypeRef } from '../type-ref.ts'
48
+ import { ASYNC_ITERABLES, BASE_KEEP, Codes, compareStrings, OUTPUT_ITERABLES } from './constants.ts'
49
+ import { externalValue } from './defaults.ts'
50
+ import { discriminatorOf } from './discriminators.ts'
51
+ import { defaultTagValue, deprecationOf, descriptionOf, isHidden, paramDescription, tagOf } from './docs.ts'
52
+ import { GraphQLIds, SCALAR_ALIASES } from './ids.ts'
53
+ import { annotationLocation } from './locate.ts'
54
+ import { readScalars, type ScalarDeclaration } from './scalars.ts'
55
+ import { sdlType } from './sdl-view.ts'
56
+
57
+ type Context = ValidateContext<GraphQLData>
58
+ type Position = 'input' | 'output'
59
+ type Operation = 'query' | 'mutation' | 'subscription'
60
+
61
+ /** Where a type is used: what diagnostics say and where they point. */
62
+ interface Use {
63
+ readonly position: Position
64
+ /** `Book.cover`, `Query.book`, `Query.books(limit:)`. */
65
+ readonly coordinate: string
66
+ /** Prefix of the trail of the types reached from here (`Query.book → Book.cover`). */
67
+ readonly trail: string
68
+ /** The trail of the owner of the coordinate (`Query.book → Book`), for "reached from". */
69
+ readonly reached: string | undefined
70
+ /** Location of the root field the walk started from. */
71
+ readonly root: Location | undefined
72
+ /** Where the type is written; called only when a diagnostic is reported. */
73
+ readonly locate: () => Location | undefined
74
+ }
75
+
76
+ /** A converted type: the reference (non-null when nothing made it nullable) and whether TS accepts `null`. */
77
+ interface Converted {
78
+ readonly ref: GraphQLTypeRef
79
+ readonly acceptsNull: boolean
80
+ }
81
+
82
+ /** A converted inner type: its reference and whether the type itself admits `null` (`JSONValue`). */
83
+ interface Inner {
84
+ readonly ref: GraphQLTypeRef
85
+ readonly nullable: boolean
86
+ }
87
+
88
+ /** A `@ResolveField` waiting for its parent type to be reached. */
89
+ interface PendingFieldResolver {
90
+ readonly resolver: ResolverData
91
+ readonly field: FieldData
92
+ readonly method: MethodInfo
93
+ attached: boolean
94
+ }
95
+
96
+ /** A default value to check once every type is complete. */
97
+ interface PendingDefault {
98
+ readonly target: ArgModel
99
+ readonly value: unknown
100
+ readonly use: Use
101
+ readonly location: Location | undefined
102
+ }
103
+
104
+ /** The first use of a declaration in a position (ZNIGHT063 "input and output"). */
105
+ interface PositionUse {
106
+ readonly coordinate: string
107
+ readonly location: Location | undefined
108
+ }
109
+
110
+ const NAME_HINT = 'GraphQL names match /^[_A-Za-z][_0-9A-Za-z]*$/ and do not start with __'
111
+ const RECORD_HINT =
112
+ "GraphQL objects have fixed fields: declare an interface, or use JSONValue (import type { JSONValue } from '@znight/graphql')"
113
+ const ROOT_NAMES: ReadonlySet<string> = new Set(Object.values(ROOT_TYPE_NAMES))
114
+
115
+ /** Builds the model; `undefined` when the application has no resolver. */
116
+ export function buildModel(ctx: Context, options: ResolvedGraphQLCompilerOptions): GraphQLModel | undefined {
117
+ const resolvers = [...ctx.stored].sort((a, b) => compareStrings(a.host.identity, b.host.identity))
118
+ if (resolvers.length === 0) return undefined
119
+ return new SchemaBuilder(ctx, options).build(resolvers)
120
+ }
121
+
122
+ class SchemaBuilder {
123
+ readonly #ctx: Context
124
+ readonly #number: 'Float' | 'Int'
125
+ readonly #shapeOptions: ShapeOptions
126
+ /** `scalar<T>()` declarations by the identity of `T`. */
127
+ readonly #scalars: ReadonlyMap<string, ScalarDeclaration>
128
+ /** Every named shape seen, by key. */
129
+ readonly #named = new Map<string, NamedShape>()
130
+ /** Models by GraphQL name (the first claimant). */
131
+ readonly #types = new Map<string, TypeModel>()
132
+ /** Models by declaration key. */
133
+ readonly #models = new Map<string, TypeModel>()
134
+ /** GraphQL names by declaration key. */
135
+ readonly #names = new Map<string, string | undefined>()
136
+ readonly #positions = new Map<string, { output?: PositionUse; input?: PositionUse }>()
137
+ readonly #reported = new Set<string>()
138
+ readonly #fieldResolvers = new Map<string, PendingFieldResolver[]>()
139
+ readonly #defaults: PendingDefault[] = []
140
+ readonly #methods = new Map<string, MethodInfo | undefined>()
141
+ #errors = 0
142
+
143
+ constructor(ctx: Context, options: ResolvedGraphQLCompilerOptions) {
144
+ this.#ctx = ctx
145
+ this.#number = options.number
146
+ const declarations = readScalars(ctx, diagnostic => this.#report(diagnostic))
147
+ this.#scalars = declarations
148
+ // shapes keep the scalar types as references: `T` of `scalar<T>()` maps to its scalar wherever it appears
149
+ const keep = [...new Set([...BASE_KEEP, ...declarations.keys()])].sort(compareStrings)
150
+ this.#shapeOptions = Object.freeze({ keep: Object.freeze(keep), members: 'instance', jsdoc: true })
151
+ }
152
+
153
+ build(resolvers: readonly ResolverData[]): GraphQLModel | undefined {
154
+ this.#checkLoaders(resolvers)
155
+ this.#indexFieldResolvers(resolvers)
156
+ const roots = new Map<Operation, ObjectModel>()
157
+ const rootFields = new Map<string, { readonly field: FieldData; readonly resolver: ResolverData }>()
158
+ let hasQuery = false
159
+ for (const resolver of resolvers) {
160
+ for (const field of resolver.fields) {
161
+ if (field.operation === 'field') continue
162
+ if (field.operation === 'query') hasQuery = true
163
+ if (!field.complete) continue
164
+ const method = this.#method(resolver, field)
165
+ if (method === undefined) continue
166
+ const root = this.#rootModel(roots, field.operation)
167
+ const coordinate = `${root.name}.${field.name}`
168
+ const previous = rootFields.get(coordinate)
169
+ if (previous !== undefined) {
170
+ this.#report({
171
+ code: Codes.NameConflict,
172
+ severity: 'error',
173
+ message: `Two root fields are named ${coordinate}: ${resolver.host.displayName}.${field.method} and ${previous.resolver.host.displayName}.${previous.field.method}`,
174
+ location: field.location,
175
+ related: [{ message: 'the other field', location: previous.field.location }],
176
+ hints: [`Rename one of them: @${decoratorName(field.operation)}('otherName')`],
177
+ })
178
+ continue
179
+ }
180
+ rootFields.set(coordinate, { field, resolver })
181
+ root.fields.push(this.#resolverField(root, resolver, field, method))
182
+ }
183
+ }
184
+ if (!hasQuery) {
185
+ const first = resolvers[0] as ResolverData
186
+ this.#report({
187
+ code: Codes.InvalidResolver,
188
+ severity: 'error',
189
+ message: 'The application has GraphQL resolvers but no @Query field: GraphQL requires a query root type',
190
+ location: first.location,
191
+ hints: [`Add a @Query() method to a resolver (${first.host.displayName}, for instance)`],
192
+ })
193
+ }
194
+ this.#reportUnreachableFieldResolvers()
195
+ this.#checkDefaults()
196
+ this.#checkInputCycles()
197
+ this.#checkDecisions(resolvers)
198
+ if (this.#errors > 0 || !hasQuery) return undefined
199
+ return this.#assemble(roots, resolvers)
200
+ }
201
+
202
+ // ------------------------------------------------------------------------------------- reporting
203
+
204
+ #report(diagnostic: Diagnostic): void {
205
+ if (diagnostic.severity === 'error') this.#errors++
206
+ this.#ctx.report(diagnostic)
207
+ }
208
+
209
+ /** Reports once per `key`. */
210
+ #reportOnce(key: string, diagnostic: Diagnostic): void {
211
+ if (this.#reported.has(key)) return
212
+ this.#reported.add(key)
213
+ this.#report(diagnostic)
214
+ }
215
+
216
+ #fail(code: Diagnostic['code'], use: Use, message: string, hints: readonly string[]): undefined {
217
+ const location = use.locate()
218
+ this.#report({
219
+ code,
220
+ severity: 'error',
221
+ message,
222
+ ...(location === undefined ? {} : { location }),
223
+ ...(hints.length === 0 ? {} : { hints }),
224
+ related: reachedFrom(use),
225
+ })
226
+ return undefined
227
+ }
228
+
229
+ // ------------------------------------------------------------------------------------ resolvers
230
+
231
+ #method(resolver: ResolverData, field: FieldData): MethodInfo | undefined {
232
+ const key = `${resolver.host.identity}#${field.method}`
233
+ if (!this.#methods.has(key)) {
234
+ const cls: ClassInfo | undefined = this.#ctx.classInfo(resolver.host.identity)
235
+ this.#methods.set(
236
+ key,
237
+ cls?.methods.find(method => method.name === field.method && method.kind === 'method'),
238
+ )
239
+ }
240
+ return this.#methods.get(key)
241
+ }
242
+
243
+ #shape(target: { readonly parameter: ParameterInfo } | { readonly returnOf: MethodInfo }): ShapeResult {
244
+ const result = this.#ctx.types.shape(target, this.#shapeOptions)
245
+ for (const named of result.named.values()) this.#named.set(named.key, named)
246
+ return result
247
+ }
248
+
249
+ #rootModel(roots: Map<Operation, ObjectModel>, operation: Operation): ObjectModel {
250
+ let root = roots.get(operation)
251
+ if (root === undefined) {
252
+ const name = ROOT_TYPE_NAMES[operation]
253
+ root = {
254
+ kind: 'object',
255
+ name,
256
+ key: `#root:${operation}`,
257
+ description: undefined,
258
+ location: undefined,
259
+ fields: [],
260
+ trail: name,
261
+ root: undefined,
262
+ classIdentity: undefined,
263
+ properties: [],
264
+ }
265
+ roots.set(operation, root)
266
+ this.#claim(root, undefined)
267
+ }
268
+ return root
269
+ }
270
+
271
+ /** A field backed by a resolver method (root field or field resolver). */
272
+ #resolverField(owner: ObjectModel, resolver: ResolverData, field: FieldData, method: MethodInfo): FieldModel {
273
+ const coordinate = `${owner.name}.${field.name}`
274
+ const isRoot = field.operation !== 'field'
275
+ const use: Use = {
276
+ position: 'output',
277
+ coordinate,
278
+ trail: isRoot ? coordinate : `${owner.trail}.${field.name}`,
279
+ reached: isRoot ? undefined : owner.trail,
280
+ root: isRoot ? method.location : owner.root,
281
+ locate: () => method.location,
282
+ }
283
+ const type = this.#returnType(resolver, field, method, use)
284
+ return {
285
+ name: field.name,
286
+ type: type === undefined ? 'String' : printTypeRef(type.ref),
287
+ description: descriptionOf(method.doc),
288
+ deprecationReason: deprecationOf(method.doc),
289
+ args: this.#arguments(field, method, use),
290
+ binding: { resolver, field },
291
+ location: field.location,
292
+ }
293
+ }
294
+
295
+ #returnType(resolver: ResolverData, field: FieldData, method: MethodInfo, use: Use): Converted | undefined {
296
+ const where = `${resolver.host.displayName}.${field.method}`
297
+ if (method.returnType === undefined) {
298
+ return this.#fail(Codes.InvalidType, use, `${where} has no return type annotation (${use.coordinate})`, [
299
+ `Annotate the return type: the schema is derived from it (${field.method}(...): Promise<Book>)`,
300
+ ])
301
+ }
302
+ const result = this.#shape({ returnOf: method })
303
+ const rootIssue = result.issues.find(issue => issue.named === undefined)
304
+ const located: Use = { ...use, locate: () => rootIssue?.location ?? method.location }
305
+ const root = result.root
306
+ const iterable = root.kind === 'reference' && ASYNC_ITERABLES.has(root.identity) ? root : undefined
307
+ if (field.operation === 'subscription') {
308
+ if (iterable === undefined) {
309
+ this.#report({
310
+ code: Codes.InvalidResolver,
311
+ severity: 'error',
312
+ message: `@Subscription ${where} must return an async iterable, not ${describeShape(root)}`,
313
+ location: method.location,
314
+ hints: [
315
+ `Declare it as an async generator (async *${field.method}(): AsyncGenerator<Event>) or return pubsub.subscribe(...) (AsyncIterable<Event>)`,
316
+ ],
317
+ })
318
+ return undefined
319
+ }
320
+ const item = iterable.typeArgs[0]
321
+ if (item === undefined) {
322
+ return this.#fail(
323
+ Codes.InvalidType,
324
+ located,
325
+ `${where} must declare the type of its events (${use.coordinate})`,
326
+ ['AsyncIterable<Event>'],
327
+ )
328
+ }
329
+ return this.#convert(item, false, located)
330
+ }
331
+ if (iterable !== undefined) {
332
+ return this.#fail(
333
+ Codes.InvalidType,
334
+ located,
335
+ `${where} returns an async iterable: only @Subscription fields stream values (${use.coordinate})`,
336
+ ['Use @Subscription() for a stream of events, or return a list (Promise<readonly Book[]>)'],
337
+ )
338
+ }
339
+ if (root.kind === 'undefined') {
340
+ return this.#fail(
341
+ Codes.InvalidType,
342
+ located,
343
+ `${where} returns void: GraphQL fields must return a value (${use.coordinate})`,
344
+ ['Return a payload type (the changed object) or a boolean'],
345
+ )
346
+ }
347
+ return this.#convert(root, false, located)
348
+ }
349
+
350
+ /** Arguments of a resolver field: `@Args('name')` parameters and the properties of `@Args()` types. */
351
+ #arguments(field: FieldData, method: MethodInfo, owner: Use): ArgModel[] {
352
+ const args: ArgModel[] = []
353
+ const seen = new Map<string, Location | undefined>()
354
+ const add = (arg: ArgModel, location: Location | undefined): void => {
355
+ if (seen.has(arg.name)) {
356
+ const first = seen.get(arg.name)
357
+ this.#report({
358
+ code: Codes.NameConflict,
359
+ severity: 'error',
360
+ message: `Argument '${arg.name}' of ${owner.coordinate} is declared twice`,
361
+ ...(location === undefined ? {} : { location }),
362
+ ...(first === undefined ? {} : { related: [{ message: 'first declared here', location: first }] }),
363
+ hints: ['Argument names must be unique: rename one of them'],
364
+ })
365
+ return
366
+ }
367
+ seen.set(arg.name, location)
368
+ args.push(arg)
369
+ }
370
+ field.params.forEach((data, index) => {
371
+ if (data?.kind !== 'args') return
372
+ const param = method.params[index] as ParameterInfo
373
+ const result = this.#shape({ parameter: param })
374
+ if (data.name !== undefined) {
375
+ const coordinate = `${owner.coordinate}(${data.name}:)`
376
+ const use: Use = {
377
+ position: 'input',
378
+ coordinate,
379
+ trail: `${owner.trail}(${data.name})`,
380
+ reached: owner.reached,
381
+ root: owner.root,
382
+ locate: () => param.typeLocation,
383
+ }
384
+ const arg = this.#parameterArgument(data.name, param, method, result, use)
385
+ if (arg !== undefined) add(arg, param.location)
386
+ return
387
+ }
388
+ const properties = this.#propertiesOf(result.root)
389
+ if (properties === 'record') {
390
+ this.#fail(
391
+ Codes.InvalidType,
392
+ { ...owner, locate: () => param.typeLocation },
393
+ `@Args() parameter '${param.name}' of ${owner.coordinate} cannot be a record or an index signature: GraphQL arguments are fixed`,
394
+ [RECORD_HINT, `@Args('${param.name}') declares one argument named '${param.name}'`],
395
+ )
396
+ return
397
+ }
398
+ if (properties === undefined) {
399
+ this.#fail(
400
+ Codes.InvalidType,
401
+ { ...owner, locate: () => param.typeLocation },
402
+ `@Args() parameter '${param.name}' of ${owner.coordinate} must have an object type: its properties are the arguments`,
403
+ [`@Args('${param.name}') declares one argument named '${param.name}'`],
404
+ )
405
+ return
406
+ }
407
+ for (const property of properties) {
408
+ if (isHidden(property.doc)) continue
409
+ const coordinate = `${owner.coordinate}(${property.name}:)`
410
+ const use: Use = {
411
+ position: 'input',
412
+ coordinate,
413
+ trail: `${owner.trail}(${property.name})`,
414
+ reached: owner.reached,
415
+ root: owner.root,
416
+ locate: () => annotationLocation(this.#ctx.project, property.location) ?? param.typeLocation,
417
+ }
418
+ const arg = this.#inputValue(property, use)
419
+ if (arg !== undefined) add(arg, property.location ?? param.location)
420
+ }
421
+ })
422
+ return args
423
+ }
424
+
425
+ /** `@Args('limit') limit: Int = 20`. */
426
+ #parameterArgument(
427
+ name: string,
428
+ param: ParameterInfo,
429
+ method: MethodInfo,
430
+ result: ShapeResult,
431
+ use: Use,
432
+ ): ArgModel | undefined {
433
+ if (!isGraphQLName(name)) {
434
+ this.#fail(Codes.NameConflict, use, `'${name}' is not a valid GraphQL argument name (${use.coordinate})`, [
435
+ NAME_HINT,
436
+ ])
437
+ return undefined
438
+ }
439
+ const converted = this.#convert(result.root, param.optional, use)
440
+ if (converted === undefined) return undefined
441
+ let ref = converted.ref
442
+ const arg: ArgModel = {
443
+ name,
444
+ type: '',
445
+ ref,
446
+ description: descriptionOf(param.doc) ?? paramDescription(method.doc, param.name),
447
+ deprecationReason: deprecationOf(param.doc),
448
+ defaultValue: undefined,
449
+ nullAsUndefined: false,
450
+ location: param.location,
451
+ }
452
+ const initializer = param.initializer
453
+ let hasDefault = false
454
+ if (param.hasDefault) {
455
+ if (initializer?.evaluated === true && initializer.ref === undefined && !containsRef(initializer.value)) {
456
+ this.#defaults.push({ target: arg, value: initializer.value, use, location: initializer.location })
457
+ hasDefault = true
458
+ } else {
459
+ // a default the compiler cannot read: the argument becomes optional, the TypeScript default applies
460
+ ref = { ...ref, nonNull: false }
461
+ }
462
+ }
463
+ arg.ref = ref
464
+ arg.type = printTypeRef(ref)
465
+ arg.nullAsUndefined = !ref.nonNull && !converted.acceptsNull
466
+ this.#checkDeprecatedInput(arg, hasDefault, use)
467
+ return arg
468
+ }
469
+
470
+ /** A property as an argument or an input field (§6.3): `@default`, `@deprecated`, null handling. */
471
+ #inputValue(property: ShapeProperty, use: Use): ArgModel | undefined {
472
+ if (!isGraphQLName(property.name)) {
473
+ this.#fail(
474
+ Codes.NameConflict,
475
+ { ...use, locate: () => property.location },
476
+ `'${property.name}' is not a valid GraphQL name (${use.coordinate})`,
477
+ [NAME_HINT, 'Hide the property with /** @gqlHidden */'],
478
+ )
479
+ return undefined
480
+ }
481
+ if (property.kind === 'getter') {
482
+ this.#fail(Codes.InvalidInput, use, `${use.coordinate} is a getter: input values are plain data`, [
483
+ 'Declare a property instead, or hide the getter with /** @gqlHidden */',
484
+ ])
485
+ return undefined
486
+ }
487
+ const converted = this.#convert(property.shape, property.optional, use)
488
+ if (converted === undefined) return undefined
489
+ const arg: ArgModel = {
490
+ name: property.name,
491
+ type: printTypeRef(converted.ref),
492
+ ref: converted.ref,
493
+ description: descriptionOf(property.doc),
494
+ deprecationReason: deprecationOf(property.doc),
495
+ defaultValue: undefined,
496
+ nullAsUndefined: !converted.ref.nonNull && !converted.acceptsNull,
497
+ location: property.location,
498
+ }
499
+ const tag = tagOf(property.doc, 'default')
500
+ if (tag !== undefined)
501
+ this.#defaults.push({ target: arg, value: defaultTagValue(tag), use, location: tag.location })
502
+ this.#checkDeprecatedInput(arg, tag !== undefined, use)
503
+ return arg
504
+ }
505
+
506
+ /** graphql-js rejects a deprecated argument or input field that is required. */
507
+ #checkDeprecatedInput(arg: ArgModel, hasDefault: boolean, use: Use): void {
508
+ if (arg.deprecationReason === undefined || !arg.ref.nonNull || hasDefault) return
509
+ this.#fail(Codes.InvalidInput, use, `${use.coordinate} is required and cannot be deprecated`, [
510
+ 'Make it optional (?: T or T | undefined) or give it a default value before deprecating it',
511
+ ])
512
+ }
513
+
514
+ /** `Loader<L>`: `L` must be a loader provider, implementing `BatchLoad<K, V>` (ZNIGHT065). */
515
+ #checkLoaders(resolvers: readonly ResolverData[]): void {
516
+ const seen = new Set<number>()
517
+ for (const resolver of resolvers) {
518
+ for (const field of resolver.fields) {
519
+ for (const param of field.params) {
520
+ if (param?.kind !== 'loader' || seen.has(param.binding.id)) continue
521
+ seen.add(param.binding.id)
522
+ const ref = this.#ctx.binding(param.binding)
523
+ const token = tokenOfRef(ref)
524
+ const provider = token === undefined ? undefined : this.#ctx.provider(token)
525
+ // an unbound loader is ZNIGHT001 (reported by the binding)
526
+ if (provider === undefined || provider.heritage.some(type => type.identity === GraphQLIds.BatchLoad)) continue
527
+ if (provider.kind !== 'class' || hasLoadMethod(this.#ctx, provider.classIdentity)) continue
528
+ this.#report({
529
+ code: Codes.InvalidLoader,
530
+ severity: 'error',
531
+ message: `${provider.displayName} is not a loader: Loader<${provider.displayName}> needs a provider implementing BatchLoad<K, V> (${resolver.host.displayName}.${field.method})`,
532
+ location: param.location,
533
+ hints: [
534
+ `export class ${provider.displayName} implements BatchLoad<ID, Value> { async load(keys: readonly ID[]): Promise<readonly (Value | Error)[]> { ... } }`,
535
+ ],
536
+ })
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ // ------------------------------------------------------------------------------ field resolvers
543
+
544
+ #indexFieldResolvers(resolvers: readonly ResolverData[]): void {
545
+ for (const resolver of resolvers) {
546
+ for (const field of resolver.fields) {
547
+ if (field.operation !== 'field' || !field.complete) continue
548
+ const method = this.#method(resolver, field)
549
+ if (method === undefined) continue
550
+ const index = field.params.findIndex(param => param?.kind === 'parent')
551
+ const param = method.params[index]
552
+ if (param === undefined) continue
553
+ const result = this.#shape({ parameter: param })
554
+ const key = this.#objectKeyOf(result.root)
555
+ if (key === undefined) {
556
+ this.#report({
557
+ code: Codes.InvalidResolver,
558
+ severity: 'error',
559
+ message: `The @Parent() parameter '${param.name}' of ${resolver.host.displayName}.${field.method} must have an object type, not ${describeShape(result.root)}`,
560
+ location: param.typeLocation,
561
+ hints: [
562
+ 'The parent type is the object type the field is added to: an interface, a class or a named alias of an object type',
563
+ ],
564
+ })
565
+ continue
566
+ }
567
+ let list = this.#fieldResolvers.get(key)
568
+ if (list === undefined) {
569
+ list = []
570
+ this.#fieldResolvers.set(key, list)
571
+ }
572
+ list.push({ resolver, field, method, attached: false })
573
+ }
574
+ }
575
+ // field resolvers are appended by resolver token, then source order (§7.3)
576
+ for (const list of this.#fieldResolvers.values()) {
577
+ list.sort(
578
+ (a, b) =>
579
+ compareStrings(a.resolver.host.identity, b.resolver.host.identity) ||
580
+ a.field.location.start - b.field.location.start,
581
+ )
582
+ }
583
+ }
584
+
585
+ #attachFieldResolvers(model: ObjectModel): void {
586
+ const pending = this.#fieldResolvers.get(model.key)
587
+ if (pending === undefined) return
588
+ for (const entry of pending) {
589
+ entry.attached = true
590
+ const field = this.#resolverField(model, entry.resolver, entry.field, entry.method)
591
+ const index = model.fields.findIndex(existing => existing.name === field.name)
592
+ if (index === -1) {
593
+ model.fields.push(field)
594
+ continue
595
+ }
596
+ const existing = model.fields[index] as FieldModel
597
+ const where = `${entry.resolver.host.displayName}.${entry.field.method}`
598
+ if (existing.binding !== undefined) {
599
+ this.#report({
600
+ code: Codes.NameConflict,
601
+ severity: 'error',
602
+ message: `Two field resolvers add ${model.name}.${field.name}: ${where} and ${existing.binding.resolver.host.displayName}.${existing.binding.field.method}`,
603
+ location: entry.field.location,
604
+ related: [{ message: 'the other field resolver', location: existing.binding.field.location }],
605
+ hints: ["Keep one of them, or rename one: @ResolveField('otherName')"],
606
+ })
607
+ continue
608
+ }
609
+ if (existing.type !== field.type) {
610
+ this.#report({
611
+ code: Codes.NameConflict,
612
+ severity: 'error',
613
+ message: `@ResolveField ${where} returns ${field.type}, but the property ${model.name}.${field.name} is ${existing.type}`,
614
+ location: entry.field.location,
615
+ ...(existing.location === undefined
616
+ ? {}
617
+ : { related: [{ message: 'the property', location: existing.location }] }),
618
+ hints: [
619
+ 'A field resolver replacing a property must resolve the same GraphQL type: change its return type, or rename the field',
620
+ ],
621
+ })
622
+ continue
623
+ }
624
+ // replaces the property field in place: same position, the method's documentation first
625
+ model.fields[index] = {
626
+ ...field,
627
+ description: field.description ?? existing.description,
628
+ deprecationReason: field.deprecationReason ?? existing.deprecationReason,
629
+ }
630
+ }
631
+ }
632
+
633
+ #reportUnreachableFieldResolvers(): void {
634
+ for (const [key, list] of this.#fieldResolvers) {
635
+ for (const entry of list) {
636
+ if (entry.attached) continue
637
+ const name = this.#named.get(key)?.name ?? key
638
+ this.#report({
639
+ code: Codes.InvalidResolver,
640
+ severity: 'warning',
641
+ message: `@ResolveField ${entry.resolver.host.displayName}.${entry.field.method} adds a field to ${name}, which no root field reaches as an output type: it is not part of the schema`,
642
+ location: entry.field.location,
643
+ hints: [`Return ${name} from a @Query (or another reachable field), or remove the field resolver`],
644
+ })
645
+ }
646
+ }
647
+ }
648
+
649
+ // ----------------------------------------------------------------------------------- conversion
650
+
651
+ /**
652
+ * The GraphQL type of a shape: `null`, `undefined` and `optional` make it nullable, everything
653
+ * else is non-null (§6.2).
654
+ */
655
+ #convert(shape: Shape, optional: boolean, use: Use): Converted | undefined {
656
+ let nullable = optional
657
+ let acceptsNull = false
658
+ let inner = shape
659
+ if (shape.kind === 'union') {
660
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
661
+ if (rest.length !== shape.members.length) {
662
+ nullable = true
663
+ acceptsNull = shape.members.some(member => member.kind === 'null')
664
+ }
665
+ if (rest.length === 0) {
666
+ return this.#fail(Codes.InvalidType, use, `${use.coordinate} is always null or undefined`, [
667
+ 'Declare the type of the value',
668
+ ])
669
+ }
670
+ inner = rest.length === 1 ? (rest[0] as Shape) : { kind: 'union', members: rest }
671
+ }
672
+ const converted = this.#inner(inner, use)
673
+ if (converted === undefined) return undefined
674
+ const isNullable = nullable || converted.nullable
675
+ return { ref: { ...converted.ref, nonNull: !isNullable }, acceptsNull: acceptsNull || converted.nullable }
676
+ }
677
+
678
+ #inner(shape: Shape, use: Use): Inner | undefined {
679
+ switch (shape.kind) {
680
+ case 'string':
681
+ return refTo('String')
682
+ case 'number':
683
+ return refTo(this.#number)
684
+ case 'boolean':
685
+ return refTo('Boolean')
686
+ case 'literal':
687
+ if (typeof shape.value === 'string') return refTo('String')
688
+ return refTo(typeof shape.value === 'number' ? this.#number : 'Boolean')
689
+ case 'date':
690
+ return this.#builtin('DateTime', use)
691
+ case 'bigint':
692
+ return this.#builtin('BigInt', use)
693
+ case 'array': {
694
+ const item = this.#convert(shape.items, false, use)
695
+ return item === undefined ? undefined : { ref: { kind: 'list', of: item.ref, nonNull: false }, nullable: false }
696
+ }
697
+ case 'named':
698
+ return this.#namedType(shape.key, use)
699
+ case 'reference':
700
+ return this.#reference(shape, use)
701
+ case 'url':
702
+ return this.#fail(Codes.InvalidType, use, `Cannot map 'URL' to a GraphQL type (${use.coordinate})`, [
703
+ "Expose a string (url.href), or declare a scalar: export const UrlScalar = scalar<URL>('URL', { ... })",
704
+ ])
705
+ case 'binary':
706
+ return this.#fail(Codes.InvalidType, use, `Cannot map binary data to a GraphQL type (${use.coordinate})`, [
707
+ "Expose a URL (coverUrl: string), hide the property with /** @gqlHidden */, or declare a scalar: export const Base64 = scalar<Uint8Array>('Base64', { ... })",
708
+ ])
709
+ case 'unknown':
710
+ return this.#fail(
711
+ Codes.InvalidType,
712
+ use,
713
+ `Cannot map 'unknown' or 'any' to a GraphQL type (${use.coordinate})`,
714
+ ["Declare the type, or use JSONValue (import type { JSONValue } from '@znight/graphql') for arbitrary JSON"],
715
+ )
716
+ case 'never':
717
+ case 'null':
718
+ case 'undefined':
719
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.kind}' to a GraphQL type (${use.coordinate})`, [
720
+ 'Declare the type of the value',
721
+ ])
722
+ case 'tuple':
723
+ return this.#fail(Codes.InvalidType, use, `Cannot map a tuple to a GraphQL type (${use.coordinate})`, [
724
+ 'Use an array (readonly T[]) or a named object type',
725
+ ])
726
+ case 'object':
727
+ if (shape.additional !== undefined) {
728
+ return this.#fail(
729
+ Codes.InvalidType,
730
+ use,
731
+ `Cannot map a record or an index signature to a GraphQL type (${use.coordinate})`,
732
+ [RECORD_HINT],
733
+ )
734
+ }
735
+ return this.#fail(
736
+ Codes.InvalidType,
737
+ use,
738
+ `Cannot map an anonymous object type to a GraphQL type (${use.coordinate})`,
739
+ ['GraphQL types have names: declare it (export interface BookStats { ... }) and use it'],
740
+ )
741
+ case 'intersection':
742
+ return this.#fail(
743
+ Codes.InvalidType,
744
+ use,
745
+ `Cannot map an anonymous intersection to a GraphQL type (${use.coordinate})`,
746
+ ['Name it: export type BookWithAuthor = Book & { readonly author: Author }'],
747
+ )
748
+ case 'union':
749
+ return this.#anonymousUnion(shape.members, use)
750
+ case 'schema':
751
+ return this.#fail(Codes.InvalidType, use, `Cannot map a schema type to a GraphQL type (${use.coordinate})`, [
752
+ 'Declare the TypeScript type: the annotation is the GraphQL type',
753
+ ])
754
+ case 'opaque':
755
+ return this.#opaque(shape, use)
756
+ }
757
+ }
758
+
759
+ #anonymousUnion(members: readonly Shape[], use: Use): undefined {
760
+ if (members.every(member => member.kind === 'literal' && typeof member.value === 'string')) {
761
+ const values = members.map(member => `'${(member as { value: string }).value}'`).join(' | ')
762
+ return this.#fail(
763
+ Codes.InvalidType,
764
+ use,
765
+ `Cannot map the anonymous union ${values} to a GraphQL type (${use.coordinate})`,
766
+ [`Name it to make it an enum: export type Sort = ${values}`],
767
+ )
768
+ }
769
+ if (members.every(member => member.kind === 'named')) {
770
+ return this.#fail(Codes.InvalidType, use, `Cannot map an anonymous union to a GraphQL type (${use.coordinate})`, [
771
+ `GraphQL unions have names: export type SearchResult = ${members.map(member => this.#named.get((member as { key: string }).key)?.name ?? '?').join(' | ')}`,
772
+ ])
773
+ }
774
+ return this.#fail(
775
+ Codes.InvalidType,
776
+ use,
777
+ `Cannot map the union ${members.map(describeShape).join(' | ')} to a GraphQL type (${use.coordinate})`,
778
+ ['GraphQL unions only contain object types: declare one type, or an object type per case'],
779
+ )
780
+ }
781
+
782
+ #opaque(shape: Extract<Shape, { kind: 'opaque' }>, use: Use): undefined {
783
+ const text = shape.text
784
+ let hints: string[]
785
+ switch (shape.reason) {
786
+ case 'non-json':
787
+ if (text.startsWith('Promise')) {
788
+ hints = ['A Promise is only unwrapped at the top of a resolver return type: declare the resolved type']
789
+ } else if (text.startsWith('Map') || text.startsWith('WeakMap')) {
790
+ hints = [
791
+ "Use an array of entries, a named object type, or JSONValue (import type { JSONValue } from '@znight/graphql')",
792
+ ]
793
+ } else {
794
+ hints = ['Declare a plain data type (an interface, an array, a scalar)']
795
+ }
796
+ break
797
+ case 'missing-annotation':
798
+ hints = ['Annotate the type: the compiler reads annotations only']
799
+ break
800
+ case 'function':
801
+ hints = ['Functions are not data: return the value, or hide the member with /** @gqlHidden */']
802
+ break
803
+ case 'type-parameter':
804
+ hints = ['Give the type arguments explicitly']
805
+ break
806
+ default:
807
+ hints = ['Declare the structure with an interface or a named alias']
808
+ }
809
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${text}' to a GraphQL type (${use.coordinate})`, hints)
810
+ }
811
+
812
+ #reference(shape: Extract<Shape, { kind: 'reference' }>, use: Use): Inner | undefined {
813
+ const alias = SCALAR_ALIASES.get(shape.identity)
814
+ if (alias === 'JSON') {
815
+ // JSONValue includes null: the field is nullable
816
+ const json = this.#builtin('JSON', use)
817
+ return json === undefined ? undefined : { ref: json.ref, nullable: true }
818
+ }
819
+ if (alias !== undefined) return refTo(alias === 'Float' ? 'Float' : alias)
820
+ const scalar = this.#scalars.get(shape.identity)
821
+ if (scalar !== undefined) return this.#customScalar(scalar, use)
822
+ if (OUTPUT_ITERABLES.has(shape.identity)) {
823
+ if (use.position === 'input') {
824
+ return this.#fail(Codes.InvalidInput, use, `${use.coordinate} is a ${shape.name}: input lists are arrays`, [
825
+ 'Declare an array (readonly T[]) for input values',
826
+ ])
827
+ }
828
+ const item = shape.typeArgs[0]
829
+ if (item === undefined)
830
+ return this.#fail(Codes.InvalidType, use, `${use.coordinate}: ${shape.name} needs a type argument`, [])
831
+ const converted = this.#convert(item, false, use)
832
+ return converted === undefined
833
+ ? undefined
834
+ : { ref: { kind: 'list', of: converted.ref, nonNull: false }, nullable: false }
835
+ }
836
+ if (ASYNC_ITERABLES.has(shape.identity)) {
837
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.name}' to a GraphQL type (${use.coordinate})`, [
838
+ 'Async iterables are only the return type of @Subscription methods',
839
+ ])
840
+ }
841
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.name}' to a GraphQL type (${use.coordinate})`, [
842
+ 'Declare a plain data type (an interface, an array, a scalar)',
843
+ ])
844
+ }
845
+
846
+ // ------------------------------------------------------------------------------------- scalars
847
+
848
+ #builtin(name: 'DateTime' | 'BigInt' | 'JSON', use: Use): Inner | undefined {
849
+ const key = `#builtin:${name}`
850
+ if (!this.#models.has(key)) {
851
+ const model: ScalarModel = {
852
+ kind: 'scalar',
853
+ name,
854
+ key,
855
+ description: undefined,
856
+ location: undefined,
857
+ definition: undefined,
858
+ specifiedByURL: undefined,
859
+ }
860
+ this.#models.set(key, model)
861
+ if (!this.#claim(model, use)) return undefined
862
+ }
863
+ return refTo(name)
864
+ }
865
+
866
+ #customScalar(scalar: ScalarDeclaration, use: Use): Inner | undefined {
867
+ const key = `#scalar:${scalar.identity}`
868
+ if (!this.#models.has(key)) {
869
+ const model: ScalarModel = {
870
+ kind: 'scalar',
871
+ name: scalar.name,
872
+ key,
873
+ description: scalar.description,
874
+ location: scalar.location,
875
+ definition: scalar.identity,
876
+ specifiedByURL: scalar.specifiedByURL,
877
+ }
878
+ this.#models.set(key, model)
879
+ if (!this.#claim(model, use)) return undefined
880
+ }
881
+ return refTo(scalar.name)
882
+ }
883
+
884
+ // ------------------------------------------------------------------------------- named types
885
+
886
+ #namedType(key: string, use: Use): Inner | undefined {
887
+ const named = this.#named.get(key)
888
+ if (named === undefined)
889
+ return this.#fail(Codes.InvalidType, use, `Cannot resolve the type of ${use.coordinate}`, [])
890
+ switch (named.declaration) {
891
+ case 'enum':
892
+ return this.#enum(named, use)
893
+ case 'alias':
894
+ return this.#alias(named, use)
895
+ default:
896
+ return this.#object(named, named, use)
897
+ }
898
+ }
899
+
900
+ /**
901
+ * A named alias: an object type (object literal, intersection, utility type, generic instance),
902
+ * an enum (string literals), a union (object types), or transparent (`type Email = string`,
903
+ * `type Books = Book[]`, `type Author = Person`).
904
+ */
905
+ #alias(alias: NamedShape, use: Use): Inner | undefined {
906
+ const shape = alias.shape
907
+ switch (shape.kind) {
908
+ case 'object':
909
+ case 'intersection':
910
+ return this.#object(alias, alias, use)
911
+ case 'named': {
912
+ const target = this.#named.get(shape.key)
913
+ if (
914
+ target !== undefined &&
915
+ target.declaration !== 'alias' &&
916
+ target.declaration !== 'enum' &&
917
+ target.typeArgs.length > 0
918
+ ) {
919
+ // `type BookList = Page<Book>`: the alias names the instance
920
+ return this.#object(alias, target, use)
921
+ }
922
+ return this.#namedType(shape.key, use)
923
+ }
924
+ case 'union': {
925
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
926
+ const nullable = rest.length !== shape.members.length
927
+ if (rest.length === 1) {
928
+ const inner = this.#inner(rest[0] as Shape, use)
929
+ return inner === undefined ? undefined : { ref: inner.ref, nullable: inner.nullable || nullable }
930
+ }
931
+ let converted: Inner | undefined
932
+ if (rest.every(member => member.kind === 'literal')) converted = this.#literalEnum(alias, rest, use)
933
+ else if (rest.length > 0 && rest.every(member => member.kind === 'named'))
934
+ converted = this.#union(alias, rest, use)
935
+ else converted = this.#anonymousUnion(rest, use)
936
+ return converted === undefined ? undefined : { ref: converted.ref, nullable }
937
+ }
938
+ default:
939
+ return this.#inner(shape, use)
940
+ }
941
+ }
942
+
943
+ /**
944
+ * The declaration key of the object type a shape maps to in output position (the parent of a
945
+ * field resolver), `undefined` when it is not an object type. Mirrors {@link #alias}.
946
+ */
947
+ #objectKeyOf(shape: Shape): string | undefined {
948
+ if (shape.kind === 'union') {
949
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
950
+ return rest.length === 1 ? this.#objectKeyOf(rest[0] as Shape) : undefined
951
+ }
952
+ if (shape.kind !== 'named') return undefined
953
+ const named = this.#named.get(shape.key)
954
+ if (named === undefined || named.declaration === 'enum') return undefined
955
+ if (named.declaration !== 'alias') return named.key
956
+ const target = named.shape
957
+ if (target.kind === 'object' || target.kind === 'intersection') return named.key
958
+ if (target.kind === 'named') {
959
+ const instance = this.#named.get(target.key)
960
+ if (
961
+ instance !== undefined &&
962
+ instance.declaration !== 'alias' &&
963
+ instance.declaration !== 'enum' &&
964
+ instance.typeArgs.length > 0
965
+ ) {
966
+ return named.key
967
+ }
968
+ }
969
+ return this.#objectKeyOf(target)
970
+ }
971
+
972
+ /** An object type (output) or an input object type (input) for `named`, structured as `structure`. */
973
+ #object(named: NamedShape, structure: NamedShape, use: Use): Inner | undefined {
974
+ const key = named.key
975
+ if (!this.#checkPosition(named, use)) return undefined
976
+ const existing = this.#models.get(key)
977
+ if (existing !== undefined) return this.#known(existing)
978
+ const name = this.#typeName(named, use)
979
+ if (name === undefined) return undefined
980
+ const properties = this.#propertiesOf(structure.shape)
981
+ if (properties === 'record') {
982
+ return this.#fail(
983
+ Codes.InvalidType,
984
+ use,
985
+ `Cannot map ${named.name}, a record or an index signature, to a GraphQL type (${use.coordinate})`,
986
+ [RECORD_HINT],
987
+ )
988
+ }
989
+ if (properties === undefined) {
990
+ return this.#fail(
991
+ Codes.InvalidType,
992
+ use,
993
+ `Cannot map ${named.name} to a GraphQL object type (${use.coordinate})`,
994
+ ['Declare an interface, a class or a named object type'],
995
+ )
996
+ }
997
+ const description = descriptionOf(named.doc) ?? (structure === named ? undefined : descriptionOf(structure.doc))
998
+ const trail = `${use.trail} → ${name}`
999
+ if (use.position === 'input') {
1000
+ const model: InputModel = { kind: 'input', name, key, description, location: named.location, fields: [] }
1001
+ this.#models.set(key, model)
1002
+ if (!this.#claim(model, use)) return undefined
1003
+ for (const property of properties) {
1004
+ if (isHidden(property.doc)) continue
1005
+ const fieldUse: Use = {
1006
+ position: 'input',
1007
+ coordinate: `${name}.${property.name}`,
1008
+ trail: `${trail}.${property.name}`,
1009
+ reached: trail,
1010
+ root: use.root,
1011
+ locate: () => annotationLocation(this.#ctx.project, property.location),
1012
+ }
1013
+ const field = this.#inputValue(property, fieldUse)
1014
+ if (field !== undefined) model.fields.push(field)
1015
+ }
1016
+ return refTo(name)
1017
+ }
1018
+ const model: ObjectModel = {
1019
+ kind: 'object',
1020
+ name,
1021
+ key,
1022
+ description,
1023
+ location: named.location,
1024
+ fields: [],
1025
+ trail,
1026
+ root: use.root,
1027
+ classIdentity:
1028
+ structure.declaration === 'class' && structure.typeArgs.length === 0 && structure === named
1029
+ ? structure.identity
1030
+ : undefined,
1031
+ properties: properties.map(property => this.#discriminant(property)),
1032
+ }
1033
+ this.#models.set(key, model)
1034
+ if (!this.#claim(model, use)) return undefined
1035
+ for (const property of properties) {
1036
+ const field = this.#outputField(model, property)
1037
+ if (field !== undefined) model.fields.push(field)
1038
+ }
1039
+ this.#attachFieldResolvers(model)
1040
+ return refTo(name)
1041
+ }
1042
+
1043
+ /** A property as a union discriminator candidate: its literal value, through aliases (`kind: BookKind`). */
1044
+ #discriminant(property: ShapeProperty): DiscriminantProperty {
1045
+ let shape = property.shape
1046
+ // `type BookKind = 'book'`, `type Kind = BookKind`; the bound only guards against alias cycles
1047
+ for (let depth = 0; shape.kind === 'named' && depth < 32; depth++) {
1048
+ const named = this.#named.get(shape.key)
1049
+ if (named?.declaration !== 'alias') break
1050
+ shape = named.shape
1051
+ }
1052
+ return {
1053
+ name: property.name,
1054
+ optional: property.optional,
1055
+ literal: shape.kind === 'literal' ? shape.value : undefined,
1056
+ }
1057
+ }
1058
+
1059
+ #outputField(model: ObjectModel, property: ShapeProperty): FieldModel | undefined {
1060
+ // hidden members, functions (methods stored as properties) and the union discriminator `__typename`
1061
+ if (isHidden(property.doc) || property.name === '__typename') return undefined
1062
+ if (property.shape.kind === 'opaque' && property.shape.reason === 'function') return undefined
1063
+ const use: Use = {
1064
+ position: 'output',
1065
+ coordinate: `${model.name}.${property.name}`,
1066
+ trail: `${model.trail}.${property.name}`,
1067
+ reached: model.trail,
1068
+ root: model.root,
1069
+ locate: () => annotationLocation(this.#ctx.project, property.location),
1070
+ }
1071
+ if (!isGraphQLName(property.name)) {
1072
+ return this.#fail(
1073
+ Codes.NameConflict,
1074
+ { ...use, locate: () => property.location },
1075
+ `'${property.name}' is not a valid GraphQL field name (${use.coordinate})`,
1076
+ [NAME_HINT, 'Hide the property with /** @gqlHidden */'],
1077
+ )
1078
+ }
1079
+ const converted = this.#convert(property.shape, property.optional, use)
1080
+ if (converted === undefined) return undefined
1081
+ return {
1082
+ name: property.name,
1083
+ type: printTypeRef(converted.ref),
1084
+ description: descriptionOf(property.doc),
1085
+ deprecationReason: deprecationOf(property.doc),
1086
+ args: [],
1087
+ binding: undefined,
1088
+ location: property.location,
1089
+ }
1090
+ }
1091
+
1092
+ /**
1093
+ * The properties of an object structure (intersections merged, later members win), `'record'` for
1094
+ * a record or an index signature (ZNIGHT060: GraphQL objects have fixed fields).
1095
+ */
1096
+ #propertiesOf(shape: Shape): readonly ShapeProperty[] | 'record' | undefined {
1097
+ switch (shape.kind) {
1098
+ case 'object':
1099
+ return shape.additional === undefined ? shape.properties : 'record'
1100
+ case 'named': {
1101
+ const named = this.#named.get(shape.key)
1102
+ return named === undefined || named.declaration === 'enum' ? undefined : this.#propertiesOf(named.shape)
1103
+ }
1104
+ case 'intersection': {
1105
+ const merged = new Map<string, ShapeProperty>()
1106
+ for (const member of shape.members) {
1107
+ const properties = this.#propertiesOf(member)
1108
+ if (properties === undefined || properties === 'record') return properties
1109
+ for (const property of properties) {
1110
+ merged.delete(property.name)
1111
+ merged.set(property.name, property)
1112
+ }
1113
+ }
1114
+ return [...merged.values()]
1115
+ }
1116
+ default:
1117
+ return undefined
1118
+ }
1119
+ }
1120
+
1121
+ /** A declaration is used in one position: input or output (ZNIGHT063 otherwise). */
1122
+ #checkPosition(named: NamedShape, use: Use): boolean {
1123
+ let uses = this.#positions.get(named.key)
1124
+ if (uses === undefined) {
1125
+ uses = {}
1126
+ this.#positions.set(named.key, uses)
1127
+ }
1128
+ const other = use.position === 'input' ? uses.output : uses.input
1129
+ if (uses[use.position] === undefined) uses[use.position] = { coordinate: use.coordinate, location: use.locate() }
1130
+ if (other === undefined) return true
1131
+ const output = use.position === 'output' ? use.coordinate : other.coordinate
1132
+ const input = use.position === 'input' ? use.coordinate : other.coordinate
1133
+ const inputOwner = input.includes('(') ? input.slice(0, input.indexOf('(')) : input.split('.')[0]
1134
+ this.#reportOnce(`position\0${named.key}`, {
1135
+ code: Codes.InvalidInput,
1136
+ severity: 'error',
1137
+ message: `'${named.name}' is used as an output type (${output}) and as an input type (${input})`,
1138
+ location: named.location,
1139
+ hints: [
1140
+ `GraphQL needs two types: declare \`export interface ${named.name}Input extends ${named.name} {}\` and use it in ${inputOwner}`,
1141
+ ],
1142
+ related: [
1143
+ ...(uses.output?.location === undefined
1144
+ ? []
1145
+ : [{ message: `used as output: ${output}`, location: uses.output.location }]),
1146
+ ...(uses.input?.location === undefined
1147
+ ? []
1148
+ : [{ message: `used as input: ${input}`, location: uses.input.location }]),
1149
+ ],
1150
+ })
1151
+ return false
1152
+ }
1153
+
1154
+ /**
1155
+ * The GraphQL name of a declaration (§6.4): `@gqlName`, the declaration name, or for a generic
1156
+ * instance the names of its type arguments followed by the declaration name (`BookPage`).
1157
+ */
1158
+ #typeName(named: NamedShape, use: Use): string | undefined {
1159
+ if (this.#names.has(named.key)) return this.#names.get(named.key)
1160
+ let name: string | undefined
1161
+ const explicit = tagOf(named.doc, 'gqlName')?.text.trim()
1162
+ if (explicit !== undefined && explicit !== '') name = explicit
1163
+ else if (named.typeArgs.length === 0) name = named.name
1164
+ else {
1165
+ const args = named.typeArgs.map(arg => this.#argumentName(arg))
1166
+ const missing = args.findIndex(arg => arg === undefined)
1167
+ if (missing === -1) name = `${args.join('')}${named.name}`
1168
+ else {
1169
+ this.#reportOnce(`argument\0${named.key}`, {
1170
+ code: Codes.InvalidType,
1171
+ severity: 'error',
1172
+ message: `Cannot name the GraphQL type of ${named.name}<...> (${use.coordinate}): its type argument ${describeShape(named.typeArgs[missing] as Shape)} has no GraphQL name`,
1173
+ location: named.location,
1174
+ related: reachedFrom(use),
1175
+ hints: [
1176
+ `Name the instance with an alias (export type BookPages = ${named.name}<...>) or with /** @gqlName Name */`,
1177
+ ],
1178
+ })
1179
+ }
1180
+ }
1181
+ this.#names.set(named.key, name)
1182
+ return name
1183
+ }
1184
+
1185
+ /** The GraphQL name a type argument contributes to a generic instance name. */
1186
+ #argumentName(shape: Shape): string | undefined {
1187
+ switch (shape.kind) {
1188
+ case 'string':
1189
+ return 'String'
1190
+ case 'number':
1191
+ return this.#number
1192
+ case 'boolean':
1193
+ return 'Boolean'
1194
+ case 'date':
1195
+ return 'DateTime'
1196
+ case 'bigint':
1197
+ return 'BigInt'
1198
+ case 'reference': {
1199
+ const alias = SCALAR_ALIASES.get(shape.identity)
1200
+ if (alias !== undefined) return alias
1201
+ return this.#scalars.get(shape.identity)?.name
1202
+ }
1203
+ case 'union': {
1204
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
1205
+ return rest.length === 1 ? this.#argumentName(rest[0] as Shape) : undefined
1206
+ }
1207
+ case 'named': {
1208
+ const named = this.#named.get(shape.key)
1209
+ if (named === undefined) return undefined
1210
+ if (named.declaration === 'alias') {
1211
+ const target = named.shape
1212
+ const instance = target.kind === 'named' ? this.#named.get(target.key) : undefined
1213
+ const isObject =
1214
+ target.kind === 'object' ||
1215
+ target.kind === 'intersection' ||
1216
+ (instance !== undefined &&
1217
+ instance.declaration !== 'alias' &&
1218
+ instance.declaration !== 'enum' &&
1219
+ instance.typeArgs.length > 0)
1220
+ const isEnum = target.kind === 'union' && target.members.every(member => member.kind === 'literal')
1221
+ const isUnion = target.kind === 'union' && target.members.filter(member => member.kind === 'named').length > 1
1222
+ if (!isObject && !isEnum && !isUnion) return this.#argumentName(target)
1223
+ }
1224
+ return this.#typeName(named, {
1225
+ position: 'output',
1226
+ coordinate: named.name,
1227
+ trail: named.name,
1228
+ reached: undefined,
1229
+ root: undefined,
1230
+ locate: () => named.location,
1231
+ })
1232
+ }
1233
+ default:
1234
+ return undefined
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * Registers a model under its GraphQL name: the name must be valid, not reserved, and not taken
1240
+ * by another declaration (ZNIGHT061).
1241
+ */
1242
+ #claim(model: TypeModel, use: Use | undefined): boolean {
1243
+ const name = model.name
1244
+ const isRoot = model.key.startsWith('#root:')
1245
+ const location = model.location ?? use?.locate()
1246
+ let problem: string | undefined
1247
+ if (!isGraphQLName(name)) problem = `'${name}' is not a valid GraphQL type name`
1248
+ else if (!isRoot && ROOT_NAMES.has(name)) problem = `'${name}' is the name of a root operation type`
1249
+ else if (SPECIFIED_SCALARS.has(name)) problem = `'${name}' is the name of a specified GraphQL scalar`
1250
+ if (problem !== undefined) {
1251
+ this.#reportOnce(`name\0${model.key}`, {
1252
+ code: Codes.NameConflict,
1253
+ severity: 'error',
1254
+ message: `${problem} (${model.key.startsWith('#') ? name : model.key})`,
1255
+ ...(location === undefined ? {} : { location }),
1256
+ related: use === undefined ? [] : reachedFrom(use),
1257
+ hints: [isGraphQLName(name) ? 'Rename the declaration, or name it with /** @gqlName OtherName */' : NAME_HINT],
1258
+ })
1259
+ return false
1260
+ }
1261
+ const existing = this.#types.get(name)
1262
+ if (existing === undefined) {
1263
+ this.#types.set(name, model)
1264
+ return true
1265
+ }
1266
+ if (existing === model) return true
1267
+ const pair = [existing.key, model.key].sort(compareStrings).join('\0')
1268
+ this.#reportOnce(`conflict\0${pair}`, {
1269
+ code: Codes.NameConflict,
1270
+ severity: 'error',
1271
+ message: `Two types are named '${name}' in the GraphQL schema: ${describeKey(model)} and ${describeKey(existing)}`,
1272
+ ...(location === undefined ? {} : { location }),
1273
+ related: [
1274
+ ...(existing.location === undefined ? [] : [{ message: 'the other declaration', location: existing.location }]),
1275
+ ...(use === undefined ? [] : reachedFrom(use)),
1276
+ ],
1277
+ hints: ['Rename one of them, or name one with /** @gqlName OtherName */'],
1278
+ })
1279
+ return false
1280
+ }
1281
+
1282
+ // -------------------------------------------------------------------------------------- enums
1283
+
1284
+ /** A model met again: its reference, unless its name was refused (already reported). */
1285
+ #known(model: TypeModel): Inner | undefined {
1286
+ return this.#types.get(model.name) === model ? refTo(model.name) : undefined
1287
+ }
1288
+
1289
+ /** A TypeScript `enum`: names = member names, internal values = member values. */
1290
+ #enum(named: NamedShape, use: Use): Inner | undefined {
1291
+ const existing = this.#models.get(named.key)
1292
+ if (existing !== undefined) return this.#known(existing)
1293
+ const name = this.#typeName(named, use)
1294
+ if (name === undefined) return undefined
1295
+ const members = named.enumMembers
1296
+ if (members === undefined || members.length === 0) {
1297
+ return this.#fail(
1298
+ Codes.InvalidType,
1299
+ use,
1300
+ `The values of the enum ${named.name} cannot be evaluated (${use.coordinate})`,
1301
+ ['Use literal member values (A = "a", B = 1)'],
1302
+ )
1303
+ }
1304
+ const values: EnumValueModel[] = []
1305
+ for (const member of members) {
1306
+ if (!this.#checkEnumValueName(member.name, named, member.location, use)) continue
1307
+ values.push({
1308
+ name: member.name,
1309
+ value: member.value,
1310
+ description: descriptionOf(member.doc),
1311
+ deprecationReason: deprecationOf(member.doc),
1312
+ })
1313
+ }
1314
+ return this.#registerEnum(named, name, values, use)
1315
+ }
1316
+
1317
+ /** A named alias of a union of string literals: names = values = the literals, in source order. */
1318
+ #literalEnum(named: NamedShape, members: readonly Shape[], use: Use): Inner | undefined {
1319
+ const existing = this.#models.get(named.key)
1320
+ if (existing !== undefined) return this.#known(existing)
1321
+ const literals = named.literals ?? members.map(member => (member as Extract<Shape, { kind: 'literal' }>).value)
1322
+ if (!literals.every(value => typeof value === 'string')) {
1323
+ return this.#fail(
1324
+ Codes.InvalidType,
1325
+ use,
1326
+ `Cannot map ${named.name} (${literals.map(value => JSON.stringify(value)).join(' | ')}) to a GraphQL type (${use.coordinate}): only unions of string literals are enums`,
1327
+ ['Declare a TypeScript enum (enum Level { Low = 1, High = 2 }), or use Int'],
1328
+ )
1329
+ }
1330
+ const name = this.#typeName(named, use)
1331
+ if (name === undefined) return undefined
1332
+ const values: EnumValueModel[] = []
1333
+ for (const literal of literals as readonly string[]) {
1334
+ if (!this.#checkEnumValueName(literal, named, named.location, use)) continue
1335
+ values.push({ name: literal, value: literal, description: undefined, deprecationReason: undefined })
1336
+ }
1337
+ return this.#registerEnum(named, name, values, use)
1338
+ }
1339
+
1340
+ #checkEnumValueName(value: string, named: NamedShape, location: Location, use: Use): boolean {
1341
+ if (isGraphQLName(value) && !RESERVED_ENUM_VALUES.has(value)) return true
1342
+ this.#reportOnce(`enum\0${named.key}\0${value}`, {
1343
+ code: Codes.NameConflict,
1344
+ severity: 'error',
1345
+ message: `'${value}' of ${named.name} is not a valid GraphQL enum value`,
1346
+ location,
1347
+ related: reachedFrom(use),
1348
+ hints: [`${NAME_HINT}; true, false and null are reserved`],
1349
+ })
1350
+ return false
1351
+ }
1352
+
1353
+ #registerEnum(named: NamedShape, name: string, values: readonly EnumValueModel[], use: Use): Inner | undefined {
1354
+ const model: TypeModel = {
1355
+ kind: 'enum',
1356
+ name,
1357
+ key: named.key,
1358
+ description: descriptionOf(named.doc),
1359
+ location: named.location,
1360
+ values,
1361
+ }
1362
+ this.#models.set(named.key, model)
1363
+ if (!this.#claim(model, use)) return undefined
1364
+ return refTo(name)
1365
+ }
1366
+
1367
+ // ------------------------------------------------------------------------------------- unions
1368
+
1369
+ /** A named alias of a union of object types (output only), with its runtime discriminator. */
1370
+ #union(alias: NamedShape, members: readonly Shape[], use: Use): Inner | undefined {
1371
+ if (use.position === 'input') {
1372
+ return this.#fail(
1373
+ Codes.InvalidInput,
1374
+ use,
1375
+ `${use.coordinate} is the union ${alias.name}: unions cannot be input values`,
1376
+ ['Declare one input type with optional fields, or one argument per case'],
1377
+ )
1378
+ }
1379
+ const existing = this.#models.get(alias.key)
1380
+ if (existing !== undefined) return this.#known(existing)
1381
+ const name = this.#typeName(alias, use)
1382
+ if (name === undefined) return undefined
1383
+ const model: UnionModel = {
1384
+ kind: 'union',
1385
+ name,
1386
+ key: alias.key,
1387
+ description: descriptionOf(alias.doc),
1388
+ location: alias.location,
1389
+ members: [],
1390
+ discriminator: undefined,
1391
+ }
1392
+ this.#models.set(alias.key, model)
1393
+ if (!this.#claim(model, use)) return undefined
1394
+ const trail = `${use.trail} → ${name}`
1395
+ const objects: ObjectModel[] = []
1396
+ // a failed member is reported: no discriminator then (members that collapse into one object, as
1397
+ // `Book | BookAlias`, are not failures)
1398
+ let failed = false
1399
+ for (const member of members) {
1400
+ const memberUse: Use = { ...use, coordinate: name, trail, reached: trail, locate: () => alias.location }
1401
+ const converted = this.#inner(member, memberUse)
1402
+ if (converted === undefined) {
1403
+ failed = true
1404
+ continue
1405
+ }
1406
+ const target = converted.ref.kind === 'named' ? this.#types.get(converted.ref.name) : undefined
1407
+ if (target?.kind !== 'object') {
1408
+ failed = true
1409
+ this.#fail(
1410
+ Codes.InvalidType,
1411
+ memberUse,
1412
+ `The union ${name} contains ${describeShape(member)}, which is not an object type`,
1413
+ ['GraphQL unions only contain object types'],
1414
+ )
1415
+ continue
1416
+ }
1417
+ if (!objects.includes(target)) objects.push(target)
1418
+ }
1419
+ objects.sort((a, b) => compareStrings(a.name, b.name))
1420
+ model.members = objects.map(object => object.name)
1421
+ if (!failed) model.discriminator = this.#discriminator(model, objects)
1422
+ return refTo(name)
1423
+ }
1424
+
1425
+ /**
1426
+ * How the runtime tells the members apart (§5.7): `instanceof` when every member is an exported
1427
+ * class, a `__typename` property, a literal property with a distinct value per member, else
1428
+ * ZNIGHT064.
1429
+ */
1430
+ #discriminator(union: UnionModel, members: readonly ObjectModel[]): DiscriminatorModel | undefined {
1431
+ const found = discriminatorOf(members, identity => this.#ctx.classInfo(identity))
1432
+ if (found !== undefined) return found
1433
+ const names = members.map(member => member.name)
1434
+ const one = names.length === 1
1435
+ const listed = one ? (names[0] as string) : `${names.slice(0, -1).join(', ')} and ${names.at(-1) as string}`
1436
+ const allClasses = members.every(member => member.classIdentity !== undefined)
1437
+ let why: string
1438
+ if (allClasses) why = one ? 'is a class that is not exported' : 'are classes that are not all exported'
1439
+ else why = one ? 'has no literal property' : 'have no common literal property'
1440
+ this.#report({
1441
+ code: Codes.UnresolvableAbstractType,
1442
+ severity: 'error',
1443
+ message: `The members of union ${union.name} cannot be told apart at runtime: ${listed} ${why}`,
1444
+ ...(union.location === undefined ? {} : { location: union.location }),
1445
+ hints: [
1446
+ allClasses
1447
+ ? 'Export the member classes: the generated module imports them for instanceof'
1448
+ : `Add a literal property to each member (${members.map(member => `readonly kind: '${lowerFirst(member.name)}'`).join(' / ')}), or make the members classes`,
1449
+ ],
1450
+ })
1451
+ return undefined
1452
+ }
1453
+
1454
+ // --------------------------------------------------------------------------------- final checks
1455
+
1456
+ /** Defaults (`@default`, parameter defaults) against their types (ZNIGHT063), as external values. */
1457
+ #checkDefaults(): void {
1458
+ for (const pending of this.#defaults) {
1459
+ const result = externalValue(pending.value, pending.target.ref, this.#types)
1460
+ if ('error' in result) {
1461
+ const location = pending.location ?? pending.use.locate()
1462
+ this.#report({
1463
+ code: Codes.InvalidInput,
1464
+ severity: 'error',
1465
+ message: `Invalid default value ${describeValue(pending.value)} for ${pending.use.coordinate} (${pending.target.type}): ${result.error}`,
1466
+ ...(location === undefined ? {} : { location }),
1467
+ related: reachedFrom(pending.use),
1468
+ hints: ['Defaults are JSON values of the argument type (enum values as their member value or name)'],
1469
+ })
1470
+ continue
1471
+ }
1472
+ pending.target.defaultValue = result.value
1473
+ if (pending.target.ref.nonNull) pending.target.nullAsUndefined = false
1474
+ }
1475
+ }
1476
+
1477
+ /** GraphQL forbids a cycle of non-null input fields (ZNIGHT063). */
1478
+ #checkInputCycles(): void {
1479
+ const inputs = [...this.#models.values()]
1480
+ .filter((model): model is InputModel => model.kind === 'input')
1481
+ .sort((a, b) => compareStrings(a.name, b.name))
1482
+ const state = new Map<string, 'visiting' | 'done'>()
1483
+ const stack: string[] = []
1484
+ const visit = (model: InputModel): void => {
1485
+ state.set(model.name, 'visiting')
1486
+ stack.push(model.name)
1487
+ for (const field of model.fields) {
1488
+ if (field.ref.kind !== 'named' || !field.ref.nonNull) continue
1489
+ const target = this.#types.get(field.ref.name)
1490
+ if (target?.kind !== 'input') continue
1491
+ const status = state.get(target.name)
1492
+ if (status === 'visiting') {
1493
+ const cycle = [...stack.slice(stack.indexOf(target.name)), target.name]
1494
+ this.#report({
1495
+ code: Codes.InvalidInput,
1496
+ severity: 'error',
1497
+ message: `The input types ${cycle.join(' → ')} reference each other through non-null fields: no value can be written`,
1498
+ ...(target.location === undefined ? {} : { location: target.location }),
1499
+ hints: ['Make one of the fields of the cycle optional (?: T) or a list'],
1500
+ })
1501
+ } else if (status === undefined) {
1502
+ visit(target)
1503
+ }
1504
+ }
1505
+ stack.pop()
1506
+ state.set(model.name, 'done')
1507
+ }
1508
+ for (const model of inputs) if (!state.has(model.name)) visit(model)
1509
+ }
1510
+
1511
+ /** RFC 0001: every root field decides every required route decorator group (ZNIGHT023). */
1512
+ #checkDecisions(resolvers: readonly ResolverData[]): void {
1513
+ const applied: MetaEntry[] = []
1514
+ for (const resolver of resolvers) for (const field of resolver.fields) applied.push(...field.meta)
1515
+ const required = requiredRouteGroups(this.#ctx, applied)
1516
+ if (required.length === 0) return
1517
+ for (const resolver of resolvers) {
1518
+ for (const field of resolver.fields) {
1519
+ if (field.operation === 'field') continue
1520
+ const decided = new Set(field.meta.map(entry => entry.group))
1521
+ for (const group of required) {
1522
+ if (decided.has(group.group)) continue
1523
+ const root = ROOT_TYPE_NAMES[field.operation]
1524
+ this.#report({
1525
+ code: Codes.MissingRouteDecision,
1526
+ severity: 'error',
1527
+ message: `Root field ${root}.${field.name} (${resolver.host.displayName}.${field.method}) has no "${group.group}" decision`,
1528
+ location: field.methodLocation,
1529
+ hints: [`Add ${group.suggestion} to the method or to ${resolver.host.displayName}`],
1530
+ related: [
1531
+ {
1532
+ message: `The "${group.group}" group is required`,
1533
+ location: (group.decorators[0] as { definition: Location }).definition,
1534
+ },
1535
+ ],
1536
+ })
1537
+ }
1538
+ }
1539
+ }
1540
+ }
1541
+
1542
+ // ------------------------------------------------------------------------------------ assembly
1543
+
1544
+ #assemble(roots: ReadonlyMap<Operation, ObjectModel>, resolvers: readonly ResolverData[]): GraphQLModel {
1545
+ const types = [...this.#types.values()].sort((a, b) => compareStrings(a.name, b.name))
1546
+ const meta: MetaEntry[] = []
1547
+ for (const resolver of resolvers) for (const field of resolver.fields) meta.push(...field.meta)
1548
+ const rootNames = {
1549
+ query: ROOT_TYPE_NAMES.query,
1550
+ ...(roots.has('mutation') ? { mutation: ROOT_TYPE_NAMES.mutation } : {}),
1551
+ ...(roots.has('subscription') ? { subscription: ROOT_TYPE_NAMES.subscription } : {}),
1552
+ }
1553
+ const sdl = printSdl({ roots: rootNames, types: types.map(sdlType) })
1554
+ return { roots: rootNames, types, meta, sdl, schemaHash: schemaHash(sdl) }
1555
+ }
1556
+ }
1557
+
1558
+ // ------------------------------------------------------------------------------------------ helpers
1559
+
1560
+ /** The class (or one of its base classes) declares an instance `load` method. */
1561
+ function hasLoadMethod(ctx: Context, identity: string): boolean {
1562
+ const seen = new Set<string>()
1563
+ let current: string | undefined = identity
1564
+ while (current !== undefined && !seen.has(current)) {
1565
+ seen.add(current)
1566
+ const cls: ClassInfo | undefined = ctx.classInfo(current)
1567
+ if (cls === undefined) return true // not analysable (a compiled class): trust the type checker
1568
+ if (cls.methods.some(method => method.name === 'load' && method.kind === 'method' && !method.static)) return true
1569
+ if (cls.properties.some(property => property.name === 'load' && !property.static)) return true
1570
+ current = cls.superClass?.identity
1571
+ }
1572
+ return false
1573
+ }
1574
+
1575
+ /** A (nullable, until `#convert` decides) reference to a named type. */
1576
+ function refTo(name: string): Inner {
1577
+ return { ref: { kind: 'named', name, nonNull: false }, nullable: false }
1578
+ }
1579
+
1580
+ function reachedFrom(use: Use): { readonly message: string; readonly location: Location }[] {
1581
+ return use.reached === undefined || use.root === undefined
1582
+ ? []
1583
+ : [{ message: `reached from ${use.reached}`, location: use.root }]
1584
+ }
1585
+
1586
+ function decoratorName(operation: Operation): string {
1587
+ return ROOT_TYPE_NAMES[operation]
1588
+ }
1589
+
1590
+ /** The token of a binding result (`undefined` when binding failed). */
1591
+ function tokenOfRef(ref: ReturnType<Context['binding']>): string | undefined {
1592
+ if (typeof ref === 'string') return ref
1593
+ return ref !== undefined && 'token' in ref ? ref.token : undefined
1594
+ }
1595
+
1596
+ function describeKey(model: TypeModel): string {
1597
+ return model.key.startsWith('#') ? `the ${model.kind} ${model.name}` : model.key
1598
+ }
1599
+
1600
+ /** Short description of a shape for messages (`string`, `Book`, `Map<string, number>`). */
1601
+ function describeShape(shape: Shape): string {
1602
+ switch (shape.kind) {
1603
+ case 'literal':
1604
+ return JSON.stringify(shape.value)
1605
+ case 'named':
1606
+ return shape.key.slice(shape.key.indexOf('#') + 1)
1607
+ case 'reference':
1608
+ return shape.name
1609
+ case 'opaque':
1610
+ return shape.text
1611
+ case 'array':
1612
+ return `${describeShape(shape.items)}[]`
1613
+ case 'union':
1614
+ return shape.members.map(describeShape).join(' | ')
1615
+ case 'object':
1616
+ return 'an object literal type'
1617
+ default:
1618
+ return shape.kind
1619
+ }
1620
+ }
1621
+
1622
+ function describeValue(value: unknown): string {
1623
+ try {
1624
+ return JSON.stringify(value) ?? String(value)
1625
+ } catch {
1626
+ return String(value)
1627
+ }
1628
+ }
1629
+
1630
+ function containsRef(value: unknown): boolean {
1631
+ if (typeof value !== 'object' || value === null) return false
1632
+ if (Array.isArray(value)) return value.some(containsRef)
1633
+ const prototype: unknown = Object.getPrototypeOf(value)
1634
+ if (prototype !== Object.prototype && prototype !== null) return true
1635
+ return Object.values(value).some(containsRef)
1636
+ }
1637
+
1638
+ function lowerFirst(text: string): string {
1639
+ return `${text.charAt(0).toLowerCase()}${text.slice(1)}`
1640
+ }