@znight/graphql 0.2.0 → 0.2.2

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 +307 -11
  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 +14 -9
  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
@@ -1,4 +1,4 @@
1
- import { f as LoaderKey, m as LoaderValue, r as BatchLoader, t as BatchLoad } from "./types-CgbV9LjD.mjs";
1
+ import { _ as LoaderValue, h as LoaderKey, r as BatchLoader, t as BatchLoad } from "./types-DQ713UgI.mjs";
2
2
  import { GraphQLFormattedError } from "graphql";
3
3
  import { MockFunction, TestApp } from "@znight/testing";
4
4
  /** An operation for {@link executeOperation} and {@link subscribeOperation}. */
package/dist/testing.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as endpointOf, r as executorOf } from "./plugin-BdBxr5NQ.mjs";
1
+ import { n as endpointOf, r as executorOf } from "./plugin-CR_wVfRU.mjs";
2
2
  import { parseSseStream } from "@znight/http";
3
3
  import { resolveMockFn } from "@znight/testing";
4
4
  // src/testing.ts
@@ -213,6 +213,38 @@ interface GraphQLErrorMapper {
213
213
  }
214
214
  /** A class producing {@link GraphQLErrorMapper}s. */
215
215
  type GraphQLErrorMapperClass = abstract new (...args: never[]) => GraphQLErrorMapper;
216
+ /**
217
+ * Turns the errors of a field into values of the field (errors as data, RFC 0008). It applies to
218
+ * the resolver-backed fields whose type is a union holding one of the object types it declares
219
+ * (`static types`), decided at boot; every other field keeps its errors in `errors[]`.
220
+ *
221
+ * ```ts
222
+ * @Injectable()
223
+ * export class Rejections implements GraphQLResultMapper {
224
+ * static readonly types = ['NotFoundRejection']
225
+ *
226
+ * toResult(error: unknown): NotFoundRejection | undefined {
227
+ * if (error instanceof NotFoundException) return { __typename: 'NotFoundRejection', message: error.message }
228
+ * return undefined
229
+ * }
230
+ * }
231
+ * ```
232
+ */
233
+ interface GraphQLResultMapper {
234
+ /**
235
+ * The value of the field for `error`: an object of a member of the field's union, or `undefined`
236
+ * to leave the error to the next mapper, then to `errors[]`. Synchronous. `error` is the raw
237
+ * error, a `GraphQLException` included (a guard returning `false` is `FORBIDDEN`, a Standard
238
+ * Schema failure of the arguments `BAD_USER_INPUT` with `extensions.issues`).
239
+ */
240
+ toResult(error: unknown, ctx: ResolverContext): unknown;
241
+ }
242
+ /** The object types a result mapper may produce: names of object types of the schema. */
243
+ interface GraphQLResultTypes {
244
+ readonly types: readonly string[];
245
+ }
246
+ /** A class producing {@link GraphQLResultMapper}s, with the object types it produces (`static types`). */
247
+ type GraphQLResultMapperClass = (abstract new (...args: never[]) => GraphQLResultMapper) & GraphQLResultTypes;
216
248
  /**
217
249
  * The implementation of a custom scalar (§5.7), given to `scalar<T>(name, definition)`. The
218
250
  * runtime maps it to graphql-js 17 `GraphQLScalarType` coercers: `serialize` → `coerceOutputValue`,
@@ -230,5 +262,5 @@ interface ScalarDefinition<T> {
230
262
  /** Literal of the document → internal value. Default: `parseValue` of the literal's JSON value. */
231
263
  parseLiteral?(literal: ConstValueNode): T;
232
264
  }
