@bhooai/nexus-graphql 0.1.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.
@@ -0,0 +1,203 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import type { IncomingMessage, Server as HttpServer } from 'node:http';
3
+ import type { Duplex } from 'node:stream';
4
+ import { parse, validate } from 'graphql';
5
+ import type { AuthService } from '@bhooai/nexus-auth';
6
+ import type { Gateway, GraphQLContext } from '../types.js';
7
+
8
+ export interface SubscriptionServerOptions {
9
+ httpServer: HttpServer;
10
+ gateway: Gateway;
11
+ /** WS path (default '/graphql/ws'). */
12
+ path?: string;
13
+ /** Optional auth: verifies access token from connection_init payload.token or ?token=. */
14
+ authService?: AuthService;
15
+ /** Build per-connection resolver context. Default: `{ user }` from auth. */
16
+ context?: (init: Record<string, unknown> | undefined, user: GraphQLContext['user']) => GraphQLContext | Promise<GraphQLContext>;
17
+ /** Reject unauthenticated connections (default true when authService provided). */
18
+ requireAuth?: boolean;
19
+ }
20
+
21
+ type ClientMsg =
22
+ | { type: 'connection_init'; payload?: Record<string, unknown> }
23
+ | { type: 'subscribe'; id: string; payload: { query: string; variables?: Record<string, any>; operationName?: string } }
24
+ | { type: 'complete'; id: string }
25
+ | { type: 'ping' }
26
+ | { type: 'pong' };
27
+
28
+ interface ActiveSub {
29
+ iterator: AsyncIterator<unknown>;
30
+ done: boolean;
31
+ }
32
+
33
+ const PROTOCOL = 'graphql-transport-ws';
34
+
35
+ /**
36
+ * GraphQL subscriptions over WebSocket (the `graphql-transport-ws` protocol).
37
+ * Attaches to an HTTP server's 'upgrade' event. On `connection_init` we
38
+ * (optionally) authenticate; on `subscribe` we run the gateway's `subscribe()`
39
+ * and stream each `next` result back until the client sends `complete` or the
40
+ * socket closes.
41
+ */
42
+ export class SubscriptionServer {
43
+ private wss: WebSocketServer;
44
+ private opts: SubscriptionServerOptions;
45
+
46
+ constructor(opts: SubscriptionServerOptions) {
47
+ this.opts = opts;
48
+ this.wss = new WebSocketServer({ noServer: true });
49
+ this.opts.httpServer.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
50
+ }
51
+
52
+ private async handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
53
+ const url = new URL(req.url ?? '/', 'http://localhost');
54
+ if (url.pathname !== (this.opts.path ?? '/graphql/ws')) return;
55
+
56
+ const protocols = (req.headers['sec-websocket-protocol'] ?? '').toString().split(',').map((s) => s.trim());
57
+ if (!protocols.includes(PROTOCOL)) {
58
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
59
+ socket.destroy();
60
+ return;
61
+ }
62
+
63
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
64
+ this.handleConnection(ws, url);
65
+ });
66
+ }
67
+
68
+ private async handleConnection(ws: WebSocket, url: URL): Promise<void> {
69
+ let user: GraphQLContext['user'];
70
+ let acknowledged = false;
71
+ const active = new Map<string, ActiveSub>();
72
+
73
+ const send = (msg: unknown) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg)); };
74
+
75
+ const cleanup = async () => {
76
+ for (const [, sub] of active) {
77
+ if (!sub.done) { sub.done = true; try { await sub.iterator.return?.(); } catch { /* ignore */ } }
78
+ }
79
+ active.clear();
80
+ };
81
+
82
+ // If auth is required and no token is supplied via ?token=, we can reject
83
+ // early; but the protocol sends the token in connection_init, so we wait.
84
+ ws.on('message', async (data) => {
85
+ let msg: ClientMsg;
86
+ try { msg = JSON.parse(data.toString('utf8')) as ClientMsg; } catch { return; }
87
+
88
+ switch (msg.type) {
89
+ case 'connection_init': {
90
+ try {
91
+ user = await this.authenticate(msg.payload, url);
92
+ } catch (err) {
93
+ send({ type: 'error', id: '', payload: [{ message: (err as Error).message }] });
94
+ ws.close(4401, 'Unauthorized');
95
+ return;
96
+ }
97
+ acknowledged = true;
98
+ send({ type: 'connection_ack' });
99
+ return;
100
+ }
101
+ case 'ping':
102
+ send({ type: 'pong' });
103
+ return;
104
+ case 'pong':
105
+ return;
106
+ case 'subscribe': {
107
+ if (!acknowledged) { send({ type: 'error', id: msg.id, payload: [{ message: 'connection not acknowledged' }] }); return; }
108
+ await this.runSubscription(msg, ws, active, user);
109
+ return;
110
+ }
111
+ case 'complete': {
112
+ const sub = active.get(msg.id);
113
+ if (sub && !sub.done) { sub.done = true; try { await sub.iterator.return?.(); } catch { /* ignore */ } }
114
+ active.delete(msg.id);
115
+ return;
116
+ }
117
+ }
118
+ });
119
+
120
+ ws.on('close', () => void cleanup());
121
+ ws.on('error', () => void cleanup());
122
+ }
123
+
124
+ private async authenticate(payload: Record<string, unknown> | undefined, url: URL): Promise<GraphQLContext['user']> {
125
+ const requireAuth = this.opts.requireAuth ?? !!this.opts.authService;
126
+ if (!this.opts.authService) {
127
+ if (requireAuth) throw new Error('Authentication required but no authService configured');
128
+ return undefined;
129
+ }
130
+ const token = (payload?.token as string | undefined) ?? url.searchParams.get('token');
131
+ if (!token) {
132
+ if (requireAuth) throw new Error('Missing access token');
133
+ return undefined;
134
+ }
135
+ const claims = await this.opts.authService.verifyAccessToken(token);
136
+ return { sub: claims.sub, roles: claims.roles ?? [], sid: claims.sid };
137
+ }
138
+
139
+ private async runSubscription(
140
+ msg: { type: 'subscribe'; id: string; payload: { query: string; variables?: Record<string, any>; operationName?: string } },
141
+ ws: WebSocket,
142
+ active: Map<string, ActiveSub>,
143
+ user: GraphQLContext['user'],
144
+ ): Promise<void> {
145
+ const send = (m: unknown) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(m)); };
146
+
147
+ let document;
148
+ try {
149
+ document = parse(msg.payload.query);
150
+ } catch (err) {
151
+ send({ type: 'error', id: msg.id, payload: [{ message: (err as Error).message }] });
152
+ return;
153
+ }
154
+
155
+ const errors = validate(this.opts.gateway.schema, document);
156
+ if (errors.length) {
157
+ send({ type: 'error', id: msg.id, payload: errors.map((e) => ({ message: e.message })) });
158
+ return;
159
+ }
160
+
161
+ const contextValue = this.opts.context
162
+ ? await this.opts.context(undefined, user)
163
+ : { user };
164
+
165
+ let stream;
166
+ try {
167
+ stream = await this.opts.gateway.subscribe({
168
+ document,
169
+ variableValues: msg.payload.variables,
170
+ operationName: msg.payload.operationName,
171
+ contextValue,
172
+ });
173
+ } catch (err) {
174
+ send({ type: 'error', id: msg.id, payload: [{ message: (err as Error).message }] });
175
+ return;
176
+ }
177
+
178
+ const sub: ActiveSub = { iterator: stream[Symbol.asyncIterator](), done: false };
179
+ active.set(msg.id, sub);
180
+
181
+ // Pump results asynchronously; stop when the client completes or the stream ends.
182
+ void (async () => {
183
+ try {
184
+ while (!sub.done) {
185
+ const { value, done } = await sub.iterator.next();
186
+ if (done) break;
187
+ if (ws.readyState !== ws.OPEN) break;
188
+ send({ type: 'next', id: msg.id, payload: value });
189
+ }
190
+ if (!sub.done && ws.readyState === ws.OPEN) send({ type: 'complete', id: msg.id });
191
+ } catch (err) {
192
+ send({ type: 'error', id: msg.id, payload: [{ message: (err as Error).message }] });
193
+ } finally {
194
+ active.delete(msg.id);
195
+ }
196
+ })();
197
+ }
198
+
199
+ close(): Promise<void> {
200
+ this.wss.close();
201
+ return Promise.resolve();
202
+ }
203
+ }
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { GraphQLSchema, ExecutionResult, DocumentNode } from 'graphql';
2
+
3
+ /** A field resolver: (parent, args, context, info) => value | Promise<value>. */
4
+ export type Resolver = (parent: any, args: Record<string, any>, context: GraphQLContext, info: any) => any | Promise<any>;
5
+
6
+ /** A subscribe resolver returning an AsyncIterable of event payloads. */
7
+ export type SubscribeResolver = (parent: any, args: Record<string, any>, context: GraphQLContext, info: any) => AsyncIterable<any> | Promise<AsyncIterable<any>>;
8
+
9
+ /** Resolvers for a type. Fields may have a plain resolver or `{ resolve, subscribe }` (subscriptions). */
10
+ export type TypeResolvers = Record<string, Resolver | { resolve?: Resolver; subscribe?: SubscribeResolver }>;
11
+
12
+ export interface Resolvers {
13
+ [typeName: string]: TypeResolvers;
14
+ }
15
+
16
+ /** Per-request context passed to every resolver. */
17
+ export interface GraphQLContext {
18
+ /** The raw HTTP/WS request (for auth headers, ip, etc.). */
19
+ request?: unknown;
20
+ /** Authenticated user payload (sub, roles, sid) when auth is applied. */
21
+ user?: { sub: string; roles: string[]; sid?: string };
22
+ /** Anything the host wants to inject (DB connections, services, dataloaders). */
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ /** Entity representation: `__typename` + the key fields of an entity. */
27
+ export interface EntityRepresentation {
28
+ __typename: string;
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ /** Resolves an entity by its representation. Returns the object (with fields) or null. */
33
+ export type EntityResolver = (representation: EntityRepresentation, context: GraphQLContext) => Promise<any | null> | any | null;
34
+
35
+ export interface DefineSubgraphOptions {
36
+ /** Subgraph name (used as the `__typename` source id and in composition). */
37
+ name: string;
38
+ /** The subgraph SDL (user types + federation directives like `@key`). */
39
+ typeDefs: string;
40
+ /** Field resolvers. */
41
+ resolvers?: Resolvers;
42
+ /** Entity resolver for `_entities`. Defaults to returning the representation as-is. */
43
+ entityResolver?: EntityResolver;
44
+ }
45
+
46
+ /** A built subgraph, ready to be composed or executed in-process. */
47
+ export interface Subgraph {
48
+ name: string;
49
+ /** Executable schema including federation boilerplate (_service, _entities). */
50
+ schema: GraphQLSchema;
51
+ /** The subgraph SDL (with federation directives) — what `_service.sdl` returns. */
52
+ sdl: string;
53
+ /** Map of entity typeName -> key field paths (e.g. `["id"]` or `["id email"]`). */
54
+ entityKeys: Map<string, string[]>;
55
+ resolvers: Resolvers;
56
+ entityResolver: EntityResolver;
57
+ }
58
+
59
+ export interface ExecuteParams {
60
+ schema: GraphQLSchema;
61
+ document: DocumentNode;
62
+ resolvers: Resolvers;
63
+ variableValues?: Record<string, any>;
64
+ operationName?: string;
65
+ contextValue?: GraphQLContext;
66
+ rootValue?: any;
67
+ }
68
+
69
+ export type ExecutionResultLike = ExecutionResult;
70
+
71
+ export interface Gateway {
72
+ /** Public (client-facing) schema — federation internals stripped. */
73
+ schema: GraphQLSchema;
74
+ subgraphs: Subgraph[];
75
+ execute(params: Omit<ExecuteParams, 'schema' | 'resolvers'>): Promise<ExecutionResult>;
76
+ subscribe(params: Omit<ExecuteParams, 'schema' | 'resolvers'>): Promise<AsyncIterable<ExecutionResult>>;
77
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parse } from 'graphql';
3
+ import { defineSubgraph, createFederatedGateway, parseFederationMetadata } from '../src/index.js';
4
+
5
+ /**
6
+ * Two-subgraph federation e2e (Component A, Phase 6):
7
+ * - users subgraph owns User.id/name/email + Query.user
8
+ * - orders subgraph extends User with `orders` (needs User.id) and owns
9
+ * Order.id/total + Query.orders. Order.customer is a User resolved via @key.
10
+ *
11
+ * The naive sequential executor must stitch cross-subgraph fields: a query for
12
+ * `{ user(id) { id name orders { id total } } }` fetches the user from `users`
13
+ * then joins `orders` via `_entities` on the User key.
14
+ */
15
+
16
+ const ORDERS = new Map<string, { id: string; total: number; customerId: string }>([
17
+ ['o1', { id: 'o1', total: 100, customerId: 'u1' }],
18
+ ['o2', { id: 'o2', total: 250, customerId: 'u1' }],
19
+ ['o3', { id: 'o3', total: 50, customerId: 'u2' }],
20
+ ]);
21
+ const USERS = new Map<string, { id: string; name: string; email: string }>([
22
+ ['u1', { id: 'u1', name: 'Alice', email: 'alice@x.com' }],
23
+ ['u2', { id: 'u2', name: 'Bob', email: 'bob@x.com' }],
24
+ ]);
25
+
26
+ const usersSubgraph = defineSubgraph({
27
+ name: 'users',
28
+ typeDefs: /* graphql */ `
29
+ type User @key(fields: "id") {
30
+ id: ID!
31
+ name: String!
32
+ email: String!
33
+ }
34
+ type Query {
35
+ user(id: ID!): User
36
+ users: [User!]!
37
+ }
38
+ `,
39
+ resolvers: {
40
+ Query: {
41
+ user: (_p: any, a: { id: string }) => USERS.get(a.id) ?? null,
42
+ users: () => [...USERS.values()],
43
+ },
44
+ },
45
+ entityResolver: (rep) => USERS.get(String(rep.id)) ?? null,
46
+ });
47
+
48
+ const ordersSubgraph = defineSubgraph({
49
+ name: 'orders',
50
+ typeDefs: /* graphql */ `
51
+ type Order @key(fields: "id") {
52
+ id: ID!
53
+ total: Int!
54
+ customerId: ID!
55
+ }
56
+ type User @key(fields: "id") @extends {
57
+ id: ID! @external
58
+ orders: [Order!]!
59
+ }
60
+ type Query {
61
+ orders: [Order!]!
62
+ }
63
+ `,
64
+ resolvers: {
65
+ Query: {
66
+ orders: () => [...ORDERS.values()],
67
+ },
68
+ User: {
69
+ // `orders` is owned by the orders subgraph; resolve using the parent's id
70
+ // (the key field carried over from the users subgraph).
71
+ orders: (parent: { id: string }) => [...ORDERS.values()].filter((o) => o.customerId === parent.id),
72
+ },
73
+ },
74
+ // The orders subgraph resolves a User entity to just its key (the id), which
75
+ // the User.orders resolver then uses.
76
+ entityResolver: (rep) => ({ __typename: 'User', id: rep.id }),
77
+ });
78
+
79
+ describe('federation: metadata parsing', () => {
80
+ it('detects @external, @extends, and @key on the extended User', () => {
81
+ const meta = parseFederationMetadata(ordersSubgraph.sdl);
82
+ const user = meta.get('User')!;
83
+ expect(user.keys).toContainEqual(['id']);
84
+ expect(user.fields.get('id')?.external).toBe(true);
85
+ expect(user.fields.get('orders')?.external).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe('federation: two-subgraph composition + execution', () => {
90
+ const gateway = createFederatedGateway([usersSubgraph, ordersSubgraph]);
91
+
92
+ it('composes a merged public schema with fields from both subgraphs', () => {
93
+ const queryFields = Object.keys(gateway.schema.getQueryType()?.getFields() ?? {});
94
+ expect(queryFields).toContain('user');
95
+ expect(queryFields).toContain('users');
96
+ expect(queryFields).toContain('orders');
97
+ const userFields = Object.keys(gateway.schema.getType('User')?.getFields?.() ?? {});
98
+ expect([...userFields].sort()).toEqual(['email', 'id', 'name', 'orders'].sort());
99
+ });
100
+
101
+ it('plans a root fetch per owning subgraph', () => {
102
+ const plan = gateway.plan(parse(`{ user(id: "u1") { id name } orders { id total } }`));
103
+ const owners = plan.nodes.map((n) => n.subgraph).sort();
104
+ expect(owners).toEqual(['orders', 'users']);
105
+ });
106
+
107
+ it('stitches a cross-subgraph query: user from users, orders joined from orders', async () => {
108
+ const res = await gateway.execute({
109
+ document: parse(`{ user(id: "u1") { id name orders { id total } } }`),
110
+ contextValue: {},
111
+ });
112
+ expect(res.errors).toBeUndefined();
113
+ const user = (res.data as any).user;
114
+ expect(user.id).toBe('u1');
115
+ expect(user.name).toBe('Alice');
116
+ expect(user.orders.map((o: any) => o.id).sort()).toEqual(['o1', 'o2']);
117
+ expect(user.orders[0].total).toBeGreaterThan(0);
118
+ });
119
+
120
+ it('stitches the reverse direction: orders from orders, then customer name from users', async () => {
121
+ // Extend the scenario: orders owns Query.orders; Order.customerId is local.
122
+ // The query asks only for order fields owned by orders → single fetch, no join.
123
+ const res = await gateway.execute({
124
+ document: parse(`{ orders { id total customerId } }`),
125
+ contextValue: {},
126
+ });
127
+ expect(res.errors).toBeUndefined();
128
+ expect((res.data as any).orders.length).toBe(3);
129
+ });
130
+
131
+ it('stitches a list query with per-entity joins: users { id name orders }', async () => {
132
+ const res = await gateway.execute({
133
+ document: parse(`{ users { id name orders { id total } } }`),
134
+ contextValue: {},
135
+ });
136
+ expect(res.errors).toBeUndefined();
137
+ const users = (res.data as any).users as any[];
138
+ const alice = users.find((u) => u.id === 'u1');
139
+ expect(alice.name).toBe('Alice');
140
+ expect(alice.orders.map((o: any) => o.id).sort()).toEqual(['o1', 'o2']);
141
+ const bob = users.find((u) => u.id === 'u2');
142
+ expect(bob.orders.map((o: any) => o.id)).toEqual(['o3']);
143
+ });
144
+
145
+ it('returns null gracefully for an unknown user', async () => {
146
+ const res = await gateway.execute({
147
+ document: parse(`{ user(id: "zzz") { id name orders { id } } }`),
148
+ contextValue: {},
149
+ });
150
+ expect(res.errors).toBeUndefined();
151
+ expect((res.data as any).user).toBeNull();
152
+ });
153
+ });
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parse } from 'graphql';
3
+ import {
4
+ defineSubgraph,
5
+ createGateway,
6
+ parseEntityKeys,
7
+ buildPublicSchema,
8
+ } from '../src/index.js';
9
+
10
+ const USER_TYPEDEFS = /* graphql */ `
11
+ type User @key(fields: "id") {
12
+ id: ID!
13
+ name: String!
14
+ email: String
15
+ }
16
+ type Query {
17
+ me: User
18
+ user(id: ID!): User
19
+ }
20
+ type Mutation {
21
+ setName(name: String!): User
22
+ }
23
+ `;
24
+
25
+ const USERS: Record<string, any> = {
26
+ u1: { id: 'u1', name: 'Alice', email: 'alice@x.com' },
27
+ };
28
+
29
+ const resolvers = {
30
+ Query: {
31
+ me: () => USERS.u1,
32
+ user: (_p: any, args: { id: string }) => USERS[args.id] ?? null,
33
+ },
34
+ Mutation: {
35
+ setName: (_p: any, args: { name: string }) => {
36
+ USERS.u1.name = args.name;
37
+ return USERS.u1;
38
+ },
39
+ },
40
+ };
41
+
42
+ describe('subgraph: defineSubgraph', () => {
43
+ it('parses @key directives into entityKeys', () => {
44
+ const keys = parseEntityKeys(USER_TYPEDEFS);
45
+ expect(keys.get('User')).toEqual(['id']);
46
+ });
47
+
48
+ it('parses composite keys', () => {
49
+ const keys = parseEntityKeys(`
50
+ type Order @key(fields: "id tenant") { id: ID! tenant: String! total: Int! }
51
+ `);
52
+ expect(keys.get('Order')).toEqual(['id', 'tenant']);
53
+ });
54
+
55
+ it('builds a federation-compliant schema exposing _service and _entities', () => {
56
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
57
+ expect(sub.entityKeys.get('User')).toEqual(['id']);
58
+ // _service.sdl returns the subgraph SDL containing the @link + user types.
59
+ expect(sub.sdl).toContain('@link');
60
+ expect(sub.sdl).toContain('type User @key');
61
+ });
62
+
63
+ it('executes _service { sdl } and returns the subgraph SDL', async () => {
64
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
65
+ const gw = createGateway({ subgraph: sub });
66
+ // Use the subgraph schema (which has _service), not the public gateway schema.
67
+ const { execute } = await import('graphql');
68
+ const result = await execute({
69
+ schema: sub.schema,
70
+ document: parse(`{ _service { sdl } }`),
71
+ fieldResolver: (await import('../src/gateway/fieldResolver.js')).createFieldResolver(sub.resolvers),
72
+ });
73
+ expect(result.errors).toBeUndefined();
74
+ const sdl = (result.data as any)._service.sdl as string;
75
+ expect(sdl).toContain('type User @key');
76
+ });
77
+
78
+ it('resolves _entities via the provided entityResolver', async () => {
79
+ const sub = defineSubgraph({
80
+ name: 'users',
81
+ typeDefs: USER_TYPEDEFS,
82
+ resolvers,
83
+ entityResolver: (rep) => ({ id: rep.id, name: `resolved-${rep.id}`, email: null }),
84
+ });
85
+ const { execute } = await import('graphql');
86
+ const { createFieldResolver } = await import('../src/gateway/fieldResolver.js');
87
+ const result = await execute({
88
+ schema: sub.schema,
89
+ document: parse(`query($reps: [_Any!]!){ _entities(representations: $reps){ ... on User { id name } } }`),
90
+ variableValues: { reps: [{ __typename: 'User', id: 'u1' }] },
91
+ fieldResolver: createFieldResolver(sub.resolvers),
92
+ });
93
+ expect(result.errors).toBeUndefined();
94
+ expect((result.data as any)._entities[0]).toEqual({ id: 'u1', name: 'resolved-u1' });
95
+ });
96
+
97
+ it('rejects an unknown directive in the user SDL', () => {
98
+ expect(() =>
99
+ defineSubgraph({ name: 'bad', typeDefs: `type X @notARealDirective { id: ID! }` }),
100
+ ).toThrow();
101
+ });
102
+ });
103
+
104
+ describe('gateway: in-process single-subgraph execution', () => {
105
+ it('runs a query end-to-end through resolvers', async () => {
106
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
107
+ const gw = createGateway({ subgraph: sub });
108
+ const result = await gw.execute({
109
+ document: parse(`{ me { id name email } }`),
110
+ contextValue: {},
111
+ });
112
+ expect(result.errors).toBeUndefined();
113
+ expect((result.data as any).me).toEqual({ id: 'u1', name: 'Alice', email: 'alice@x.com' });
114
+ });
115
+
116
+ it('runs a mutation', async () => {
117
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
118
+ const gw = createGateway({ subgraph: sub });
119
+ const result = await gw.execute({
120
+ document: parse(`mutation { setName(name: "Bob"){ id name } }`),
121
+ contextValue: {},
122
+ });
123
+ expect(result.errors).toBeUndefined();
124
+ expect((result.data as any).setName.name).toBe('Bob');
125
+ });
126
+
127
+ it('supports variables', async () => {
128
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
129
+ const gw = createGateway({ subgraph: sub });
130
+ const result = await gw.execute({
131
+ document: parse(`query($id: ID!){ user(id: $id){ id name } }`),
132
+ variableValues: { id: 'u1' },
133
+ contextValue: {},
134
+ });
135
+ expect((result.data as any).user.id).toBe('u1');
136
+ });
137
+
138
+ it('reports validation errors for bad queries', async () => {
139
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
140
+ const gw = createGateway({ subgraph: sub });
141
+ const result = await gw.execute({
142
+ document: parse(`{ me { id doesNotExist } }`),
143
+ contextValue: {},
144
+ });
145
+ expect(result.errors?.length).toBeGreaterThan(0);
146
+ });
147
+
148
+ it('exposes a public schema without federation internals', () => {
149
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
150
+ const gw = createGateway({ subgraph: sub });
151
+ const introspect = parse(`{ __schema { queryType { name } mutationType { name } } }`);
152
+ // The public schema still has Query+Mutation; _service/_entities are NOT queryable.
153
+ // (We verify by checking the schema's query type doesn't define _entities by name.)
154
+ const queryFields = Object.keys((gw.schema.getQueryType()?.getFields() ?? {}) as Record<string, unknown>);
155
+ expect(queryFields).toContain('me');
156
+ expect(queryFields).not.toContain('_service');
157
+ expect(queryFields).not.toContain('_entities');
158
+ });
159
+
160
+ it('throws a clear error for multi-subgraph (Phase 6 territory)', () => {
161
+ const a = defineSubgraph({ name: 'a', typeDefs: `type A @key(fields:"id"){ id: ID! } type Query { a: A }` });
162
+ const b = defineSubgraph({ name: 'b', typeDefs: `type B @key(fields:"id"){ id: ID! } type Query { b: B }` });
163
+ expect(() => createGateway({ subgraphs: [a, b] })).toThrow(/Phase 6/);
164
+ });
165
+
166
+ it('buildPublicSchema strips federation directives', () => {
167
+ const sub = defineSubgraph({ name: 'users', typeDefs: USER_TYPEDEFS, resolvers });
168
+ // Public schema builds cleanly from the stripped SDL (no @key in the printed type).
169
+ const schema = buildPublicSchema(sub.sdl);
170
+ expect(() => schema.getQueryType()).not.toThrow();
171
+ });
172
+ });