@znight/graphql 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -17
- package/dist/compiler.d.mts +21 -1
- package/dist/compiler.mjs +116 -15
- package/dist/compiler.mjs.map +1 -1
- package/dist/{types-DQ713UgI.d.mts → driver-DgbpFj4-.d.mts} +193 -9
- package/dist/http-BWcayUtH.mjs +2165 -0
- package/dist/http-BWcayUtH.mjs.map +1 -0
- package/dist/index.d.mts +52 -51
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/names-BlzDM5cF.mjs.map +1 -1
- package/dist/{plugin-CR_wVfRU.mjs → plugin-BssB7wtn.mjs} +242 -1778
- package/dist/plugin-BssB7wtn.mjs.map +1 -0
- package/dist/testing.d.mts +40 -1
- package/dist/testing.mjs +649 -4
- package/dist/testing.mjs.map +1 -1
- package/dist/yoga.d.mts +64 -0
- package/dist/yoga.mjs +379 -0
- package/dist/yoga.mjs.map +1 -0
- package/package.json +19 -10
- package/src/compiler/analyze.ts +27 -0
- package/src/compiler/data.ts +20 -1
- package/src/compiler/generate.ts +137 -19
- package/src/conformance.ts +700 -0
- package/src/context.ts +13 -0
- package/src/driver.ts +63 -0
- package/src/engine.ts +146 -0
- package/src/execution.ts +602 -67
- package/src/index.ts +7 -0
- package/src/limits.ts +22 -1
- package/src/metadata.ts +20 -4
- package/src/options.ts +53 -4
- package/src/pipeline.ts +72 -9
- package/src/plugin.ts +172 -29
- package/src/testing.ts +3 -0
- package/src/transport/builtin.ts +57 -0
- package/src/transport/fetch.ts +57 -0
- package/src/transport/http.ts +12 -9
- package/src/types.ts +22 -4
- package/src/yoga.ts +471 -0
- package/dist/plugin-CR_wVfRU.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -44,7 +44,8 @@ npm install -D @znight/compiler vite
|
|
|
44
44
|
|
|
45
45
|
Runs on Node.js >= 24 and Bun >= 1.3 (ESM only). `@znight/http` and `graphql` 17 are required peer
|
|
46
46
|
dependencies, `dataloader` is a dependency, `@znight/testing` an optional peer (for
|
|
47
|
-
`@znight/graphql/testing`)
|
|
47
|
+
`@znight/graphql/testing`), `graphql-yoga` 6 an optional peer (for the Yoga driver,
|
|
48
|
+
`@znight/graphql/yoga`).
|
|
48
49
|
|
|
49
50
|
## Contents
|
|
50
51
|
|
|
@@ -61,6 +62,7 @@ dependencies, `dataloader` is a dependency, `@znight/testing` an optional peer (
|
|
|
61
62
|
- [Errors as data](#errors-as-data)
|
|
62
63
|
- [Plugin options](#plugin-options)
|
|
63
64
|
- [GraphQL over HTTP and SSE](#graphql-over-http-and-sse)
|
|
65
|
+
- [Drivers and the engine](#drivers-and-the-engine)
|
|
64
66
|
- [Security defaults and limits](#security-defaults-and-limits)
|
|
65
67
|
- [Testing](#testing)
|
|
66
68
|
- [The generated SDL and the compiler options](#the-generated-sdl-and-the-compiler-options)
|
|
@@ -521,13 +523,14 @@ Built-in scalars appear in the schema only when used: `String`, `Float`, `Int`,
|
|
|
521
523
|
| Member | What |
|
|
522
524
|
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
523
525
|
| `requestId` | Id of the request carrying the operation (`HttpContext.requestId` over HTTP, a UUID or `requestId` option otherwise) |
|
|
524
|
-
| `transport` | `'http'`, `'sse'` or `'direct'` (`plugin.execute()` / `subscribe()`)
|
|
526
|
+
| `transport` | `'http'`, `'sse'` or `'direct'` (`plugin.execute()` / `subscribe()`); `'ws'` is reserved for `graphql-ws` |
|
|
525
527
|
| `http` | The `HttpContext` of the request, `undefined` for direct executions outside a request |
|
|
526
528
|
| `operationType` / `operationName` | `'query'`, `'mutation'` or `'subscription'`; the selected operation name |
|
|
527
529
|
| `document` / `variables` | The parsed document (shared by the document cache: never mutate it) and the coerced variables |
|
|
528
530
|
| `signal` | Over HTTP, the request's `ctx.signal`: client gone, SSE stream closed; passed to graphql-js as `abortSignal` |
|
|
529
531
|
| `state` | A `Map` for per-operation storage |
|
|
530
532
|
| `loader(Class \| token)` | The DataLoader of the current execution for a loader |
|
|
533
|
+
| `serverContext` | With a [driver](#drivers-and-the-engine), the context its server gave the engine; `undefined` otherwise |
|
|
531
534
|
|
|
532
535
|
- Resolvers receive it with `@Ctx()`; request-scoped providers inject `GraphQLContext`, singletons
|
|
533
536
|
`Lazy<GraphQLContext>`, exactly like `HttpContext`.
|
|
@@ -537,6 +540,32 @@ Built-in scalars appear in the schema only when used: `String`, `Float`, `Int`,
|
|
|
537
540
|
`plugin.execute()` and `plugin.subscribe()` run in the current scope when one is active (a
|
|
538
541
|
controller calling GraphQL), otherwise in a scope of their own.
|
|
539
542
|
|
|
543
|
+
### Custom parameter decorators
|
|
544
|
+
|
|
545
|
+
A decorator defined with `createParamDecorator` (`@znight/core`, RFC 0010) works on resolver
|
|
546
|
+
methods when it has a `graphql` resolver, which receives the field's `ResolverContext` (`parent`,
|
|
547
|
+
`args`, `info`, `operation`, `http`, `route.meta`). One definition serves HTTP routes and GraphQL
|
|
548
|
+
fields:
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
import { createParamDecorator } from '@znight/core'
|
|
552
|
+
import type { User } from './user.ts'
|
|
553
|
+
|
|
554
|
+
export const CurrentUser = createParamDecorator<User>({
|
|
555
|
+
http: ctx => ctx.state.get('user') as User,
|
|
556
|
+
graphql: ctx => ctx.operation.state.get('user') as User,
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
// @Query() me(@CurrentUser() user: User): User { return user }
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The resolver runs after the guards (it sees what they stored) and before the method; what it
|
|
563
|
+
throws fails the field like the method would (`errors[]`, or a result mapper). A custom parameter
|
|
564
|
+
is not a GraphQL argument: it never appears in the schema. Resolver classes implementing
|
|
565
|
+
`ParamResolver` are resolved in the operation scope. Custom parameters belong to the field
|
|
566
|
+
resolvers of the schema, so they work the same through the built-in transport, `plugin.execute()`
|
|
567
|
+
and a driver such as `yogaDriver()` (RFC 0009).
|
|
568
|
+
|
|
540
569
|
## Authorization
|
|
541
570
|
|
|
542
571
|
The route decorators of `@znight/http` (`createRouteDecorator`, with RFC 0001's groups) apply to
|
|
@@ -786,12 +815,13 @@ ZNIGHT066.
|
|
|
786
815
|
| `resultMappers` | `[]` | `GraphQLResultMapper` classes or instances, tried in order: errors as data on the fields whose union holds one of their `types` ([Errors as data](#errors-as-data)) |
|
|
787
816
|
| `exposeErrors` | `false` | Unmasked messages for unexpected errors (development) |
|
|
788
817
|
| `introspection` | `NODE_ENV !== 'production'` | Off: graphql-js `NoSchemaIntrospectionCustomRule`, and suggestions are hidden |
|
|
789
|
-
| `graphiql` | `NODE_ENV === 'development'` | `GET path` with `accept: text/html` serves GraphiQL
|
|
818
|
+
| `graphiql` | `NODE_ENV === 'development'` | `GET path` with `accept: text/html` serves GraphiQL; with a `driver`, the driver's IDE |
|
|
790
819
|
| `limits` | `{ maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 }` | Request limits (introspection fields exempt from `maxDepth`); `false` disables one |
|
|
791
820
|
| `documentCache` | `1000` | Distinct documents kept parsed and validated (LRU); `0` disables |
|
|
792
821
|
| `subscriptions` | `{ maxActive: 10_000, bufferSize: 100 }` | `false` disables subscriptions over SSE. `heartbeat` (ms or `false`) and `maxDuration` (ms) are passed to `HttpResponse.sse()` (default: the `HttpPlugin` `sse` options) |
|
|
793
822
|
| `pubsub` | a `MemoryPubSub` | The `PubSub` provided to the container |
|
|
794
823
|
| `enforcement` | `'error'` | How the boot check of field decisions reports a problem: `'error'` fails the boot (ZNIGHT023), `'warn'` logs |
|
|
824
|
+
| `driver` | the built-in transport | The GraphQL server serving the schema ([Drivers and the engine](#drivers-and-the-engine)); `heartbeat` and `maxDuration` are refused with one (ZNIGHT066) |
|
|
795
825
|
|
|
796
826
|
`HttpPlugin` must be registered unless `http: false` (ZNIGHT066). The plugin instance exposes:
|
|
797
827
|
|
|
@@ -803,14 +833,15 @@ ZNIGHT066.
|
|
|
803
833
|
| `subscribe(request, options?)` | A stream of results, one per event, until the source ends, `options.signal` aborts, `return()` is called or the application stops |
|
|
804
834
|
| `pubsub` | The `PubSub` of the application |
|
|
805
835
|
| `activeSubscriptions` | Active subscriptions, every transport |
|
|
806
|
-
| `
|
|
836
|
+
| `engine` | The `GraphQLEngine` drivers call (after bootstrap): [Drivers and the engine](#drivers-and-the-engine) |
|
|
837
|
+
| `initRoots()` / `httpRoutes()` | The `PluginInitRoots` contract (`TestApp`) and the `PluginHttpRoutes` contract of `@znight/http`, `onHttpDrain()` included |
|
|
807
838
|
|
|
808
839
|
`request` is `{ query, operationName?, variables?, extensions? }`; `options` is `{ signal?,
|
|
809
840
|
requestId? }`. A direct execution has no HTTP guards of its own: inside a controller, it runs in the
|
|
810
841
|
route's request scope, after the route's guards. With `http: false`, a controller can hand its
|
|
811
|
-
requests to another GraphQL server that delegates the execution to `execute()` and `subscribe()
|
|
812
|
-
the
|
|
813
|
-
|
|
842
|
+
requests to another GraphQL server that delegates the execution to `execute()` and `subscribe()`;
|
|
843
|
+
a [driver](#drivers-and-the-engine) does it without the copying, on the parsed document (GraphQL
|
|
844
|
+
Yoga: [`yogaDriver()`](#graphql-yoga)).
|
|
814
845
|
|
|
815
846
|
```ts
|
|
816
847
|
const plugin = app.getPlugin<GraphQLPlugin>('graphql')
|
|
@@ -818,10 +849,13 @@ const result = await plugin?.execute({ query: '{ books { total } }' })
|
|
|
818
849
|
```
|
|
819
850
|
|
|
820
851
|
`onStart` logs `GraphQL endpoint ready` with the path, the number of types and the schema hash
|
|
821
|
-
(`GraphQL schema ready` with `http: false`). On shutdown, `onStop` ends the direct subscriptions
|
|
822
|
-
`HttpPlugin` drains the requests in flight and closes the SSE streams
|
|
823
|
-
`'shutdown'`, then the stream ends with a jittered `retry` and **no**
|
|
824
|
-
reconnect to another instance).
|
|
852
|
+
(`GraphQL schema ready` with `http: false`). On shutdown, `onStop` ends the direct subscriptions
|
|
853
|
+
and those of the engine; `HttpPlugin` drains the requests in flight and closes the SSE streams
|
|
854
|
+
(their `ctx.signal` aborts with `'shutdown'`, then the stream ends with a jittered `retry` and **no**
|
|
855
|
+
`complete` event, so clients reconnect to another instance). When `HttpPlugin` starts draining, it
|
|
856
|
+
calls `GraphQLPlugin.onHttpDrain()`, which ends the engine's subscriptions too (a driver serves them
|
|
857
|
+
as streams `HttpPlugin` does not close) and refuses new ones (503). A route-mounted driver stops in
|
|
858
|
+
`onDispose`, once the drain is over: requests in flight are served. Plugin order does not matter.
|
|
825
859
|
|
|
826
860
|
## GraphQL over HTTP and SSE
|
|
827
861
|
|
|
@@ -863,6 +897,201 @@ Adapters need nothing specific. With Express, do not mount `express.json()` befo
|
|
|
863
897
|
(the body would be consumed). In tests, `TestApp.inject()` runs queries and mutations through every
|
|
864
898
|
adapter in memory and `injectStream()` / `injectSse()` stream subscriptions.
|
|
865
899
|
|
|
900
|
+
## Drivers and the engine
|
|
901
|
+
|
|
902
|
+
The built-in GraphQL-over-HTTP and SSE endpoint is the default and the fastest path. A **driver**
|
|
903
|
+
serves the same schema with another GraphQL server, for its protocol features and its plugins
|
|
904
|
+
(RFC 0009): Znight stays the engine, the driver owns the transport.
|
|
905
|
+
|
|
906
|
+
```ts
|
|
907
|
+
import { yogaDriver } from '@znight/graphql/yoga'
|
|
908
|
+
|
|
909
|
+
GraphQLPlugin.configure({ guards: [AccessGuard], driver: yogaDriver() })
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
Resolvers, guards, loaders, result mappers, request-scoped providers and tests do not change: the
|
|
913
|
+
server calls `plugin.engine`, which runs the compiled schema with everything this README describes.
|
|
914
|
+
**GraphQL Yoga** is the first driver ([`@znight/graphql/yoga`](#graphql-yoga)); **Apollo Server**
|
|
915
|
+
follows once it accepts graphql-js 17. Any other graphql-js-shaped server takes the engine as it
|
|
916
|
+
is: a driver is a small object, checked with the [conformance suite](#driver-conformance).
|
|
917
|
+
|
|
918
|
+
**The engine.** `plugin.engine` (after bootstrap, and the first argument of `driver.start()`):
|
|
919
|
+
|
|
920
|
+
| Member | What |
|
|
921
|
+
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
922
|
+
| `schema` | The executable schema; its resolvers are Znight's pipelines |
|
|
923
|
+
| `settings` | The resolved options a server needs: `path` (prefix included), `introspection`, `ide` (`graphiql`), `exposeErrors`, `maxTokens`, `subscriptions` (`false` or `{ maxActive }`), `http` (`false` or `{ get, bodyLimit }`) |
|
|
924
|
+
| `parse(source)` | graphql-js `parse` through the document cache, with `maxTokens` and the depth, aliases and directives limits; throws a `GraphQLError` (`GRAPHQL_PARSE_FAILED`, `QUERY_TOO_COMPLEX`) |
|
|
925
|
+
| `validate(schema, doc, rules?)` | graphql-js `validate` with `rules` (default `specifiedRules`) plus the engine's, suggestions hidden without introspection; a valid document is cached (default rules), its errors carry `GRAPHQL_VALIDATION_FAILED` |
|
|
926
|
+
| `rules` | The engine's own rules (the limits, `NoSchemaIntrospectionCustomRule` without introspection), for servers that validate themselves |
|
|
927
|
+
| `execute(args, options?)` | A validated query or mutation, in the current request scope (or one of its own): the `GraphQLContext` is the `contextValue`, `args.contextValue` its `serverContext`; errors mapped and masked, as `GraphQLError` instances |
|
|
928
|
+
| `subscribe(args, options?)` | A validated subscription: a stream of results (loaders renewed per event), or a single error result; the `maxActive` cap is shared with every transport, and the plugin ends the stream when the application stops |
|
|
929
|
+
|
|
930
|
+
`options` is `{ transport?: 'http' | 'sse' | 'ws', signal?, requestId? }`; the signal defaults to
|
|
931
|
+
`args.abortSignal`, then to the HTTP request's. The functions are bound and have graphql-js's
|
|
932
|
+
signatures, so they fit `graphql-http`, `graphql-sse`, `graphql-ws` and Envelop's `useEngine` as
|
|
933
|
+
they are. Every operation produces one `graphql:operation` event with the driver's name, and
|
|
934
|
+
documents rejected by `parse` or `validate` are observed too.
|
|
935
|
+
|
|
936
|
+
**The driver contract.** A `GraphQLDriver` has a `name` (`builtin` is reserved), optional
|
|
937
|
+
`routes()`, `start(engine, context)` and an optional `stop()`:
|
|
938
|
+
|
|
939
|
+
```ts
|
|
940
|
+
import type { HttpContext } from '@znight/http'
|
|
941
|
+
import type { GraphQLDriver } from '@znight/graphql'
|
|
942
|
+
|
|
943
|
+
import { HttpResponse } from '@znight/http'
|
|
944
|
+
import { createHandler, type Handler } from 'graphql-http'
|
|
945
|
+
|
|
946
|
+
/** graphql-http's handler on the engine: GraphQL over HTTP served by the reference implementation. */
|
|
947
|
+
export function graphqlHttpDriver(): GraphQLDriver {
|
|
948
|
+
let handler: Handler<HttpContext> | undefined
|
|
949
|
+
return {
|
|
950
|
+
name: 'graphql-http',
|
|
951
|
+
routes: ({ path }) => {
|
|
952
|
+
const handle = async (ctx: HttpContext) => {
|
|
953
|
+
const body = ctx.method === 'POST' ? new TextDecoder().decode(await ctx.rawBody()) : null
|
|
954
|
+
const [text, init] = await (handler as Handler<HttpContext>)({
|
|
955
|
+
method: ctx.method,
|
|
956
|
+
url: ctx.url,
|
|
957
|
+
headers: { get: name => ctx.header(name.toLowerCase()) ?? null },
|
|
958
|
+
body,
|
|
959
|
+
raw: ctx,
|
|
960
|
+
context: undefined,
|
|
961
|
+
})
|
|
962
|
+
return new HttpResponse(text ?? undefined, { status: init.status, headers: init.headers ?? {} })
|
|
963
|
+
}
|
|
964
|
+
return [
|
|
965
|
+
{ method: 'GET', path, name: 'graphql', handler: handle },
|
|
966
|
+
{ method: 'POST', path, name: 'graphql', handler: handle },
|
|
967
|
+
]
|
|
968
|
+
},
|
|
969
|
+
start(engine) {
|
|
970
|
+
handler = createHandler<HttpContext>({
|
|
971
|
+
schema: engine.schema,
|
|
972
|
+
parse: engine.parse,
|
|
973
|
+
validate: engine.validate,
|
|
974
|
+
execute: args => engine.execute(args),
|
|
975
|
+
})
|
|
976
|
+
},
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
- **Routes.** A route-mounted driver returns its routes: `GraphQLPlugin.httpRoutes()` gives them to
|
|
982
|
+
`HttpPlugin` with the `http` option's `meta` and `guards`, and its `bodyLimit` on the routes that
|
|
983
|
+
are not `GET`. They run in the HTTP pipeline like the built-in endpoint (request id, request
|
|
984
|
+
scope, `http:request` hooks, global and endpoint guards, route decisions, CORS, security headers,
|
|
985
|
+
drain), so the engine finds the request scope: `GraphQLContext.http` is the request, and HTTP
|
|
986
|
+
guards and GraphQL guards see the same request-scoped providers. `routes()` is called once,
|
|
987
|
+
possibly before `start()`. A driver without routes is server-mounted: it attaches itself to
|
|
988
|
+
`context.http.adapter` in `start()` and runs outside the pipeline.
|
|
989
|
+
- **Lifecycle.** `onBootstrap` builds the engine, then awaits `start(engine, { logger, container,
|
|
990
|
+
http })`; a failure fails the boot (ZNIGHT066, the original error as `cause`). The engine's
|
|
991
|
+
subscriptions end when `HttpPlugin` starts draining (`onHttpDrain()`, whatever the plugin order)
|
|
992
|
+
and in `onStop`; new ones are then refused (503). `stop()` is awaited in `onDispose` for a
|
|
993
|
+
route-mounted driver, after `HttpPlugin` drained its requests, and in `onStop` for a
|
|
994
|
+
server-mounted one (`onDispose` does, for an application that never started).
|
|
995
|
+
- **Options.** The engine options (`guards`, `errorMappers`, `resultMappers`, `exposeErrors`,
|
|
996
|
+
`introspection`, `limits`, `documentCache`, `subscriptions: false`, `maxActive`, `bufferSize`,
|
|
997
|
+
`pubsub`, `enforcement`) apply to every driver. `path`, `http` and `graphiql` are read by the
|
|
998
|
+
driver through its routes and `engine.settings`. `subscriptions.heartbeat` and `maxDuration`
|
|
999
|
+
configure the built-in SSE transport only: with a driver they are ZNIGHT066 (use the driver's
|
|
1000
|
+
keep-alives). A driver with routes needs `HttpPlugin` and refuses `http: false` (ZNIGHT066).
|
|
1001
|
+
- **Errors.** The engine maps and masks errors once; a driver keeps the engine's errors as they
|
|
1002
|
+
are. Request errors raised by the server itself (malformed JSON, a persisted query not found) are
|
|
1003
|
+
the server's; an unexpected error thrown outside the engine is masked like the engine masks one.
|
|
1004
|
+
- **Cost.** Without `driver`, the plugin runs the built-in transport's handlers, which call the
|
|
1005
|
+
internal executor directly: an application without driver pays nothing for the abstraction.
|
|
1006
|
+
|
|
1007
|
+
`executeOperation()` and `subscribeOperation()` of `@znight/graphql/testing` send their requests to
|
|
1008
|
+
the driver's routes like to the built-in endpoint. The repository's tests run the `graphql-http`
|
|
1009
|
+
audits against the driver above and the Yoga driver on the four adapters, on Node.js and Bun.
|
|
1010
|
+
|
|
1011
|
+
### GraphQL Yoga
|
|
1012
|
+
|
|
1013
|
+
`@znight/graphql/yoga` serves the schema with [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
|
|
1014
|
+
6 (`graphql-yoga`, an optional peer dependency loaded by this entry only):
|
|
1015
|
+
|
|
1016
|
+
```ts
|
|
1017
|
+
import { GraphQLPlugin } from '@znight/graphql'
|
|
1018
|
+
import { yogaDriver } from '@znight/graphql/yoga'
|
|
1019
|
+
|
|
1020
|
+
GraphQLPlugin.configure({ driver: yogaDriver({ plugins: [usePersistedOperations({ getPersistedOperation })] }) })
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
| Export | What |
|
|
1024
|
+
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
1025
|
+
| `yogaDriver(options?)` | The driver (`name: 'yoga'`); `driver.yoga` is the Yoga server once started |
|
|
1026
|
+
| `YogaDriverOptions` | Yoga's options without those Znight owns (below); `graphiql` takes GraphiQL's options |
|
|
1027
|
+
| `yogaContext(ctx)` | Yoga's context of an operation (`request`, `params`, what plugins added, `znight`: the `HttpContext`), ZNIGHT066 otherwise |
|
|
1028
|
+
| `YogaContext`, `YogaServerContext` | Their types |
|
|
1029
|
+
|
|
1030
|
+
- **The engine is Yoga's engine.** The driver installs Envelop's `useEngine` with the engine's
|
|
1031
|
+
`parse`, `validate`, `execute` and `subscribe`, before the application's plugins: one document
|
|
1032
|
+
cache (Yoga's `parserAndValidationCache` is off), the engine's limits and introspection rule,
|
|
1033
|
+
rejected documents observed by the hook, one `graphql:operation` event per operation with
|
|
1034
|
+
`driver: 'yoga'` (`transport: 'sse'` for subscriptions). Plugins wrapping the execution
|
|
1035
|
+
(`useResponseCache`) call the engine; an operation executed by a plugin that replaces it
|
|
1036
|
+
(`useGraphQlJit`) fails with ZNIGHT066, logged once: its resolvers would not get the
|
|
1037
|
+
`GraphQLContext`.
|
|
1038
|
+
- **Owned options**, refused with ZNIGHT066 (a type error first): `schema`, `context`,
|
|
1039
|
+
`graphqlEndpoint` (`path`), `maskedErrors` (the engine masks once, see below),
|
|
1040
|
+
`cors` (`HttpPlugin`'s), `maxRequestBodySize` (`http.bodyLimit`), `parserAndValidationCache`,
|
|
1041
|
+
`requestId` (Yoga uses `HttpContext.requestId`), `healthCheckEndpoint`, `landingPage`,
|
|
1042
|
+
`fetchAPI` (the runtime's own), `disposeOnProcessTerminate` (the application stops Yoga),
|
|
1043
|
+
`batching` (one operation per request: the operations of a batch would share the request scope,
|
|
1044
|
+
where each one's `GraphQLContext` would replace the other's) and `multipart` (JSON bodies only,
|
|
1045
|
+
below). Yoga's `logging` is off unless set.
|
|
1046
|
+
- **Routes.** `GET` and `POST` on `path`. `GET` serves Yoga's GraphiQL with `graphiql` on, and
|
|
1047
|
+
queries with `http.get` on (405 with `allow: POST` otherwise); a mutation over `GET` is Yoga's 405
|
|
1048
|
+
(`allow: POST`). A `POST` body is `application/json` only, like the built-in transport: any other
|
|
1049
|
+
content type (a form, `multipart/form-data`, `text/plain`, `application/graphql`) is the built-in
|
|
1050
|
+
transport's 415, before Yoga parses it, so a cross-site page cannot run a mutation with the
|
|
1051
|
+
user's cookies (no CSRF: a JSON body always needs a CORS preflight).
|
|
1052
|
+
- **Errors.** The engine's errors (masked already) and `GraphQLError`s thrown on purpose (a plugin's
|
|
1053
|
+
rate limit, Yoga's request errors) are sent as they are. Any other error thrown outside the engine
|
|
1054
|
+
(an Envelop plugin's hook, Yoga itself) is logged once by Znight and masked like the engine masks
|
|
1055
|
+
one: `Unexpected error.` with code `INTERNAL_SERVER_ERROR` (its message with `exposeErrors`), a
|
|
1056
|
+
500 for a request error.
|
|
1057
|
+
- **The Fetch bridge.** On `honoAdapter()` the native Web `Request` is handed to Yoga as is (a `GET`,
|
|
1058
|
+
or a body with a `content-length` within the limit); otherwise a `Request` is built from
|
|
1059
|
+
`HttpContext` with the body read by `ctx.rawBody()`, so a body past `http.bodyLimit` is
|
|
1060
|
+
`HttpPlugin`'s 413 on every adapter. Yoga's `Response` is sent as is by `HttpPlugin`.
|
|
1061
|
+
- **Subscriptions** use Yoga's SSE; the engine caps them (`subscriptions.maxActive`) and ends them
|
|
1062
|
+
when `HttpPlugin` starts draining, whatever the plugin order, when Yoga sends `complete` (not the
|
|
1063
|
+
built-in transport's `retry` hint); a subscription sent during the drain gets a
|
|
1064
|
+
`SERVICE_UNAVAILABLE` error. Queries in flight are served until the drain is over; `stop()` then
|
|
1065
|
+
awaits `yoga.dispose()` (the plugins' `onDispose`), and a request reaching the stopped driver gets
|
|
1066
|
+
a 503.
|
|
1067
|
+
- **Cost.** Yoga's request parsing, its plugin chain and the Web objects cost about 13 to 18 µs per
|
|
1068
|
+
request on Bun and 27 to 45 µs on Node.js in the in-process benchmark (`test/bench/http.bench.ts`),
|
|
1069
|
+
against about 1.6 µs and 5.5 µs for the built-in transport.
|
|
1070
|
+
|
|
1071
|
+
### Driver conformance
|
|
1072
|
+
|
|
1073
|
+
`describeDriverConformance()` of `@znight/graphql/testing` describes the scenarios every driver
|
|
1074
|
+
passes (RFC 0009 §12), with the test framework's own functions (`bun:test`, Vitest, Jest,
|
|
1075
|
+
`node:test`) and `node:assert`: queries, mutations, a field guard reading a request-scoped
|
|
1076
|
+
provider, loaders batching, request-scoped providers per request, result mappers, masking,
|
|
1077
|
+
introspection off, limits, HTTP guards on the endpoint, the hook, `POST` bodies other than JSON
|
|
1078
|
+
(415, never executed) and mutations over `GET` (405), and over SSE (unless `subscriptions: false`)
|
|
1079
|
+
events, completion, client abort, `maxActive` and shutdown in both plugin orders.
|
|
1080
|
+
|
|
1081
|
+
```ts
|
|
1082
|
+
import { describeDriverConformance } from '@znight/graphql/testing'
|
|
1083
|
+
import { yogaDriver } from '@znight/graphql/yoga'
|
|
1084
|
+
import { expressAdapter } from '@znight/http/express'
|
|
1085
|
+
import { honoAdapter } from '@znight/http/hono'
|
|
1086
|
+
import { afterAll, beforeAll, describe, it } from 'bun:test'
|
|
1087
|
+
|
|
1088
|
+
describeDriverConformance({
|
|
1089
|
+
driver: () => yogaDriver(), // a new driver per application; () => undefined for the built-in transport
|
|
1090
|
+
test: { describe, it, beforeAll, afterAll },
|
|
1091
|
+
adapters: { express: () => expressAdapter(), hono: () => honoAdapter() },
|
|
1092
|
+
})
|
|
1093
|
+
```
|
|
1094
|
+
|
|
866
1095
|
## Security defaults and limits
|
|
867
1096
|
|
|
868
1097
|
- **Introspection** off in production (`NODE_ENV === 'production'`), and suggestions hidden with it
|
|
@@ -1141,8 +1370,9 @@ Boot and execution are those of code-first.
|
|
|
1141
1370
|
## Observability
|
|
1142
1371
|
|
|
1143
1372
|
Every operation goes through the `graphql:operation` hook of `@znight/core` (`GraphQLOperationEvent`:
|
|
1144
|
-
`operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`,
|
|
1145
|
-
`
|
|
1373
|
+
`operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`, `driver`
|
|
1374
|
+
(`builtin`, or the name of the [driver](#drivers-and-the-engine)), `requestId`, `context`,
|
|
1375
|
+
`startTime`, then `outcome` (`success`, `partial`, `error`, `rejected`) and
|
|
1146
1376
|
`errors` with their codes). A subscription produces one event for its whole life. The event is built
|
|
1147
1377
|
only when the hook has wrappers or listeners, so it costs nothing otherwise.
|
|
1148
1378
|
|
|
@@ -1186,8 +1416,9 @@ on Node.js, use `node --conditions=@znight/source` instead of `bun`):
|
|
|
1186
1416
|
`GraphQLPlugin.execute()` over graphql-js `execute()` for a cached 50-field query (target: under
|
|
1187
1417
|
10 %; measured 0–9 % on Bun 1.4, 1–7 % on Node.js 24).
|
|
1188
1418
|
- `bun --conditions=@znight/source test/bench/http.bench.ts`: the endpoint through the `HttpPlugin`
|
|
1189
|
-
pipeline in process (`GET` and `POST`, both JSON media types), next to bare graphql-js
|
|
1190
|
-
controller route answering a constant
|
|
1419
|
+
pipeline in process (`GET` and `POST`, both JSON media types), next to bare graphql-js, to a
|
|
1420
|
+
controller route answering a constant, and to `GraphQLEngine.execute()` (what a driver calls: on
|
|
1421
|
+
a cached `{ hello }`, within 2 % of `plugin.execute()`). `ABORT_REASON=1` removes the cost graphql-js 17.0 adds to
|
|
1191
1422
|
every execution (a `DOMException` built when it aborts its own `AbortController`, about 3 µs on
|
|
1192
1423
|
Node.js): compare with other servers at the same graphql-js version.
|
|
1193
1424
|
|
|
@@ -1281,7 +1512,17 @@ interface GraphQLResolverBinding {
|
|
|
1281
1512
|
guards: Token[] // class @UseGuard guards, then method ones
|
|
1282
1513
|
meta: Record<string, unknown> // effective route metadata (method over class); {}: none
|
|
1283
1514
|
loaders?: Token[] // informational: loaders the method injects
|
|
1284
|
-
|
|
1515
|
+
context?: true // custom parameters (RFC 0010): the pipeline passes the ResolverContext as `f`
|
|
1516
|
+
paramResolvers?: Token[] // class resolvers of custom parameters, passed as `x`
|
|
1517
|
+
invoke(
|
|
1518
|
+
resolver,
|
|
1519
|
+
parent,
|
|
1520
|
+
args: GraphQLArgs,
|
|
1521
|
+
context: GraphQLContext,
|
|
1522
|
+
info: GraphQLResolveInfo,
|
|
1523
|
+
f?: ResolverContext,
|
|
1524
|
+
x?,
|
|
1525
|
+
): unknown
|
|
1285
1526
|
}
|
|
1286
1527
|
```
|
|
1287
1528
|
|
|
@@ -1293,7 +1534,9 @@ Type references are strings in graphql-js' canonical form, parsed by
|
|
|
1293
1534
|
|
|
1294
1535
|
`invoke` maps the graphql-js arguments to the method parameters: `@Args('x')` → `a['x']`,
|
|
1295
1536
|
`@Args()` → `a`, `@Parent()` → `p`, `@Ctx()` → `c`, `@Info()` → `i`, `@Loader()` →
|
|
1296
|
-
`c.loader(token)
|
|
1537
|
+
`c.loader(token)`, a custom parameter decorator (`createParamDecorator`, RFC 0010) →
|
|
1538
|
+
`(D.resolvers.graphql as ParamFunctionOf<typeof D, 'graphql'>)(f, data)` or `x[i].resolve(f, data)`
|
|
1539
|
+
(evaluated like a validated argument: synchronous while the resolvers return values). Unused middle parameters are `_`-prefixed and trailing ones omitted, so the code
|
|
1297
1540
|
type-checks with `noUnusedParameters`. It calls these runtime helpers, kept stable:
|
|
1298
1541
|
|
|
1299
1542
|
- `validateArgs(schema, value, label)`, exported from `@znight/graphql`: validates an argument with
|
|
@@ -1341,6 +1584,8 @@ are emitted by schema-first only: code-first sections, hashes and artifacts are
|
|
|
1341
1584
|
custom internal enum values (an enum value is its SDL name).
|
|
1342
1585
|
- **Subscriptions over SSE only.** No `graphql-ws` (WebSocket), no SSE single connection mode, no
|
|
1343
1586
|
multipart subscriptions.
|
|
1587
|
+
- **Drivers**: the built-in transport and GraphQL Yoga (`@znight/graphql/yoga`); Apollo Server once it
|
|
1588
|
+
accepts graphql-js 17. Yoga plugins that replace the execution (`useGraphQlJit`) are refused.
|
|
1344
1589
|
- No `@defer` / `@stream`, no persisted or trusted documents, no query cost analysis, no response
|
|
1345
1590
|
cache, no federation or schema stitching, no file uploads (use an HTTP upload route), no operation
|
|
1346
1591
|
batching.
|
package/dist/compiler.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Scope } from "@znight/core";
|
|
2
2
|
import { MetaEntry } from "@znight/http/compiler";
|
|
3
|
-
import { BindingHandle, ClassInfo, ClassRef, CompilerExtension, Location, ValueRef } from "@znight/compiler";
|
|
3
|
+
import { BindingHandle, ClassInfo, ClassRef, CompilerExtension, Location, StaticValue, ValueRef } from "@znight/compiler";
|
|
4
4
|
/** Kind of field a resolver method provides. */
|
|
5
5
|
type FieldOperation = "query" | "mutation" | "subscription" | "field";
|
|
6
6
|
/** A `@Resolver` class. */
|
|
@@ -71,6 +71,26 @@ type ParamData = {
|
|
|
71
71
|
/** Identity of `L` in `Loader<L>`. */
|
|
72
72
|
readonly identity: string;
|
|
73
73
|
readonly location: Location;
|
|
74
|
+
} | {
|
|
75
|
+
/** A custom parameter decorator (`createParamDecorator`, RFC 0010). */
|
|
76
|
+
readonly kind: "custom";
|
|
77
|
+
/** Canonical identity of the decorator `const` (imported by the generated module). */
|
|
78
|
+
readonly identity: string;
|
|
79
|
+
readonly name: string;
|
|
80
|
+
/** The data argument (a value, possibly holding references, or a reference); `undefined` without argument. */
|
|
81
|
+
readonly data: {
|
|
82
|
+
readonly value: StaticValue;
|
|
83
|
+
} | undefined;
|
|
84
|
+
/** The class resolver (`x[i].resolve(f, data)`); `undefined` for a function resolver. */
|
|
85
|
+
readonly resolver: ClassRef | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* Parameters of the class resolver's `resolve` (own, inherited or an arrow function property);
|
|
88
|
+
* `undefined` for a function, or when the compiler cannot read it (the call is cast).
|
|
89
|
+
*/
|
|
90
|
+
readonly arity?: number;
|
|
91
|
+
/** The value type could not be checked against the parameter. */
|
|
92
|
+
readonly cast: boolean;
|
|
93
|
+
readonly location: Location;
|
|
74
94
|
};
|
|
75
95
|
/** Everything the extension stores. */
|
|
76
96
|
type GraphQLData = ResolverData;
|
package/dist/compiler.mjs
CHANGED
|
@@ -417,6 +417,22 @@ function parameterDecorators(param) {
|
|
|
417
417
|
}
|
|
418
418
|
function analyzeParameter(ctx, cls, param, where) {
|
|
419
419
|
const decorators = parameterDecorators(param);
|
|
420
|
+
const custom = typeof ctx.paramDecorator === "function" ? ctx.paramDecorator(param, "graphql", cls) : void 0;
|
|
421
|
+
if (custom !== void 0) {
|
|
422
|
+
for (const other of decorators) error(ctx, Codes.InvalidDecorator, other.location, `Parameter '${param.name}' of ${where} has several parameter decorators (@${custom.decorator.name} and @${other.name})`, ["Keep a single parameter decorator"]);
|
|
423
|
+
if (!custom.ok || decorators.length > 0) return void 0;
|
|
424
|
+
const data = custom.data;
|
|
425
|
+
return {
|
|
426
|
+
kind: "custom",
|
|
427
|
+
identity: custom.identity,
|
|
428
|
+
name: custom.name,
|
|
429
|
+
data: data === void 0 ? void 0 : { value: data.ref ?? data.value },
|
|
430
|
+
resolver: custom.resolver.kind === "class" ? custom.resolver.ref : void 0,
|
|
431
|
+
...custom.resolver.kind === "class" && custom.resolver.arity !== void 0 ? { arity: custom.resolver.arity } : {},
|
|
432
|
+
cast: custom.cast,
|
|
433
|
+
location: custom.location
|
|
434
|
+
};
|
|
435
|
+
}
|
|
420
436
|
const decorator = decorators[0];
|
|
421
437
|
if (decorator === void 0) {
|
|
422
438
|
const lookAlike = param.decorators.find((found) => found.identity !== void 0 && HTTP_LOOK_ALIKES.has(found.identity));
|
|
@@ -2627,6 +2643,8 @@ function createImports(ctx) {
|
|
|
2627
2643
|
let args;
|
|
2628
2644
|
let context;
|
|
2629
2645
|
let info;
|
|
2646
|
+
let resolverContext;
|
|
2647
|
+
let paramFunctionOf;
|
|
2630
2648
|
let validateArgs;
|
|
2631
2649
|
let isThenable;
|
|
2632
2650
|
const hosts = /* @__PURE__ */ new Map();
|
|
@@ -2643,6 +2661,14 @@ function createImports(ctx) {
|
|
|
2643
2661
|
info ??= ctx.importFrom("graphql", "GraphQLResolveInfo", { type: true });
|
|
2644
2662
|
return info;
|
|
2645
2663
|
},
|
|
2664
|
+
resolverContextType: () => {
|
|
2665
|
+
resolverContext ??= ctx.importFrom(RUNTIME_MODULE, "ResolverContext", { type: true });
|
|
2666
|
+
return resolverContext;
|
|
2667
|
+
},
|
|
2668
|
+
paramFunctionOf: () => {
|
|
2669
|
+
paramFunctionOf ??= ctx.importFrom("@znight/core", "ParamFunctionOf", { type: true });
|
|
2670
|
+
return paramFunctionOf;
|
|
2671
|
+
},
|
|
2646
2672
|
validateArgs: () => {
|
|
2647
2673
|
validateArgs ??= ctx.importFrom(RUNTIME_MODULE, "validateArgs");
|
|
2648
2674
|
return validateArgs;
|
|
@@ -2734,7 +2760,10 @@ function resolverNode(ctx, imports, resolver, field) {
|
|
|
2734
2760
|
];
|
|
2735
2761
|
const loaders = field.params.flatMap((param) => param?.kind === "loader" ? [loaderToken(ctx, param)] : []);
|
|
2736
2762
|
if (loaders.length > 0) entries.push(["loaders", ctx.lit(loaders)]);
|
|
2737
|
-
|
|
2763
|
+
const resolvers = paramResolvers(field);
|
|
2764
|
+
if (field.params.some((param) => param?.kind === "custom")) entries.push(["context", ctx.lit(true)]);
|
|
2765
|
+
if (resolvers.length > 0) entries.push(["paramResolvers", ctx.lit(resolvers.map((ref) => ctx.tokenOf(ref) ?? ref.token))]);
|
|
2766
|
+
entries.push(["invoke", invokeNode(ctx, imports, resolver, field, resolvers)]);
|
|
2738
2767
|
return ctx.obj(entries);
|
|
2739
2768
|
}
|
|
2740
2769
|
/** The provider token a loader parameter is bound to. */
|
|
@@ -2744,13 +2773,28 @@ function loaderToken(ctx, param) {
|
|
|
2744
2773
|
if (ref !== void 0 && "token" in ref) return ref.token;
|
|
2745
2774
|
return param.identity;
|
|
2746
2775
|
}
|
|
2747
|
-
/**
|
|
2776
|
+
/** The provider classes resolving custom parameters, in order of first use (`x[i]`). */
|
|
2777
|
+
function paramResolvers(field) {
|
|
2778
|
+
const found = /* @__PURE__ */ new Map();
|
|
2779
|
+
for (const param of field.params) {
|
|
2780
|
+
const resolver = param?.kind === "custom" ? param.resolver : void 0;
|
|
2781
|
+
if (resolver !== void 0 && !found.has(resolver.token)) found.set(resolver.token, resolver);
|
|
2782
|
+
}
|
|
2783
|
+
return [...found.values()];
|
|
2784
|
+
}
|
|
2785
|
+
/** Which `invoke` parameters a field uses: `(r, p, a, c, i, f, x)`. */
|
|
2748
2786
|
function usedParameters(field) {
|
|
2749
2787
|
let p = false;
|
|
2750
2788
|
let a = false;
|
|
2751
2789
|
let c = false;
|
|
2752
2790
|
let i = false;
|
|
2791
|
+
let f = false;
|
|
2792
|
+
let x = false;
|
|
2753
2793
|
for (const param of field.params) switch (param?.kind) {
|
|
2794
|
+
case "custom":
|
|
2795
|
+
f ||= param.resolver === void 0 || param.arity !== 0;
|
|
2796
|
+
x ||= param.resolver !== void 0;
|
|
2797
|
+
break;
|
|
2754
2798
|
case "parent":
|
|
2755
2799
|
p = true;
|
|
2756
2800
|
break;
|
|
@@ -2767,43 +2811,63 @@ function usedParameters(field) {
|
|
|
2767
2811
|
p,
|
|
2768
2812
|
a,
|
|
2769
2813
|
c,
|
|
2770
|
-
i
|
|
2814
|
+
i,
|
|
2815
|
+
f,
|
|
2816
|
+
x
|
|
2771
2817
|
];
|
|
2772
2818
|
}
|
|
2773
2819
|
/**
|
|
2774
2820
|
* `(r: Resolver, p: unknown, a: GraphQLArgs, c: GraphQLContext, i: GraphQLResolveInfo) => r.m(...)`:
|
|
2775
2821
|
* unused middle parameters are `_`-prefixed, trailing ones omitted (`noUnusedParameters`).
|
|
2776
2822
|
*/
|
|
2777
|
-
function invokeNode(ctx, imports, resolver, field) {
|
|
2823
|
+
function invokeNode(ctx, imports, resolver, field, resolvers) {
|
|
2778
2824
|
const used = usedParameters(field);
|
|
2779
2825
|
const last = used.lastIndexOf(true);
|
|
2780
2826
|
const types = [
|
|
2781
2827
|
() => ctx.code`unknown`,
|
|
2782
2828
|
imports.argsType,
|
|
2783
2829
|
imports.contextType,
|
|
2784
|
-
imports.infoType
|
|
2830
|
+
imports.infoType,
|
|
2831
|
+
imports.resolverContextType,
|
|
2832
|
+
() => tupleType(ctx, resolvers.map((ref) => ctx.importType(ref)))
|
|
2785
2833
|
];
|
|
2786
2834
|
const names = [
|
|
2787
2835
|
"p",
|
|
2788
2836
|
"a",
|
|
2789
2837
|
"c",
|
|
2790
|
-
"i"
|
|
2838
|
+
"i",
|
|
2839
|
+
"f",
|
|
2840
|
+
"x"
|
|
2791
2841
|
];
|
|
2792
2842
|
const params = [["r", imports.hostType(resolver.host.identity)]];
|
|
2793
2843
|
for (let index = 0; index <= last; index++) params.push([`${used[index] ? "" : "_"}${names[index]}`, types[index]()]);
|
|
2794
|
-
const
|
|
2844
|
+
const deferred = field.params.flatMap((param, index) => param !== void 0 && isDeferred(param) ? [index] : []);
|
|
2845
|
+
const positions = new Map(resolvers.map((ref, position) => [ref.token, position]));
|
|
2795
2846
|
return ctx.arrow(params, (r, ...rest) => {
|
|
2796
|
-
const [p, a, c, i] = rest;
|
|
2847
|
+
const [p, a, c, i, f, x] = rest;
|
|
2797
2848
|
const scope = {
|
|
2798
2849
|
p,
|
|
2799
2850
|
a,
|
|
2800
2851
|
c,
|
|
2801
|
-
i
|
|
2852
|
+
i,
|
|
2853
|
+
f,
|
|
2854
|
+
x,
|
|
2855
|
+
resolvers: positions,
|
|
2856
|
+
imports
|
|
2802
2857
|
};
|
|
2803
|
-
if (
|
|
2804
|
-
return validatingBlock(ctx, imports, r, scope, field,
|
|
2858
|
+
if (deferred.length === 0) return call(ctx, r, field, (index) => argument(ctx, scope, field, index));
|
|
2859
|
+
return validatingBlock(ctx, imports, r, scope, field, deferred);
|
|
2805
2860
|
});
|
|
2806
2861
|
}
|
|
2862
|
+
/** `readonly [A, B]`. */
|
|
2863
|
+
function tupleType(ctx, nodes) {
|
|
2864
|
+
const joined = nodes.slice(1).reduce((all, node) => ctx.code`${all}, ${node}`, nodes[0]);
|
|
2865
|
+
return ctx.code`readonly [${joined}]`;
|
|
2866
|
+
}
|
|
2867
|
+
/** An argument whose expression may return a thenable: a validated `@Args`, or a custom parameter. */
|
|
2868
|
+
function isDeferred(param) {
|
|
2869
|
+
return param.kind === "args" && param.schema !== void 0 || param.kind === "custom";
|
|
2870
|
+
}
|
|
2807
2871
|
/** `r.method(args)`. */
|
|
2808
2872
|
function call(ctx, r, field, argumentOf) {
|
|
2809
2873
|
return ctx.call(ctx.member(r, field.method), field.params.map((_, index) => argumentOf(index)));
|
|
@@ -2817,9 +2881,48 @@ function argument(ctx, scope, field, index) {
|
|
|
2817
2881
|
case "info": return scope.i;
|
|
2818
2882
|
case "loader": return ctx.code`${ctx.call(ctx.member(scope.c, "loader"), [loaderToken(ctx, param)])} as never`;
|
|
2819
2883
|
case "args": return ctx.code`${argsValue(ctx, scope, param.name)} as never`;
|
|
2884
|
+
case "custom": return customArgument(ctx, scope, param);
|
|
2820
2885
|
default: return ctx.lit(void 0);
|
|
2821
2886
|
}
|
|
2822
2887
|
}
|
|
2888
|
+
/**
|
|
2889
|
+
* A custom parameter (RFC 0010): `(D.resolvers.graphql as ParamFunctionOf<typeof D, 'graphql'>)(f, data)`
|
|
2890
|
+
* or `x[i].resolve(f, data)`, cast with `as never` when the value type could not be checked.
|
|
2891
|
+
*/
|
|
2892
|
+
function customArgument(ctx, scope, param) {
|
|
2893
|
+
const f = scope.f;
|
|
2894
|
+
const data = param.data === void 0 ? ctx.lit(void 0) : dataNode(ctx, param.data.value);
|
|
2895
|
+
let node;
|
|
2896
|
+
if (param.resolver === void 0) {
|
|
2897
|
+
const decorator = ctx.importValue(param.identity);
|
|
2898
|
+
const type = ctx.code`${scope.imports.paramFunctionOf()}<typeof ${decorator}, 'graphql'>`;
|
|
2899
|
+
node = ctx.call(ctx.code`${ctx.member(decorator, "resolvers", "graphql")} as ${type}`, [f, data]);
|
|
2900
|
+
} else {
|
|
2901
|
+
const position = scope.resolvers.get(param.resolver.token);
|
|
2902
|
+
if (param.arity === void 0) return ctx.code`(${scope.x}[${position}] as { resolve(ctx: unknown, data: unknown): unknown }).resolve(${f}, ${data}) as never`;
|
|
2903
|
+
const arity = Math.min(param.arity, 2);
|
|
2904
|
+
let args = ctx.code``;
|
|
2905
|
+
if (arity === 1) args = f;
|
|
2906
|
+
else if (arity === 2) args = ctx.code`${f}, ${data}`;
|
|
2907
|
+
node = ctx.code`${scope.x}[${position}].resolve(${args})`;
|
|
2908
|
+
}
|
|
2909
|
+
return param.cast ? ctx.code`${node} as never` : node;
|
|
2910
|
+
}
|
|
2911
|
+
/** The data argument of a custom parameter: a literal (references imported) or a reference. */
|
|
2912
|
+
function dataNode(ctx, value) {
|
|
2913
|
+
return isValueRef(value) ? ctx.ref(value) : ctx.lit(value);
|
|
2914
|
+
}
|
|
2915
|
+
/** `true` for a {@link ValueRef} (the only non-plain object a static value can hold). */
|
|
2916
|
+
function isValueRef(value) {
|
|
2917
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
2918
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2919
|
+
return prototype !== Object.prototype && prototype !== null && value.kind === "ref";
|
|
2920
|
+
}
|
|
2921
|
+
/** The expression of a deferred parameter (validation or custom resolver), before any `await`. */
|
|
2922
|
+
function deferredValue(ctx, imports, scope, field, index) {
|
|
2923
|
+
const param = field.params[index];
|
|
2924
|
+
return param.kind === "custom" ? customArgument(ctx, scope, param) : validation(ctx, imports, scope, param);
|
|
2925
|
+
}
|
|
2823
2926
|
/** `a['name']`, or `a` for `@Args()`. */
|
|
2824
2927
|
function argsValue(ctx, scope, name) {
|
|
2825
2928
|
const a = scope.a;
|
|
@@ -2853,15 +2956,13 @@ function validatingBlock(ctx, imports, r, scope, field, validated) {
|
|
|
2853
2956
|
const statements = [];
|
|
2854
2957
|
for (const index of validated) {
|
|
2855
2958
|
const name = local.get(index);
|
|
2856
|
-
const
|
|
2857
|
-
statements.push(ctx.code`const ${name} = ${validation(ctx, imports, scope, param)}`);
|
|
2959
|
+
statements.push(ctx.code`const ${name} = ${deferredValue(ctx, imports, scope, field, index)}`);
|
|
2858
2960
|
const resume = ctx.arrow([], () => call(ctx, r, field, (position) => {
|
|
2859
2961
|
const value = local.get(position);
|
|
2860
2962
|
if (value === void 0) return argument(ctx, scope, field, position);
|
|
2861
2963
|
if (position < index) return ctx.code`${value} as never`;
|
|
2862
2964
|
if (position === index) return ctx.code`await ${value} as never`;
|
|
2863
|
-
|
|
2864
|
-
return ctx.code`await ${validation(ctx, imports, scope, later)} as never`;
|
|
2965
|
+
return ctx.code`await ${deferredValue(ctx, imports, scope, field, position)} as never`;
|
|
2865
2966
|
}), { async: true });
|
|
2866
2967
|
statements.push(ctx.code`if (${ctx.call(imports.isThenable(), [name])}) return (${resume})()`);
|
|
2867
2968
|
}
|