233
- export { GraphQLErrorCode as _, GraphQLContext as a, GraphQLExceptionOptions as b, GraphQLGuard as c, GraphQLTransport as d, LoaderKey as f, ScalarDefinition as g, ResolverContext as h, GraphQLArgs as i, GraphQLGuardClass as l, LoaderValue as m, BatchLoadClass as n, GraphQLErrorMapper as o, LoaderOptions as p, BatchLoader as r, GraphQLErrorMapperClass as s, BatchLoad as t, GraphQLOperationKind as u, GraphQLErrorCodeValue as v, GraphQLException as y };
234
- //# sourceMappingURL=types-CgbV9LjD.d.mts.map
265
+ export { GraphQLExceptionOptions as C, GraphQLException as S, LoaderValue as _, GraphQLContext as a, GraphQLErrorCode as b, GraphQLGuard as c, GraphQLResultMapper as d, GraphQLResultMapperClass as f, LoaderOptions as g, LoaderKey as h, GraphQLArgs as i, GraphQLGuardClass as l, GraphQLTransport as m, BatchLoadClass as n, GraphQLErrorMapper as o, GraphQLResultTypes as p, BatchLoader as r, GraphQLErrorMapperClass as s, BatchLoad as t, GraphQLOperationKind as u, ResolverContext as v, GraphQLErrorCodeValue as x, ScalarDefinition as y };
266
+ //# sourceMappingURL=types-DQ713UgI.d.mts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znight/graphql",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Znight GraphQL: code-first GraphQL whose schema is derived from TypeScript types at build time, on graphql-js 17",
5
5
  "license": "MIT",
6
6
  "author": "Ugho STEPHAN",
@@ -40,19 +40,20 @@
40
40
  "scripts": {
41
41
  "build": "tsdown",
42
42
  "typecheck": "tsc --noEmit && tsc --noEmit -p test",
43
- "test": "bun test --conditions=@znight/source test"
43
+ "test": "bun test --conditions=@znight/source test",
44
+ "test:integration": "ZNIGHT_INTEGRATION=1 bun test --conditions=@znight/source --timeout 180000 test/recipes/redis-pubsub.integration.test.ts"
44
45
  },
45
46
  "publishConfig": {
46
47
  "access": "public"
47
48
  },
48
49
  "dependencies": {
49
- "@znight/core": "0.2.0",
50
+ "@znight/core": "0.2.2",
50
51
  "dataloader": "^2.2.3"
51
52
  },
52
53
  "peerDependencies": {
53
- "@znight/http": "^0.2.0",
54
- "@znight/compiler": "^0.2.0",
55
- "@znight/testing": "^0.2.0",
54
+ "@znight/http": "^0.2.2",
55
+ "@znight/compiler": "^0.2.2",
56
+ "@znight/testing": "^0.2.2",
56
57
  "graphql": "^17.0.2"
57
58
  },
58
59
  "peerDependenciesMeta": {
@@ -64,12 +65,16 @@
64
65
  }
65
66
  },
66
67
  "devDependencies": {
67
- "@znight/http": "0.2.0",
68
- "@znight/compiler": "0.2.0",
69
- "@znight/testing": "0.2.0",
68
+ "@testcontainers/redis": "12.2.1",
69
+ "@znight/compiler": "0.2.2",
70
+ "@znight/http": "0.2.2",
71
+ "@znight/testing": "0.2.2",
70
72
  "graphql": "17.0.2",
71
73
  "graphql-http": "1.23.1",
72
74
  "graphql-sse": "2.6.3",
75
+ "graphql-yoga": "6.0.1",
76
+ "ioredis": "6.0.0",
77
+ "testcontainers": "12.2.1",
73
78
  "valibot": "1.5.0",
74
79
  "zod": "4.6.7"
75
80
  },
@@ -18,7 +18,8 @@ import type {
18
18
  } from '@znight/compiler'
19
19
  import type { Scope } from '@znight/core'
20
20
  import type { MetaEntry } from '@znight/http/compiler'
21
- import type { FieldData, FieldOperation, GraphQLData, ParamData } from './data.ts'
21
+ import type { FieldData, FieldOperation, GraphQLData, ParamData, ResolverData } from './data.ts'
22
+ import type { ResolvedGraphQLCompilerOptions } from './options.ts'
22
23
 
23
24
  import { effectiveRouteMeta, readRouteMeta } from '@znight/http/compiler'
24
25
 
@@ -41,12 +42,17 @@ const SCOPES: readonly Scope[] = ['singleton', 'request', 'transient']
41
42
  /** Shape options reading `Loader<L>` parameters: the alias is kept, `L` stays a named shape. */
