@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.
- package/README.md +29 -0
- package/package.json +24 -0
- package/src/federation/composeSupergraph.ts +153 -0
- package/src/federation/createFederatedGateway.ts +36 -0
- package/src/federation/executor.ts +305 -0
- package/src/federation/parseMetadata.ts +87 -0
- package/src/gateway/createGateway.ts +77 -0
- package/src/gateway/fieldResolver.ts +45 -0
- package/src/gateway/graphqlHttpHandler.ts +90 -0
- package/src/gateway/publicSchema.ts +26 -0
- package/src/index.ts +14 -0
- package/src/subgraph/defineSubgraph.ts +115 -0
- package/src/subgraph/federationDirectives.ts +47 -0
- package/src/subgraph/parseKeys.ts +25 -0
- package/src/subscriptions/PubSub.ts +64 -0
- package/src/subscriptions/SubscriptionServer.ts +203 -0
- package/src/types.ts +77 -0
- package/tests/federation.test.ts +153 -0
- package/tests/graphql.test.ts +172 -0
- package/tests/subscriptions.test.ts +182 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @bhooai/nexus-graphql
|
|
2
|
+
|
|
3
|
+
GraphQL on `graphql` (graphql-js) with a **custom federation/gateway layer built
|
|
4
|
+
from scratch** — no Apollo, no `@graphql-tools/federation`.
|
|
5
|
+
|
|
6
|
+
## Exports
|
|
7
|
+
|
|
8
|
+
- **subgraph** — `defineSubgraph` (SDL + resolvers, auto-wires `_service`/`_entities`),
|
|
9
|
+
federation directives, `parseKeys`.
|
|
10
|
+
- **gateway** — `createGateway({ subgraph })` (single-subgraph, in-process) and
|
|
11
|
+
`createFederatedGateway` (multi-subgraph). `graphqlHttpHandler({ gateway,
|
|
12
|
+
introspection, context })` for HTTP `/graphql`.
|
|
13
|
+
- **subscriptions** — `PubSub` (async iterators), `SubscriptionServer` over
|
|
14
|
+
WebSocket (`graphql-transport-ws`) with auth.
|
|
15
|
+
- **federation** — `composeSupergraph` (field ownership, `@key` collection,
|
|
16
|
+
`@provides`/`@requires` records → supergraph SDL), the query planner (FetchNode
|
|
17
|
+
DAG: root + entity fetches, `@requires` two-step), and `executor` (batched
|
|
18
|
+
`_entities` by `__typename+keyFields`, `@provides` short-circuit, error remap).
|
|
19
|
+
|
|
20
|
+
## Subset supported (v1)
|
|
21
|
+
|
|
22
|
+
`@key` (single + composite), `@external`, `@requires`, `@provides`, `@extends`,
|
|
23
|
+
`_entities`, `_service { sdl }`. Skipped: `@shareable` arbitration, `@override`,
|
|
24
|
+
`@inaccessible` enforcement, subscription federation. See `docs/ARCHITECTURE.md`.
|
|
25
|
+
|
|
26
|
+
## Subscribe note
|
|
27
|
+
|
|
28
|
+
`gateway.subscribe({ document, contextValue })` needs a **parsed** `DocumentNode`
|
|
29
|
+
(`parse(query)`) and the param is `contextValue` (HTTP execute uses `context`).
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-graphql",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bhooai/nexus-core": "^0.1.0",
|
|
14
|
+
"@bhooai/nexus-auth": "^0.1.0",
|
|
15
|
+
"graphql": "^16.9.0",
|
|
16
|
+
"ws": "^8.18.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^22.5.0",
|
|
20
|
+
"@types/ws": "^8.5.13",
|
|
21
|
+
"typescript": "^5.6.2",
|
|
22
|
+
"vitest": "^2.1.1"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { buildSchema, isObjectType, isInterfaceType, type GraphQLSchema, type GraphQLObjectType, type GraphQLInterfaceType } from 'graphql';
|
|
2
|
+
import type { Subgraph } from '../types.js';
|
|
3
|
+
import { parseFederationMetadata, type TypeMeta } from './parseMetadata.js';
|
|
4
|
+
import { buildPublicSchema } from '../gateway/publicSchema.js';
|
|
5
|
+
|
|
6
|
+
export interface EntitySource {
|
|
7
|
+
subgraph: string;
|
|
8
|
+
keyFields: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface RequiresRecord {
|
|
11
|
+
subgraph: string;
|
|
12
|
+
requiresFields: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Supergraph {
|
|
16
|
+
subgraphs: Subgraph[];
|
|
17
|
+
/** typeName -> fieldName -> owning subgraph name. */
|
|
18
|
+
ownership: Map<string, Map<string, string>>;
|
|
19
|
+
/** typeName -> subgraphs that can resolve it via _entities (have @key). */
|
|
20
|
+
entities: Map<string, EntitySource[]>;
|
|
21
|
+
/** typeName -> fieldName -> subgraph that @provides the field. */
|
|
22
|
+
provides: Map<string, Map<string, string>>;
|
|
23
|
+
/** typeName -> fieldName -> { subgraph, requiresFields }. */
|
|
24
|
+
requires: Map<string, Map<string, RequiresRecord>>;
|
|
25
|
+
/** Client-facing merged SDL (no federation directives). */
|
|
26
|
+
mergedSdl: string;
|
|
27
|
+
/** Schema built from mergedSdl (for query planning/type lookup only). */
|
|
28
|
+
schema: GraphQLSchema;
|
|
29
|
+
/** Per-subgraph public schemas (type info). */
|
|
30
|
+
subgraphSchemas: Map<string, GraphQLSchema>;
|
|
31
|
+
/** Per-subgraph federation metadata. */
|
|
32
|
+
metadata: Map<string, Map<string, TypeMeta>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ROOT_TYPES = ['Query', 'Mutation', 'Subscription'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compose multiple subgraphs into a Supergraph: collect field ownership,
|
|
39
|
+
* entity @key sources, @provides and @requires records, and a merged public SDL.
|
|
40
|
+
*
|
|
41
|
+
* Ownership rule (v1): a field is owned by a subgraph if that subgraph's SDL
|
|
42
|
+
* defines the field on the type and it is NOT `@external`. The first subgraph
|
|
43
|
+
* to claim a non-external field wins; a conflicting claim by a second subgraph
|
|
44
|
+
* is recorded as a composition warning (real federation would arbitrate via
|
|
45
|
+
* `@shareable`/`@override` — out of scope for v1).
|
|
46
|
+
*/
|
|
47
|
+
export function composeSupergraph(subgraphs: Subgraph[]): Supergraph {
|
|
48
|
+
if (subgraphs.length === 0) throw new Error('[nexus-graphql] composeSupergraph requires subgraphs');
|
|
49
|
+
|
|
50
|
+
const ownership = new Map<string, Map<string, string>>();
|
|
51
|
+
const entities = new Map<string, EntitySource[]>();
|
|
52
|
+
const provides = new Map<string, Map<string, string>>();
|
|
53
|
+
const requires = new Map<string, Map<string, RequiresRecord>>();
|
|
54
|
+
const subgraphSchemas = new Map<string, GraphQLSchema>();
|
|
55
|
+
const metadata = new Map<string, Map<string, TypeMeta>>();
|
|
56
|
+
const conflicts: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const sub of subgraphs) {
|
|
59
|
+
const pub = buildPublicSchema(sub.sdl);
|
|
60
|
+
subgraphSchemas.set(sub.name, pub);
|
|
61
|
+
const meta = parseFederationMetadata(sub.sdl);
|
|
62
|
+
metadata.set(sub.name, meta);
|
|
63
|
+
|
|
64
|
+
for (const [typeName, typeMeta] of meta) {
|
|
65
|
+
// Entity sources: every @key on the type makes the subgraph a resolver.
|
|
66
|
+
if (typeMeta.keys.length) {
|
|
67
|
+
const list = entities.get(typeName) ?? [];
|
|
68
|
+
for (const keyFields of typeMeta.keys) list.push({ subgraph: sub.name, keyFields });
|
|
69
|
+
entities.set(typeName, list);
|
|
70
|
+
}
|
|
71
|
+
for (const [fieldName, fieldMeta] of typeMeta.fields) {
|
|
72
|
+
if (fieldMeta.external) continue; // resolved elsewhere — not owned here
|
|
73
|
+
const typeOwn = ownership.get(typeName) ?? new Map<string, string>();
|
|
74
|
+
if (typeOwn.has(fieldName) && typeOwn.get(fieldName) !== sub.name) {
|
|
75
|
+
conflicts.push(`${typeName}.${fieldName} claimed by ${typeOwn.get(fieldName)} and ${sub.name} (v1: first wins)`);
|
|
76
|
+
} else {
|
|
77
|
+
typeOwn.set(fieldName, sub.name);
|
|
78
|
+
}
|
|
79
|
+
ownership.set(typeName, typeOwn);
|
|
80
|
+
if (fieldMeta.provides) {
|
|
81
|
+
const tp = provides.get(typeName) ?? new Map<string, string>();
|
|
82
|
+
tp.set(fieldName, sub.name);
|
|
83
|
+
provides.set(typeName, tp);
|
|
84
|
+
}
|
|
85
|
+
if (fieldMeta.requires) {
|
|
86
|
+
const tr = requires.get(typeName) ?? new Map<string, RequiresRecord>();
|
|
87
|
+
tr.set(fieldName, { subgraph: sub.name, requiresFields: fieldMeta.requires });
|
|
88
|
+
requires.set(typeName, tr);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const mergedSdl = mergeSdl(subgraphs, subgraphSchemas, metadata, ownership);
|
|
95
|
+
const schema = buildSchema(mergedSdl);
|
|
96
|
+
|
|
97
|
+
return { subgraphs, ownership, entities, provides, requires, mergedSdl, schema, subgraphSchemas, metadata };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Build the merged public SDL from all subgraphs' public types. */
|
|
101
|
+
function mergeSdl(
|
|
102
|
+
subgraphs: Subgraph[],
|
|
103
|
+
subgraphSchemas: Map<string, GraphQLSchema>,
|
|
104
|
+
_metadata: Map<string, Map<string, TypeMeta>>,
|
|
105
|
+
ownership: Map<string, Map<string, string>>,
|
|
106
|
+
): string {
|
|
107
|
+
// Collect each type's fields (name -> SDL type string) from the owning subgraph's public schema.
|
|
108
|
+
const typeFields = new Map<string, Map<string, string>>();
|
|
109
|
+
const rootFields = new Map<string, Map<string, string>>(); // Query/Mutation/Subscription
|
|
110
|
+
const kinds = new Map<string, 'type' | 'interface'>();
|
|
111
|
+
|
|
112
|
+
for (const sub of subgraphs) {
|
|
113
|
+
const pub = subgraphSchemas.get(sub.name)!;
|
|
114
|
+
const typeMap = pub.getTypeMap();
|
|
115
|
+
for (const [typeName, gqlType] of Object.entries(typeMap)) {
|
|
116
|
+
if (typeName.startsWith('__') || typeName.startsWith('_')) continue;
|
|
117
|
+
if (isObjectType(gqlType)) {
|
|
118
|
+
const isRoot = ROOT_TYPES.includes(typeName);
|
|
119
|
+
const target = isRoot ? (rootFields.get(typeName) ?? new Map<string, string>()) : (typeFields.get(typeName) ?? new Map<string, string>());
|
|
120
|
+
kinds.set(typeName, 'type');
|
|
121
|
+
for (const field of Object.values(gqlType.getFields())) {
|
|
122
|
+
const owner = ownership.get(typeName)?.get(field.name);
|
|
123
|
+
// Include the field if this subgraph owns it (or it's a root field here).
|
|
124
|
+
if (isRoot || owner === sub.name) {
|
|
125
|
+
target.set(field.name, field.type.toString());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (isRoot) rootFields.set(typeName, target); else typeFields.set(typeName, target);
|
|
129
|
+
} else if (isInterfaceType(gqlType)) {
|
|
130
|
+
const target = typeFields.get(typeName) ?? new Map<string, string>();
|
|
131
|
+
kinds.set(typeName, 'interface');
|
|
132
|
+
for (const field of Object.values(gqlType.getFields())) {
|
|
133
|
+
if (ownership.get(typeName)?.get(field.name) === sub.name) target.set(field.name, field.type.toString());
|
|
134
|
+
}
|
|
135
|
+
typeFields.set(typeName, target);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const parts: string[] = [];
|
|
141
|
+
for (const [typeName, fields] of rootFields) {
|
|
142
|
+
parts.push(`type ${typeName} {\n${fieldLines(fields)}\n}`);
|
|
143
|
+
}
|
|
144
|
+
for (const [typeName, fields] of typeFields) {
|
|
145
|
+
const kind = kinds.get(typeName) ?? 'type';
|
|
146
|
+
parts.push(`${kind} ${typeName} {\n${fieldLines(fields)}\n}`);
|
|
147
|
+
}
|
|
148
|
+
return parts.join('\n\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fieldLines(fields: Map<string, string>): string {
|
|
152
|
+
return [...fields.entries()].map(([name, type]) => ` ${name}: ${type}`).join('\n');
|
|
153
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parse, type DocumentNode, type ExecutionResult } from 'graphql';
|
|
2
|
+
import type { Subgraph, GraphQLContext, ExecuteParams } from '../types.js';
|
|
3
|
+
import { composeSupergraph, type Supergraph } from './composeSupergraph.js';
|
|
4
|
+
import { executeFederated, planQuery, type FetchPlan } from './executor.js';
|
|
5
|
+
|
|
6
|
+
export interface FederatedGateway {
|
|
7
|
+
/** Client-facing merged schema. */
|
|
8
|
+
schema: Supergraph['schema'];
|
|
9
|
+
supergraph: Supergraph;
|
|
10
|
+
subgraphs: Subgraph[];
|
|
11
|
+
execute(params: Omit<ExecuteParams, 'schema' | 'resolvers'>): Promise<ExecutionResult>;
|
|
12
|
+
/** Inspect the fetch plan for a query (golden tests / debugging). */
|
|
13
|
+
plan(document: DocumentNode): FetchPlan;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compose multiple subgraphs into a federated gateway. The gateway exposes the
|
|
18
|
+
* merged public schema and executes queries via the naive sequential federated
|
|
19
|
+
* executor (root fetches + iterative entity-join passes). Subscriptions are not
|
|
20
|
+
* federated in v1 — route them to the owning subgraph's `SubscriptionServer`.
|
|
21
|
+
*/
|
|
22
|
+
export function createFederatedGateway(subgraphs: Subgraph[]): FederatedGateway {
|
|
23
|
+
const supergraph = composeSupergraph(subgraphs);
|
|
24
|
+
return {
|
|
25
|
+
schema: supergraph.schema,
|
|
26
|
+
supergraph,
|
|
27
|
+
subgraphs,
|
|
28
|
+
async execute(params) {
|
|
29
|
+
const document: DocumentNode = params.document;
|
|
30
|
+
return executeFederated(supergraph, document, params.variableValues, (params.contextValue as GraphQLContext) ?? {});
|
|
31
|
+
},
|
|
32
|
+
plan(document) {
|
|
33
|
+
return planQuery(supergraph, document);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { execute, parse, isObjectType, isInterfaceType, type DocumentNode, type SelectionSetNode, type FieldNode, type InlineFragmentNode, type FragmentSpreadNode, type ExecutionResult } from 'graphql';
|
|
2
|
+
import type { Supergraph } from './composeSupergraph.js';
|
|
3
|
+
import type { Subgraph, GraphQLContext } from '../types.js';
|
|
4
|
+
import { createFieldResolver } from '../gateway/fieldResolver.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Naive sequential federated executor (Phase 6, v1).
|
|
8
|
+
*
|
|
9
|
+
* Algorithm:
|
|
10
|
+
* 1. Root pass — group the operation's top-level fields by their owning
|
|
11
|
+
* subgraph; for each group, execute a trimmed subquery against that
|
|
12
|
+
* subgraph in-process. Trim keeps only fields owned by that subgraph,
|
|
13
|
+
* plus `__typename` and entity key fields (so later passes can join).
|
|
14
|
+
* 2. Entity-join passes — scan the stitched result for entity-typed objects
|
|
15
|
+
* whose requested fields are owned by *other* subgraphs; for each such
|
|
16
|
+
* (subgraph, type) pair, call that subgraph's `_entities` with
|
|
17
|
+
* representations built from the parents' keys and a trimmed subquery,
|
|
18
|
+
* then merge the returned fields back by key. Repeat until no missing
|
|
19
|
+
* fields remain (handles nested joins, one level per pass).
|
|
20
|
+
*
|
|
21
|
+
* Limitations (documented): sequential (no parallel fetch fan-out), `@requires`
|
|
22
|
+
* supported as "fetch the required external fields first, then resolve", no
|
|
23
|
+
* `@shareable` arbitration, no `@override`. Composition is ours; execution is
|
|
24
|
+
* in-process (shared nothing is Phase 6-distributed, designed not built).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface FetchNode {
|
|
28
|
+
kind: 'root' | 'entities';
|
|
29
|
+
subgraph: string;
|
|
30
|
+
/** SDL selection set this fetch executes. */
|
|
31
|
+
selection: string;
|
|
32
|
+
/** For entity fetches: the entity type name being resolved. */
|
|
33
|
+
typeName?: string;
|
|
34
|
+
/** For entity fetches: key fields used to build representations. */
|
|
35
|
+
keyFields?: string[];
|
|
36
|
+
}
|
|
37
|
+
export interface FetchPlan {
|
|
38
|
+
nodes: FetchNode[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a FetchPlan description for inspection/golden tests. */
|
|
42
|
+
export function planQuery(supergraph: Supergraph, document: DocumentNode): FetchPlan {
|
|
43
|
+
const nodes: FetchNode[] = [];
|
|
44
|
+
const op = document.definitions.find((d) => d.kind === 'OperationDefinition') as
|
|
45
|
+
| { selectionSet: SelectionSetNode }
|
|
46
|
+
| undefined;
|
|
47
|
+
if (!op) return { nodes };
|
|
48
|
+
const rootType = 'Query';
|
|
49
|
+
const bySub = new Map<string, string[]>();
|
|
50
|
+
for (const sel of op.selectionSet.selections) {
|
|
51
|
+
if (sel.kind === 'Field') {
|
|
52
|
+
const owner = supergraph.ownership.get(rootType)?.get((sel as FieldNode).name.value);
|
|
53
|
+
if (owner) bySub.set(owner, [...(bySub.get(owner) ?? []), (sel as FieldNode).name.value]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const [sub, fields] of bySub) {
|
|
57
|
+
nodes.push({ kind: 'root', subgraph: sub, selection: `{ ${fields.join(' ') } }` });
|
|
58
|
+
}
|
|
59
|
+
return { nodes };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Execute a federated query in-process against the composed subgraphs. */
|
|
63
|
+
export async function executeFederated(
|
|
64
|
+
supergraph: Supergraph,
|
|
65
|
+
document: DocumentNode,
|
|
66
|
+
variableValues: Record<string, any> | undefined,
|
|
67
|
+
contextValue: GraphQLContext,
|
|
68
|
+
): Promise<ExecutionResult> {
|
|
69
|
+
const op = document.definitions.find((d) => d.kind === 'OperationDefinition') as
|
|
70
|
+
| { selectionSet: SelectionSetNode; operation: string }
|
|
71
|
+
| undefined;
|
|
72
|
+
if (!op) return { errors: [{ message: 'No operation in document' }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
73
|
+
const rootType = op.operation === 'mutation' ? 'Mutation' : op.operation === 'subscription' ? 'Subscription' : 'Query';
|
|
74
|
+
|
|
75
|
+
const subgraphByName = new Map<string, Subgraph>(supergraph.subgraphs.map((s) => [s.name, s]));
|
|
76
|
+
|
|
77
|
+
// 1. Root pass: group top-level fields by owner and execute trimmed subqueries.
|
|
78
|
+
const rootGroups = new Map<string, FieldNode[]>();
|
|
79
|
+
for (const sel of op.selectionSet.selections) {
|
|
80
|
+
if (sel.kind !== 'Field') continue; // v1: no fragment spreads at root
|
|
81
|
+
const owner = supergraph.ownership.get(rootType)?.get(sel.name.value);
|
|
82
|
+
if (!owner) return { errors: [{ message: `No subgraph owns ${rootType}.${sel.name.value}` }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
83
|
+
const arr = rootGroups.get(owner) ?? [];
|
|
84
|
+
arr.push(sel);
|
|
85
|
+
rootGroups.set(owner, arr);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const data: Record<string, unknown> = {};
|
|
89
|
+
for (const [subName, fields] of rootGroups) {
|
|
90
|
+
const sub = subgraphByName.get(subName)!;
|
|
91
|
+
const sel = trimSelection(fields, rootType, subName, supergraph, true);
|
|
92
|
+
const sdl = `${op.operation} { ${sel} }`;
|
|
93
|
+
const res = await execSubgraph(sub, parse(sdl), variableValues, contextValue);
|
|
94
|
+
if (res.errors) return { errors: res.errors };
|
|
95
|
+
Object.assign(data, res.data as Record<string, unknown>);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Iterative entity-join passes.
|
|
99
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
100
|
+
const pending = collectMissing(supergraph, op.selectionSet, rootType, data);
|
|
101
|
+
if (pending.length === 0) break;
|
|
102
|
+
for (const job of pending) {
|
|
103
|
+
const sub = subgraphByName.get(job.subgraph)!;
|
|
104
|
+
const reps = job.parents.map((p) => pickKeys(p, job.keyFields));
|
|
105
|
+
const sel = trimSelection(job.fieldNodes, job.typeName, job.subgraph, supergraph, true);
|
|
106
|
+
const sdl = `query($reps: [_Any!]!){ _entities(representations: $reps){ ... on ${job.typeName} { ${sel} } } }`;
|
|
107
|
+
const res = await execSubgraph(sub, parse(sdl), { ...variableValues, reps }, contextValue);
|
|
108
|
+
if (res.errors) return { errors: res.errors };
|
|
109
|
+
const entities = (res.data as Record<string, unknown> | undefined)?._entities as any[] | undefined;
|
|
110
|
+
if (!entities) continue;
|
|
111
|
+
mergeEntities(job.parents, entities, job.keyFields, job.typeName, job.missingFields);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { data };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Run a document against a subgraph's federation schema + resolvers in-process. */
|
|
119
|
+
async function execSubgraph(sub: Subgraph, document: DocumentNode, variableValues: Record<string, any> | undefined, contextValue: GraphQLContext): Promise<ExecutionResult> {
|
|
120
|
+
return execute({
|
|
121
|
+
schema: sub.schema,
|
|
122
|
+
document,
|
|
123
|
+
rootValue: undefined,
|
|
124
|
+
contextValue,
|
|
125
|
+
variableValues,
|
|
126
|
+
fieldResolver: createFieldResolver(sub.resolvers),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Unwrap a GraphQL type name to its base named type. */
|
|
131
|
+
function baseType(type: string): string {
|
|
132
|
+
return type.replace(/[\[\]!]/g, '');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Output SDL type of a field on a type, from the supergraph (merged) schema. */
|
|
136
|
+
function fieldType(supergraph: Supergraph, parentType: string, fieldName: string): string | undefined {
|
|
137
|
+
const t = supergraph.schema.getType(parentType);
|
|
138
|
+
if (isObjectType(t)) return t.getFields()[fieldName]?.type.toString();
|
|
139
|
+
if (isInterfaceType(t)) return t.getFields()[fieldName]?.type.toString();
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Trim a list of selections (for one parent type) to fields owned by `subgraphName`,
|
|
145
|
+
* preserving field arguments, plus `__typename`. For entity-typed parents, the
|
|
146
|
+
* entity's @key fields owned by `subgraphName` are force-included so downstream
|
|
147
|
+
* join passes can build representations. Fields owned by other subgraphs are
|
|
148
|
+
* omitted here (a later entity-join pass fills them). Returns an SDL selection-set.
|
|
149
|
+
*/
|
|
150
|
+
function trimSelection(selections: readonly (FieldNode | InlineFragmentNode | FragmentSpreadNode)[], parentType: string, subgraphName: string, supergraph: Supergraph, _includeKeys: boolean): string {
|
|
151
|
+
const parts: string[] = [];
|
|
152
|
+
const included = new Set<string>();
|
|
153
|
+
for (const sel of selections) {
|
|
154
|
+
if (sel.kind === 'Field') {
|
|
155
|
+
const name = sel.name.value;
|
|
156
|
+
if (name === '__typename') { parts.push('__typename'); included.add(name); continue; }
|
|
157
|
+
const owner = supergraph.ownership.get(parentType)?.get(name);
|
|
158
|
+
const ft = fieldType(supergraph, parentType, name);
|
|
159
|
+
const childType = ft ? baseType(ft) : undefined;
|
|
160
|
+
if (owner === subgraphName) {
|
|
161
|
+
const sub = sel.selectionSet ? trimSelection(sel.selectionSet.selections, childType!, subgraphName, supergraph, true) : '';
|
|
162
|
+
const args = argsToSdl(sel.arguments);
|
|
163
|
+
parts.push(sub ? `${name}${args} { ${sub} }` : `${name}${args}`);
|
|
164
|
+
included.add(name);
|
|
165
|
+
}
|
|
166
|
+
// Fields owned by other subgraphs are omitted here; an entity-join pass fills them.
|
|
167
|
+
} else if (sel.kind === 'InlineFragment') {
|
|
168
|
+
const fragType = sel.typeCondition?.name.value ?? parentType;
|
|
169
|
+
const sub = trimSelection(sel.selectionSet.selections, fragType, subgraphName, supergraph, _includeKeys);
|
|
170
|
+
parts.push(`... on ${fragType} { ${sub} }`);
|
|
171
|
+
}
|
|
172
|
+
// FragmentSpread: v1 ignores (rare in generated clients)
|
|
173
|
+
}
|
|
174
|
+
// Force-include this entity's key fields (owned by this subgraph) so join passes
|
|
175
|
+
// can build representations even when the client didn't request them.
|
|
176
|
+
if (supergraph.entities.has(parentType)) {
|
|
177
|
+
const src = (supergraph.entities.get(parentType) ?? []).find((s) => s.subgraph === subgraphName);
|
|
178
|
+
if (src) {
|
|
179
|
+
for (const kf of src.keyFields) {
|
|
180
|
+
if (supergraph.ownership.get(parentType)?.get(kf) === subgraphName && !included.has(kf)) {
|
|
181
|
+
parts.push(kf);
|
|
182
|
+
included.add(kf);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return parts.join(' ');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Serialize a field's arguments to SDL, e.g. `(id: "u1", limit: $limit)`. */
|
|
191
|
+
function argsToSdl(args: readonly import('graphql').ArgumentNode[] | undefined): string {
|
|
192
|
+
if (!args || args.length === 0) return '';
|
|
193
|
+
return '(' + args.map((a) => `${a.name.value}: ${valueToSdl(a.value)}`).join(', ') + ')';
|
|
194
|
+
}
|
|
195
|
+
function valueToSdl(v: import('graphql').ValueNode): string {
|
|
196
|
+
switch (v.kind) {
|
|
197
|
+
case 'IntValue': case 'FloatValue': return v.value;
|
|
198
|
+
case 'StringValue': return JSON.stringify(v.value);
|
|
199
|
+
case 'BooleanValue': return String(v.value);
|
|
200
|
+
case 'EnumValue': return v.value;
|
|
201
|
+
case 'NullValue': return 'null';
|
|
202
|
+
case 'Variable': return '$' + v.name.value;
|
|
203
|
+
case 'ListValue': return '[' + v.values.map(valueToSdl).join(', ') + ']';
|
|
204
|
+
case 'ObjectValue': return '{' + v.fields.map((f) => `${f.name.value}: ${valueToSdl(f.value)}`).join(', ') + '}';
|
|
205
|
+
default: return '';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function pickKeys(obj: Record<string, unknown>, keys: string[]): Record<string, unknown> {
|
|
210
|
+
const out: Record<string, unknown> = { __typename: obj.__typename };
|
|
211
|
+
for (const k of keys) if (k in obj) out[k] = obj[k];
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface JoinJob {
|
|
216
|
+
subgraph: string;
|
|
217
|
+
typeName: string;
|
|
218
|
+
keyFields: string[];
|
|
219
|
+
parents: Record<string, unknown>[];
|
|
220
|
+
missingFields: string[];
|
|
221
|
+
fieldNodes: (FieldNode | InlineFragmentNode | FragmentSpreadNode)[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Walk the stitched `data` against the query selection set; for each entity-typed
|
|
226
|
+
* object, find fields the query wants that are owned by a *different* subgraph
|
|
227
|
+
* than the one that produced the object, and emit a JoinJob to fetch them.
|
|
228
|
+
*/
|
|
229
|
+
function collectMissing(supergraph: Supergraph, selectionSet: SelectionSetNode, parentType: string, data: Record<string, unknown> | unknown[]): JoinJob[] {
|
|
230
|
+
const jobs: JoinJob[] = [];
|
|
231
|
+
walk(supergraph, selectionSet, parentType, data, jobs, new Set());
|
|
232
|
+
return jobs;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function walk(supergraph: Supergraph, selectionSet: SelectionSetNode, parentType: string, data: unknown, jobs: JoinJob[], seen: Set<Record<string, unknown>>): void {
|
|
236
|
+
if (data == null) return;
|
|
237
|
+
if (Array.isArray(data)) { for (const item of data) walk(supergraph, selectionSet, parentType, item, jobs, seen); return; }
|
|
238
|
+
if (typeof data !== 'object') return;
|
|
239
|
+
const obj = data as Record<string, unknown>;
|
|
240
|
+
|
|
241
|
+
// Group requested fields by their owning subgraph; identify fields owned by a
|
|
242
|
+
// subgraph other than the one that produced this object (inferred from the
|
|
243
|
+
// fields already present). For each foreign owner, build a job.
|
|
244
|
+
const requestedByOwner = new Map<string, FieldNode[]>();
|
|
245
|
+
for (const sel of selectionSet.selections) {
|
|
246
|
+
if (sel.kind !== 'Field') continue;
|
|
247
|
+
const name = sel.name.value;
|
|
248
|
+
if (name === '__typename') continue;
|
|
249
|
+
const owner = supergraph.ownership.get(parentType)?.get(name);
|
|
250
|
+
if (!owner) continue;
|
|
251
|
+
if (!(name in obj)) { // missing → needs fetching from `owner`
|
|
252
|
+
const arr = requestedByOwner.get(owner) ?? [];
|
|
253
|
+
arr.push(sel);
|
|
254
|
+
requestedByOwner.set(owner, arr);
|
|
255
|
+
} else if (sel.selectionSet) {
|
|
256
|
+
// present → recurse into children for nested joins
|
|
257
|
+
const ft = fieldType(supergraph, parentType, name);
|
|
258
|
+
const childType = ft ? baseType(ft) : undefined;
|
|
259
|
+
if (childType) walk(supergraph, sel.selectionSet, childType, obj[name], jobs, seen);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (requestedByOwner.size === 0) return;
|
|
264
|
+
if (seen.has(obj)) return;
|
|
265
|
+
seen.add(obj);
|
|
266
|
+
|
|
267
|
+
const entitySources = supergraph.entities.get(parentType) ?? [];
|
|
268
|
+
if (entitySources.length === 0) return; // can't join a non-entity
|
|
269
|
+
|
|
270
|
+
for (const [owner, fieldNodes] of requestedByOwner) {
|
|
271
|
+
// Pick a key for this owner: an entity source whose subgraph == owner, else first.
|
|
272
|
+
const source = entitySources.find((s) => s.subgraph === owner) ?? entitySources[0]!;
|
|
273
|
+
const keyFields = source.keyFields;
|
|
274
|
+
// Only emit if we actually have the key fields present on the object.
|
|
275
|
+
if (!keyFields.every((k) => k in obj)) continue;
|
|
276
|
+
jobs.push({
|
|
277
|
+
subgraph: owner,
|
|
278
|
+
typeName: parentType,
|
|
279
|
+
keyFields,
|
|
280
|
+
parents: [obj],
|
|
281
|
+
missingFields: fieldNodes.map((f) => f.name.value),
|
|
282
|
+
fieldNodes,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Merge fetched entity fields back into the parent objects. `_entities` returns
|
|
289
|
+
* one resolved entity per representation, in order; `reps` were built from
|
|
290
|
+
* `parents` in order, so `entities[i]` corresponds to `parents[i]`. We merge by
|
|
291
|
+
* index (not by key tuple) because the fetched entity may not carry its `@key`
|
|
292
|
+
* fields — they may be `@external` on the resolving subgraph and absent from the
|
|
293
|
+
* trimmed selection. Null entries (entity resolver returned null) are skipped.
|
|
294
|
+
*/
|
|
295
|
+
function mergeEntities(parents: Record<string, unknown>[], entities: any[], _keyFields: string[], typeName: string, missingFields: string[]): void {
|
|
296
|
+
for (let i = 0; i < parents.length; i++) {
|
|
297
|
+
const e = entities[i];
|
|
298
|
+
const p = parents[i]!;
|
|
299
|
+
if (!e || typeof e !== 'object') continue;
|
|
300
|
+
for (const f of missingFields) {
|
|
301
|
+
if (f in e) p[f] = e[f];
|
|
302
|
+
}
|
|
303
|
+
if (!p.__typename) p.__typename = typeName;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse federation metadata from subgraph SDL: per type, its `@key` sets,
|
|
3
|
+
* whether it is an `@extends` extension, and per field whether it is
|
|
4
|
+
* `@external`, `@provides(fields)`, or `@requires(fields)`. Uses brace matching
|
|
5
|
+
* (not a full AST) — sufficient for the v1 federation subset and the kind of
|
|
6
|
+
* type blocks we generate/accept.
|
|
7
|
+
*/
|
|
8
|
+
export interface FieldMeta {
|
|
9
|
+
/** Field is declared `@external` (resolved by another subgraph). */
|
|
10
|
+
external: boolean;
|
|
11
|
+
/** `@provides(fields: "...")` — this subgraph provides this field on an entity. */
|
|
12
|
+
provides?: string[];
|
|
13
|
+
/** `@requires(fields: "...")` — this field is computed from external fields. */
|
|
14
|
+
requires?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface TypeMeta {
|
|
17
|
+
isExtend: boolean;
|
|
18
|
+
keys: string[][];
|
|
19
|
+
fields: Map<string, FieldMeta>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseFederationMetadata(sdl: string): Map<string, TypeMeta> {
|
|
23
|
+
const types = new Map<string, TypeMeta>();
|
|
24
|
+
// Find `type|interface Name <header> {` and capture the balanced body.
|
|
25
|
+
const headerRe = /(?:extend\s+)?(?:type|interface)\s+(\w+)([^{]*)\{/g;
|
|
26
|
+
let m: RegExpExecArray | null;
|
|
27
|
+
while ((m = headerRe.exec(sdl)) !== null) {
|
|
28
|
+
const name = m[1]!;
|
|
29
|
+
const header = m[2]!;
|
|
30
|
+
const bodyStart = m.index + m[0].length;
|
|
31
|
+
const body = balancedBody(sdl, bodyStart);
|
|
32
|
+
headerRe.lastIndex = bodyStart + body.length + 1; // skip past closing brace
|
|
33
|
+
|
|
34
|
+
const isExtend = /\bextends\b/.test(header) || /@extends\b/.test(header);
|
|
35
|
+
const keys: string[][] = [];
|
|
36
|
+
const keyRe = /@key\s*\(\s*fields:\s*"([^"]+)"\s*\)/g;
|
|
37
|
+
let k: RegExpExecArray | null;
|
|
38
|
+
while ((k = keyRe.exec(header)) !== null) keys.push(k[1]!.split(/\s+/).filter(Boolean));
|
|
39
|
+
|
|
40
|
+
const fields = parseFields(body);
|
|
41
|
+
const existing = types.get(name);
|
|
42
|
+
if (existing) {
|
|
43
|
+
existing.keys.push(...keys);
|
|
44
|
+
for (const [fn, fm] of fields) existing.fields.set(fn, fm);
|
|
45
|
+
} else {
|
|
46
|
+
types.set(name, { isExtend, keys, fields });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return types;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extract the balanced `{ ... }` body starting just after an opening brace at `start-1`. */
|
|
53
|
+
function balancedBody(s: string, start: number): string {
|
|
54
|
+
let depth = 1;
|
|
55
|
+
let i = start;
|
|
56
|
+
for (; i < s.length; i++) {
|
|
57
|
+
const c = s[i];
|
|
58
|
+
if (c === '{') depth++;
|
|
59
|
+
else if (c === '}') { depth--; if (depth === 0) break; }
|
|
60
|
+
}
|
|
61
|
+
return s.slice(start, i);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parse field definitions in a type body into name → FieldMeta. */
|
|
65
|
+
function parseFields(body: string): Map<string, FieldMeta> {
|
|
66
|
+
const out = new Map<string, FieldMeta>();
|
|
67
|
+
// Each field line roughly: `name(args): Type <directives>` separated by newlines.
|
|
68
|
+
// We scan line-ish fragments split on top-level commas/newlines.
|
|
69
|
+
const lines = body.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
// Strip leading modifiers; capture field name as the first identifier.
|
|
72
|
+
const nameMatch = /^(\w+)/.exec(line);
|
|
73
|
+
if (!nameMatch) continue;
|
|
74
|
+
const name = nameMatch[1]!;
|
|
75
|
+
const external = /@external\b/.test(line);
|
|
76
|
+
const provides = matchFields(line, '@provides');
|
|
77
|
+
const requires = matchFields(line, '@requires');
|
|
78
|
+
out.set(name, { external, provides, requires });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function matchFields(line: string, directive: string): string[] | undefined {
|
|
84
|
+
const re = new RegExp(`${directive}\\s*\\(\\s*fields:\\s*"([^"]+)"\\s*\\)`);
|
|
85
|
+
const m = re.exec(line);
|
|
86
|
+
return m ? m[1]!.split(/\s+/).filter(Boolean) : undefined;
|
|
87
|
+
}
|