42
43
  const LOADER_SHAPE = Object.freeze({ keep: Object.freeze([GraphQLIds.Loader]), jsdoc: false })
43
44
 
44
- /** Analyses a class carrying at least one graphql decorator (class, method or parameter). */
45
- export function analyzeGraphQLClass(cls: ClassInfo, ctx: Context): void {
45
+ /**
46
+ * Analyses a class carrying at least one graphql decorator (class, method or parameter). In
47
+ * schema-first mode (RFC 0007), `@Resolver('Type')` names the SDL type of the field resolvers and
48
+ * `@Parent()` is optional.
49
+ */
50
+ export function analyzeGraphQLClass(cls: ClassInfo, ctx: Context, options: ResolvedGraphQLCompilerOptions): void {
46
51
  const resolvers = cls.decorators.filter(decorator => decorator.identity === GraphQLIds.Resolver)
47
52
  const resolver = resolvers[0]
48
53
  if (resolver === undefined) {
49
54
  reportOutsideResolver(cls, ctx)
55
+ storeClaims(ctx, cls, undefined, options)
50
56
  return
51
57
  }
52
58
  for (const extra of resolvers.slice(1)) {
@@ -66,6 +72,7 @@ export function analyzeGraphQLClass(cls: ClassInfo, ctx: Context): void {
66
72
  ],
67
73
  [{ message: '@Controller is applied here', location: controller.location }],
68
74
  )
75
+ storeClaims(ctx, cls, resolver, options)
69
76
  return
70
77
  }
71
78
  if (ctx.options.library) {
@@ -76,6 +83,7 @@ export function analyzeGraphQLClass(cls: ClassInfo, ctx: Context): void {
76
83
  `${cls.displayName} is a @Resolver in a compiled provider library: the schema is derived from the sources`,
77
84
  ['Publish the package as a source library (without library: true), so that applications compile its resolvers'],
78
85
  )
86
+ storeClaims(ctx, cls, resolver, options)
79
87
  return
80
88
  }
81
89
  if (cls.abstract || cls.typeParameters.length > 0) {
@@ -88,10 +96,11 @@ export function analyzeGraphQLClass(cls: ClassInfo, ctx: Context): void {
88
96
  : `@Resolver cannot be applied to the generic class ${cls.displayName}<${cls.typeParameters.join(', ')}>`,
89
97
  [cls.abstract ? 'Resolvers are instantiated: remove `abstract`' : 'Remove the type parameters of the resolver'],
90
98
  )
99
+ storeClaims(ctx, cls, resolver, options)
91
100
  return
92
101
  }
93
102
 
94
- const scope = resolverScope(ctx, resolver)
103
+ const { scope, typeName } = resolverArguments(ctx, resolver, options.schema !== false)
95
104
  ctx.registerProvider(cls, { scope })
96
105
  // resolvers are entry points of the plugin: roots of ZNIGHT019 and prune
97
106
  ctx.addRoot(cls, { reason: ROOT_REASON, location: resolver.location })
@@ -105,9 +114,79 @@ export function analyzeGraphQLClass(cls: ClassInfo, ctx: Context): void {
105
114
  reportStrayMethodDecorators(cls, method, ctx)
106
115
  continue
107
116
  }
108
- fields.push(...analyzeMethod(ctx, cls, method, decorators, classMeta))
117
+ fields.push(...analyzeMethod(ctx, cls, method, decorators, classMeta, options.schema !== false))
118
+ }
119
+ const data: ResolverData = {
120
+ kind: 'resolver',
121
+ host: cls,
122
+ scope,
123
+ location: resolver.location,
124
+ guards: classGuards,
125
+ fields,
126
+ ...(typeName === undefined ? {} : { typeName }),
127
+ }
128
+ if (options.schema !== false && typeName === undefined) {
129
+ for (const field of fields) {
130
+ if (field.operation !== 'field') continue
131
+ error(
132
+ ctx,
133
+ Codes.InvalidResolver,
134
+ field.location,
135
+ `@ResolveField ${cls.displayName}.${field.method} needs the type it resolves a field of: name it in @Resolver (schema-first)`,
136
+ [
137
+ `@Resolver('Event') on ${cls.displayName}, where Event is the object type of the SDL declaring '${field.name}'`,
138
+ ],
139
+ )
140
+ }
109
141
  }
110
- ctx.store({ kind: 'resolver', host: cls, scope, location: resolver.location, guards: classGuards, fields })
142
+ ctx.store(data)
143
+ }
144
+
145
+ /**
146
+ * Schema-first: a class rejected above (no `@Resolver`, abstract, a controller...) still names the
147
+ * fields its methods resolve, stored as an `invalid` resolver, so that these fields are not also
148
+ * reported as missing a resolver (one diagnostic per mistake).
149
+ */
150
+ function storeClaims(
151
+ ctx: Context,
152
+ cls: ClassInfo,
153
+ resolver: DecoratorInfo | undefined,
154
+ options: ResolvedGraphQLCompilerOptions,
155
+ ): void {
156
+ if (options.schema === false) return
157
+ const typeArg = resolver?.args[0]
158
+ const typeName =
159
+ typeArg?.evaluated === true && typeof typeArg.value === 'string'
160
+ ? { name: typeArg.value, location: typeArg.location }
161
+ : undefined
162
+ const fields: FieldData[] = []
163
+ for (const method of cls.methods) {
164
+ for (const decorator of method.decorators.filter(isFieldDecorator)) {
165
+ const arg = decorator.args[0]
166
+ fields.push({
167
+ method: method.name,
168
+ operation: FIELD_DECORATORS.get(decorator.identity as string) as FieldOperation,
169
+ name: arg?.evaluated === true && typeof arg.value === 'string' ? arg.value : method.name,
170
+ location: decorator.location,
171
+ methodLocation: method.location,
172
+ guards: [],
173
+ meta: [],
174
+ params: [],
175
+ complete: false,
176
+ })
177
+ }
178
+ }
179
+ if (fields.length === 0) return
180
+ ctx.store({
181
+ kind: 'resolver',
182
+ host: cls,
183
+ scope: 'singleton',
184
+ location: resolver?.location ?? cls.location,
185
+ guards: [],
186
+ fields,
187
+ invalid: true,
188
+ ...(typeName === undefined ? {} : { typeName }),
189
+ })
111
190
  }
112
191
 
113
192
  function isFieldDecorator(decorator: DecoratorInfo): boolean {
@@ -118,9 +197,47 @@ function isParameterDecorator(decorator: DecoratorInfo): boolean {
118
197
  return decorator.identity !== undefined && PARAMETER_DECORATORS.has(decorator.identity)
119
198
  }
120
199
 
121
- /** `@Resolver({ scope })`. */
122
- function resolverScope(ctx: Context, decorator: DecoratorInfo): Scope {
123
- const arg = decorator.args[0]
200
+ /** `@Resolver()`, `@Resolver({ scope })`, `@Resolver('Event')`, `@Resolver('Event', { scope })` (schema-first). */
201
+ function resolverArguments(
202
+ ctx: Context,
203
+ decorator: DecoratorInfo,
204
+ schemaFirst: boolean,
205
+ ): { scope: Scope; typeName?: { readonly name: string; readonly location: Location } } {
206
+ const [first, second] = decorator.args
207
+ if (first?.evaluated !== true || typeof first.value !== 'string') {
208
+ if (second !== undefined) {
209
+ error(ctx, Codes.InvalidDecorator, second.location, '@Resolver takes the options as its only argument here', [
210
+ "@Resolver({ scope: 'request' }), or @Resolver('Event', { scope: 'request' }) in schema-first mode",
211
+ ])
212
+ }
213
+ return { scope: resolverScope(ctx, first) }
214
+ }
215
+ const scope = resolverScope(ctx, second)
216
+ const name = first.value
217
+ if (!schemaFirst) {
218
+ error(
219
+ ctx,
220
+ Codes.InvalidDecorator,
221
+ first.location,
222
+ `@Resolver('${name}'): naming the type is schema-first only, the code-first schema is derived from the types`,
223
+ [
224
+ 'Remove the type name: @ResolveField methods add fields to the type of their @Parent() parameter',
225
+ "Or switch to schema-first: graphql.configure({ schema: 'src/**/*.graphql' }) in znight.config.ts",
226
+ ],
227
+ )
228
+ return { scope }
229
+ }
230
+ if (!isGraphQLName(name)) {
231
+ error(ctx, Codes.NameConflict, first.location, `'${name}' is not a valid GraphQL type name (@Resolver)`, [
232
+ 'GraphQL names match /^[_A-Za-z][_0-9A-Za-z]*$/ and do not start with __',
233
+ ])
234
+ return { scope }
235
+ }
236
+ return { scope, typeName: { name, location: first.location } }
237
+ }
238
+
239
+ /** The `{ scope }` options of `@Resolver`. */
240
+ function resolverScope(ctx: Context, arg: DecoratorInfo['args'][number] | undefined): Scope {
124
241
  if (arg === undefined || !arg.evaluated || arg.value === undefined) return 'singleton'
125
242
  const value = arg.value
126
243
  if (!isRecord(value)) {
@@ -171,6 +288,7 @@ function analyzeMethod(
171
288
  method: MethodInfo,
172
289
  decorators: readonly DecoratorInfo[],
173
290
  classMeta: readonly MetaEntry[],
291
+ schemaFirst: boolean,
174
292
  ): FieldData[] {
175
293
  const where = `${cls.displayName}.${method.name}`
176
294
  const primary = decorators[0] as DecoratorInfo
@@ -216,7 +334,8 @@ function analyzeMethod(
216
334
  }
217
335
  seen.add(key)
218
336
  let fieldComplete = complete && explicit !== undefined
219
- if (operation === 'field' && parents.length === 0) {
337
+ // schema-first: the type is named by @Resolver('Type'), the parent is optional (NestJS)
338
+ if (operation === 'field' && parents.length === 0 && !schemaFirst) {
220
339
  error(ctx, Codes.InvalidResolver, decorator.location, `@ResolveField ${where} has no @Parent() parameter`, [
221
340
  `Declare the parent object first: ${method.name}(@Parent() parent: ParentType, ...)`,
222
341
  ])
@@ -39,17 +39,16 @@ import type {
39
39
  } from './model.ts'
40
40
  import type { ResolvedGraphQLCompilerOptions } from './options.ts'
41
41
 
42
- import { requiredRouteGroups } from '@znight/http/compiler'
43
-
44
42
  import { isGraphQLName, RESERVED_ENUM_VALUES, ROOT_TYPE_NAMES, SPECIFIED_SCALARS } from '../names.ts'
45
43
  import { schemaHash } from '../schema-hash.ts'
46
44
  import { printSdl } from '../sdl.ts'
47
45
  import { printTypeRef } from '../type-ref.ts'
46
+ import { checkDecisions, checkLoaders } from './checks.ts'
48
47
  import { ASYNC_ITERABLES, BASE_KEEP, Codes, compareStrings, OUTPUT_ITERABLES } from './constants.ts'
49
48
  import { externalValue } from './defaults.ts'
50
49
  import { discriminatorOf } from './discriminators.ts'
51
50
  import { defaultTagValue, deprecationOf, descriptionOf, isHidden, paramDescription, tagOf } from './docs.ts'
52
- import { GraphQLIds, SCALAR_ALIASES } from './ids.ts'
51
+ import { SCALAR_ALIASES } from './ids.ts'
53
52
  import { annotationLocation } from './locate.ts'
54
53
  import { readScalars, type ScalarDeclaration } from './scalars.ts'
55
54
  import { sdlType } from './sdl-view.ts'
@@ -151,7 +150,7 @@ class SchemaBuilder {
151
150
  }
152
151
 
153
152
  build(resolvers: readonly ResolverData[]): GraphQLModel | undefined {
154
- this.#checkLoaders(resolvers)
153
+ checkLoaders(this.#ctx, resolvers, diagnostic => this.#report(diagnostic))
155
154
  this.#indexFieldResolvers(resolvers)
156
155
  const roots = new Map<Operation, ObjectModel>()
157
156
  const rootFields = new Map<string, { readonly field: FieldData; readonly resolver: ResolverData }>()
@@ -194,7 +193,7 @@ class SchemaBuilder {
194
193
  this.#reportUnreachableFieldResolvers()
195
194
  this.#checkDefaults()
196
195
  this.#checkInputCycles()
197
- this.#checkDecisions(resolvers)
196
+ checkDecisions(this.#ctx, resolvers, diagnostic => this.#report(diagnostic))
198
197
  if (this.#errors > 0 || !hasQuery) return undefined
199
198
  return this.#assemble(roots, resolvers)
200
199
  }
@@ -261,6 +260,7 @@ class SchemaBuilder {
261
260
  root: undefined,
262
261
  classIdentity: undefined,
263
262
  properties: [],
263
+ interfaces: [],
264
264
  }
265
265
  roots.set(operation, root)
266
266
  this.#claim(root, undefined)
@@ -511,34 +511,6 @@ class SchemaBuilder {
511
511
  ])
512
512
  }
513
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
514
  // ------------------------------------------------------------------------------ field resolvers
543
515
 
544
516
  #indexFieldResolvers(resolvers: readonly ResolverData[]): void {
@@ -1029,6 +1001,7 @@ class SchemaBuilder {
1029
1001
  ? structure.identity
1030
1002
  : undefined,
1031
1003
  properties: properties.map(property => this.#discriminant(property)),
1004
+ interfaces: [],
1032
1005
  }
1033
1006
  this.#models.set(key, model)
1034
1007
  if (!this.#claim(model, use)) return undefined
@@ -1508,37 +1481,6 @@ class SchemaBuilder {
1508
1481
  for (const model of inputs) if (!state.has(model.name)) visit(model)
1509
1482
  }
1510
1483
 
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
1484
  // ------------------------------------------------------------------------------------ assembly
1543
1485
 
1544
1486
  #assemble(roots: ReadonlyMap<Operation, ObjectModel>, resolvers: readonly ResolverData[]): GraphQLModel {
@@ -1557,21 +1499,6 @@ class SchemaBuilder {
1557
1499
 
1558
1500
  // ------------------------------------------------------------------------------------------ helpers
1559
1501
 
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
1502
  /** A (nullable, until `#convert` decides) reference to a named type. */
1576
1503
  function refTo(name: string): Inner {
1577
1504
  return { ref: { kind: 'named', name, nonNull: false }, nullable: false }
@@ -1587,12 +1514,6 @@ function decoratorName(operation: Operation): string {
1587
1514
  return ROOT_TYPE_NAMES[operation]
1588
1515
  }
1589
1516
 
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
1517
  function describeKey(model: TypeModel): string {
1597
1518
  return model.key.startsWith('#') ? `the ${model.kind} ${model.name}` : model.key
1598
1519
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Checks shared by the code-first schema builder (`builder.ts`) and the schema-first one
3
+ * (`schema-first.ts`): loader providers (ZNIGHT065) and the route decisions of root fields
4
+ * (ZNIGHT023, RFC 0001).
5
+ */
6
+ import type { ClassInfo, Diagnostic, Location, ValidateContext } from '@znight/compiler'
7
+ import type { MetaEntry } from '@znight/http/compiler'
8
+ import type { GraphQLData, ResolverData } from './data.ts'
9
+
10
+ import { requiredRouteGroups } from '@znight/http/compiler'
11
+
12
+ import { ROOT_TYPE_NAMES } from '../names.ts'
13
+ import { Codes } from './constants.ts'
14
+ import { GraphQLIds } from './ids.ts'
15
+
16
+ type Context = ValidateContext<GraphQLData>
17
+ type Report = (diagnostic: Diagnostic) => void
18
+
19
+ /** `Loader<L>`: `L` must be a loader provider, implementing `BatchLoad<K, V>` (ZNIGHT065). */
20
+ export function checkLoaders(ctx: Context, resolvers: readonly ResolverData[], report: Report): void {
21
+ const seen = new Set<number>()
22
+ for (const resolver of resolvers) {
23
+ for (const field of resolver.fields) {
24
+ for (const param of field.params) {
25
+ if (param?.kind !== 'loader' || seen.has(param.binding.id)) continue
26
+ seen.add(param.binding.id)
27
+ const ref = ctx.binding(param.binding)
28
+ const token = tokenOfRef(ref)
29
+ const provider = token === undefined ? undefined : ctx.provider(token)
30
+ // an unbound loader is ZNIGHT001 (reported by the binding)
31
+ if (provider === undefined || provider.heritage.some(type => type.identity === GraphQLIds.BatchLoad)) continue
32
+ if (provider.kind !== 'class' || hasLoadMethod(ctx, provider.classIdentity)) continue
33
+ report({
34
+ code: Codes.InvalidLoader,
35
+ severity: 'error',
36
+ message: `${provider.displayName} is not a loader: Loader<${provider.displayName}> needs a provider implementing BatchLoad<K, V> (${resolver.host.displayName}.${field.method})`,
37
+ location: param.location,
38
+ hints: [
39
+ `export class ${provider.displayName} implements BatchLoad<ID, Value> { async load(keys: readonly ID[]): Promise<readonly (Value | Error)[]> { ... } }`,
40
+ ],
41
+ })
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * RFC 0001: every root field decides every required route decorator group (ZNIGHT023). `roots`
49
+ * names the root types (`schema { query: RootQuery }` in schema-first).
50
+ */
51
+ export function checkDecisions(
52
+ ctx: Context,
53
+ resolvers: readonly ResolverData[],
54
+ report: Report,
55
+ roots: { readonly query: string; readonly mutation?: string; readonly subscription?: string } = ROOT_TYPE_NAMES,
56
+ ): void {
57
+ const applied: MetaEntry[] = []
58
+ for (const resolver of resolvers) for (const field of resolver.fields) applied.push(...field.meta)
59
+ const required = requiredRouteGroups(ctx, applied)
60
+ if (required.length === 0) return
61
+ for (const resolver of resolvers) {
62
+ for (const field of resolver.fields) {
63
+ if (field.operation === 'field') continue
64
+ const decided = new Set(field.meta.map(entry => entry.group))
65
+ for (const group of required) {
66
+ if (decided.has(group.group)) continue
67
+ const root = roots[field.operation] ?? ROOT_TYPE_NAMES[field.operation]
68
+ report({
69
+ code: Codes.MissingRouteDecision,
70
+ severity: 'error',
71
+ message: `Root field ${root}.${field.name} (${resolver.host.displayName}.${field.method}) has no "${group.group}" decision`,
72
+ location: field.methodLocation,
73
+ hints: [`Add ${group.suggestion} to the method or to ${resolver.host.displayName}`],
74
+ related: [
75
+ {
76
+ message: `The "${group.group}" group is required`,
77
+ location: (group.decorators[0] as { definition: Location }).definition,
78
+ },
79
+ ],
80
+ })
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ /** The class (or one of its base classes) declares an instance `load` method. */
87
+ function hasLoadMethod(ctx: Context, identity: string): boolean {
88
+ const seen = new Set<string>()
89
+ let current: string | undefined = identity
90
+ while (current !== undefined && !seen.has(current)) {
91
+ seen.add(current)
92
+ const cls: ClassInfo | undefined = ctx.classInfo(current)
93
+ if (cls === undefined) return true // not analysable (a compiled class): trust the type checker
94
+ if (cls.methods.some(method => method.name === 'load' && method.kind === 'method' && !method.static)) return true
95
+ if (cls.properties.some(property => property.name === 'load' && !property.static)) return true
96
+ current = cls.superClass?.identity
97
+ }
98
+ return false
99
+ }
100
+
101
+ /** The token of a binding result (`undefined` when binding failed). */
102
+ function tokenOfRef(ref: ReturnType<Context['binding']>): string | undefined {
103
+ if (typeof ref === 'string') return ref
104
+ return ref !== undefined && 'token' in ref ? ref.token : undefined
105
+ }