Skip to content

Commit 2859cba

Browse files
committed
feat(composition): @openfed__requestScoped directive
1 parent c7044af commit 2859cba

16 files changed

Lines changed: 1247 additions & 681 deletions

File tree

composition/src/directive-definition-data/directive-definition-data.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
MAX_AGE,
8989
NEGATIVE_CACHE_TTL,
9090
PARTIAL_CACHE_LOAD,
91+
REQUEST_SCOPED,
9192
SHADOW_MODE,
9293
} from '../utils/string-constants';
9394
import {
@@ -123,6 +124,7 @@ import {
123124
CACHE_INVALIDATE_DEFINITION,
124125
CACHE_POPULATE_DEFINITION,
125126
ENTITY_CACHE_DEFINITION,
127+
REQUEST_SCOPED_DEFINITION,
126128
SPECIFIED_BY_DEFINITION,
127129
SUBSCRIPTION_FILTER_DEFINITION,
128130
TAG_DEFINITION,
@@ -1044,3 +1046,21 @@ export const CACHE_POPULATE_DEFINITION_DATA = newDirectiveDefinitionData({
10441046
node: CACHE_POPULATE_DEFINITION,
10451047
optionalArgumentNames: new Set<ArgumentName>([MAX_AGE]),
10461048
});
1049+
export const REQUEST_SCOPED_DEFINITION_DATA = newDirectiveDefinitionData({
1050+
argumentDataByName: new Map<ArgumentName, DirectiveArgumentData>([
1051+
[
1052+
KEY,
1053+
newDirectiveArgumentData({
1054+
directive: `@${REQUEST_SCOPED}`,
1055+
name: KEY,
1056+
namedTypeKind: Kind.SCALAR_TYPE_DEFINITION,
1057+
typeNode: REQUIRED_STRING_TYPE_NODE,
1058+
}),
1059+
],
1060+
]),
1061+
locations: new Set<DirectiveLocation>([FIELD_DEFINITION_UPPER]),
1062+
name: REQUEST_SCOPED,
1063+
node: REQUEST_SCOPED_DEFINITION,
1064+
requiredArgumentNames: new Set<ArgumentName>([KEY]),
1065+
});
1066+

composition/src/router-configuration/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ export type RequiredFieldConfiguration = {
8686
disableEntityResolver?: boolean;
8787
};
8888

89+
export type RequestScopedFieldConfig = {
90+
fieldName: FieldName;
91+
typeName: TypeName;
92+
// L1 cache key used to store/lookup this field's value for the duration of a request.
93+
// Format: "{subgraphName}.{key}" where `key` is the @openfed__requestScoped(key:) argument.
94+
// All fields in the same subgraph declaring @openfed__requestScoped with the same key share
95+
// the same L1 entry — the first one to resolve populates it, subsequent ones inject
96+
// from it (subject to widening checks and alias-aware normalization).
97+
l1Key: string;
98+
};
99+
89100
export type ConfigurationData = {
90101
fieldNames: Set<FieldName>;
91102
isRootNode: boolean;
@@ -144,6 +155,7 @@ export type EntityCachingConfiguration = {
144155
cacheInvalidateConfigurations?: Array<CacheInvalidateConfig>;
145156
// Attached to the Mutation/Subscription type's ConfigurationData from @openfed__cachePopulate.
146157
cachePopulateConfigurations?: Array<CachePopulateConfig>;
158+
requestScopedFields?: Array<RequestScopedFieldConfig>;
147159
};
148160

149161
export type Costs = {

composition/src/utils/string-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export const REASON = 'reason';
132132
export const REQUEST = 'request';
133133
export const REQUIRE_FETCH_REASONS = 'openfed__requireFetchReasons';
134134
export const REQUIRE_ONE_SLICING_ARGUMENT = 'requireOneSlicingArgument';
135+
export const REQUEST_SCOPED = 'openfed__requestScoped';
135136
export const REQUIRES = 'requires';
136137
export const REQUIRES_SCOPES = 'requiresScopes';
137138
export const RESOLVABLE = 'resolvable';

composition/src/v1/constants/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
CACHE_POPULATE,
1111
COST,
1212
ENTITY_CACHE,
13+
REQUEST_SCOPED,
1314
DEPRECATED,
1415
EDFS_KAFKA_PUBLISH,
1516
EDFS_KAFKA_SUBSCRIBE,
@@ -80,6 +81,7 @@ import {
8081
CACHE_INVALIDATE_DEFINITION,
8182
CACHE_POPULATE_DEFINITION,
8283
ENTITY_CACHE_DEFINITION,
84+
REQUEST_SCOPED_DEFINITION,
8385
} from './directive-definitions';
8486

8587
export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap<DirectiveName, DirectiveDefinitionNode> = new Map<
@@ -95,6 +97,7 @@ export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap<DirectiveName, DirectiveD
9597
[ENTITY_CACHE, ENTITY_CACHE_DEFINITION],
9698
[CACHE_INVALIDATE, CACHE_INVALIDATE_DEFINITION],
9799
[CACHE_POPULATE, CACHE_POPULATE_DEFINITION],
100+
[REQUEST_SCOPED, REQUEST_SCOPED_DEFINITION],
98101
[DEPRECATED, DEPRECATED_DEFINITION],
99102
[EDFS_KAFKA_PUBLISH, EDFS_KAFKA_PUBLISH_DEFINITION],
100103
[EDFS_KAFKA_SUBSCRIBE, EDFS_KAFKA_SUBSCRIBE_DEFINITION],

composition/src/v1/constants/directive-definitions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
MAX_AGE,
8989
NEGATIVE_CACHE_TTL,
9090
PARTIAL_CACHE_LOAD,
91+
REQUEST_SCOPED,
9192
SHADOW_MODE,
9293
} from '../../utils/string-constants';
9394
import { REQUIRED_FIELDSET_TYPE_NODE, REQUIRED_INT_TYPE_NODE, REQUIRED_STRING_TYPE_NODE } from './type-nodes';
@@ -888,3 +889,20 @@ export const CACHE_POPULATE_DEFINITION: DirectiveDefinitionNode = {
888889
name: stringToNameNode(CACHE_POPULATE),
889890
repeatable: false,
890891
};
892+
export const REQUEST_SCOPED_DEFINITION: DirectiveDefinitionNode = {
893+
arguments: [
894+
{
895+
kind: Kind.INPUT_VALUE_DEFINITION,
896+
name: stringToNameNode(KEY),
897+
type: {
898+
kind: Kind.NON_NULL_TYPE,
899+
type: stringToNamedTypeNode(STRING_SCALAR),
900+
},
901+
},
902+
],
903+
kind: Kind.DIRECTIVE_DEFINITION,
904+
locations: stringArrayToNameNodeArray([FIELD_DEFINITION_UPPER]),
905+
name: stringToNameNode(REQUEST_SCOPED),
906+
repeatable: false,
907+
};
908+

composition/src/v1/normalization/normalization-factory.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ import {
193193
type CacheInvalidateConfig,
194194
type CachePopulateConfig,
195195
type EntityCacheConfig,
196+
type RequestScopedFieldConfig,
196197
} from '../../router-configuration/types';
197198
import { printTypeNode } from '@graphql-tools/merge';
198199
import {
@@ -205,6 +206,7 @@ import {
205206
nonExternalConditionalFieldWarning,
206207
singleSubgraphInputFieldOneOfWarning,
207208
unimplementedInterfaceOutputTypeWarning,
209+
requestScopedSingleFieldWarning,
208210
} from '../warnings/warnings';
209211
import { upsertDirectiveSchemaAndEntityDefinitions, upsertParentsAndChildren } from './walkers';
210212
import {
@@ -357,6 +359,7 @@ import {
357359
MAX_AGE,
358360
NEGATIVE_CACHE_TTL,
359361
PARTIAL_CACHE_LOAD,
362+
REQUEST_SCOPED,
360363
SHADOW_MODE,
361364
} from '../../utils/string-constants';
362365
import { MAX_INT32 } from '../../utils/integer-constants';
@@ -390,6 +393,7 @@ import {
390393
type ValidateDirectiveParams,
391394
type CachePopulateDirectiveNode,
392395
type EntityCacheDirectiveNode,
396+
type RequestScopedDirectiveNode,
393397
} from './types/types';
394398
import { newConfigurationData, newFieldSetConditionData } from '../../router-configuration/utils';
395399
import { type ImplementationErrors, type InvalidFieldImplementation } from '../../utils/types';
@@ -4239,6 +4243,83 @@ export class NormalizationFactory {
42394243
};
42404244
}
42414245

4246+
extractRequestScopedFields() {
4247+
// Gather fields annotated with @openfed__requestScoped across all types in this subgraph.
4248+
// A field is both a reader and writer of the coordinate L1 — no receiver/provider.
4249+
// Fields with the same `key` share the same L1 entry: whichever is resolved first
4250+
// populates it, subsequent ones inject from it.
4251+
type Collected = {
4252+
typeName: string;
4253+
fieldName: string;
4254+
fieldCoords: string;
4255+
key: string;
4256+
l1Key: string;
4257+
};
4258+
const collected: Array<Collected> = [];
4259+
4260+
for (const [, parentData] of this.parentDefinitionDataByTypeName) {
4261+
if (parentData.kind !== Kind.OBJECT_TYPE_DEFINITION && parentData.kind !== Kind.INTERFACE_TYPE_DEFINITION) {
4262+
continue;
4263+
}
4264+
const typeName = getParentTypeName(parentData);
4265+
for (const [fieldName, fieldData] of parentData.fieldDataByName) {
4266+
const directives = fieldData.directivesByName.get(REQUEST_SCOPED);
4267+
if (!directives) {
4268+
continue;
4269+
}
4270+
// validateDirectives() (run earlier in normalize()) has already guaranteed a single, non-repeated
4271+
// @openfed__requestScoped with a required String `key`, so the generic ConstDirectiveNode can be
4272+
// narrowed to the precise typed node — mirroring handleComposeDirective()/ComposeDirectiveNode.
4273+
const directive = directives[0] as RequestScopedDirectiveNode;
4274+
const keyArg = directive.arguments.find((arg) => arg.name.value === KEY);
4275+
if (!keyArg) {
4276+
continue;
4277+
}
4278+
4279+
const fieldCoords = `${typeName}.${fieldName}`;
4280+
const key = keyArg.value.value;
4281+
const l1Key = `${this.subgraphName}.${key}`;
4282+
collected.push({ typeName, fieldName, fieldCoords, key, l1Key });
4283+
}
4284+
}
4285+
4286+
if (collected.length === 0) {
4287+
return;
4288+
}
4289+
4290+
// Warn when a key is used on only one field — @openfed__requestScoped is meaningless
4291+
// unless >= 2 fields share the key (there'd be no second reader to benefit).
4292+
const coordsByKey = new Map<string, Array<string>>();
4293+
for (const c of collected) {
4294+
getValueOrDefault(coordsByKey, c.key, () => []).push(c.fieldCoords);
4295+
}
4296+
for (const [key, coordsList] of coordsByKey) {
4297+
if (coordsList.length == 1) {
4298+
this.warnings.push(
4299+
requestScopedSingleFieldWarning({
4300+
subgraphName: this.subgraphName,
4301+
key,
4302+
fieldCoords: coordsList[0],
4303+
}),
4304+
);
4305+
}
4306+
}
4307+
4308+
// Group by type and attach to configurationData.
4309+
const byType = new Map<string, Array<RequestScopedFieldConfig>>();
4310+
for (const c of collected) {
4311+
const list = byType.get(c.typeName) ?? [];
4312+
list.push({ fieldName: c.fieldName, typeName: c.typeName, l1Key: c.l1Key });
4313+
byType.set(c.typeName, list);
4314+
}
4315+
for (const [typeName, fields] of byType) {
4316+
const configurationData = getValueOrDefault(this.configurationDataByTypeName, typeName, () =>
4317+
newConfigurationData(false, typeName),
4318+
);
4319+
configurationData.entityCaching = { ...configurationData.entityCaching, requestScopedFields: fields };
4320+
}
4321+
}
4322+
42424323
addFieldNamesToConfigurationData(fieldDataByFieldName: Map<string, FieldData>, configurationData: ConfigurationData) {
42434324
const externalFieldNames = new Set<string>();
42444325
for (const [fieldName, fieldData] of fieldDataByFieldName) {
@@ -4512,6 +4593,8 @@ export class NormalizationFactory {
45124593
// per-field caching directives (@openfed__cacheInvalidate etc.) — must run after entityCache
45134594
// (reads entityCacheConfigByTypeName)
45144595
this.processRootFieldCacheDirectives();
4596+
// this is where @openfed__requestScoped configurations are added to the ConfigurationData
4597+
this.extractRequestScopedFields();
45154598
// Check that explicitly defined operations types are valid objects and that their fields are also valid
45164599
for (const operationType of Object.values(OperationTypeNode)) {
45174600
const operationTypeNode = this.schemaData.operationTypes.get(operationType);

composition/src/v1/normalization/types/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,20 @@ export type ComposeDirectiveArgumentNode = {
138138
readonly loc?: Location;
139139
};
140140

141+
export type RequestScopedDirectiveNode = {
142+
readonly arguments: ReadonlyArray<RequestScopedArgumentNode>;
143+
readonly kind: Kind.DIRECTIVE;
144+
readonly name: NameNode;
145+
readonly loc?: Location;
146+
};
147+
148+
export type RequestScopedArgumentNode = {
149+
readonly kind: Kind.ARGUMENT;
150+
readonly name: NameNode;
151+
readonly value: StringValueNode; // key: String! — guaranteed by validateDirectives()
152+
readonly loc?: Location;
153+
};
154+
141155
export type EntityCacheDirectiveNode = {
142156
readonly arguments: ReadonlyArray<EntityCacheArgumentNode>;
143157
readonly kind: Kind.DIRECTIVE;

composition/src/v1/normalization/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
CACHE_INVALIDATE_DEFINITION_DATA,
8080
CACHE_POPULATE_DEFINITION_DATA,
8181
ENTITY_CACHE_DEFINITION_DATA,
82+
REQUEST_SCOPED_DEFINITION_DATA,
8283
} from '../../directive-definition-data/directive-definition-data';
8384
import {
8485
AS,
@@ -91,6 +92,7 @@ import {
9192
CACHE_POPULATE,
9293
COST,
9394
ENTITY_CACHE,
95+
REQUEST_SCOPED,
9496
DEPRECATED,
9597
EDFS_KAFKA_PUBLISH,
9698
EDFS_KAFKA_SUBSCRIBE,
@@ -482,6 +484,7 @@ export function initializeDirectiveDefinitionDatas(): Map<string, DirectiveDefin
482484
[ENTITY_CACHE, ENTITY_CACHE_DEFINITION_DATA],
483485
[CACHE_INVALIDATE, CACHE_INVALIDATE_DEFINITION_DATA],
484486
[CACHE_POPULATE, CACHE_POPULATE_DEFINITION_DATA],
487+
[REQUEST_SCOPED, REQUEST_SCOPED_DEFINITION_DATA],
485488
[DEPRECATED, DEPRECATED_DEFINITION_DATA],
486489
[EDFS_KAFKA_PUBLISH, KAFKA_PUBLISH_DEFINITION_DATA],
487490
[EDFS_KAFKA_SUBSCRIBE, KAFKA_SUBSCRIBE_DEFINITION_DATA],

composition/src/v1/warnings/params.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,9 @@ export type InvalidRepeatedComposedDirectiveWarningParams = {
1616
directiveName: DirectiveName;
1717
printedDirective: string;
1818
};
19+
20+
export type RequestScopedSingleFieldWarningParams = {
21+
subgraphName: SubgraphName;
22+
key: string;
23+
fieldCoords: FieldName;
24+
};

composition/src/v1/warnings/warnings.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Warning } from '../../warnings/types';
22
import { QUOTATION_JOIN } from '../../utils/string-constants';
33
import {
44
type InvalidRepeatedComposedDirectiveWarningParams,
5+
type RequestScopedSingleFieldWarningParams,
56
type SingleFederatedInputFieldOneOfWarningParams,
67
type SingleSubgraphInputFieldOneOfWarningParams,
78
} from './params';
@@ -236,3 +237,19 @@ export function invalidRepeatedComposedDirectiveWarning({
236237
},
237238
});
238239
}
240+
241+
export function requestScopedSingleFieldWarning({
242+
subgraphName,
243+
key,
244+
fieldCoords,
245+
}: RequestScopedSingleFieldWarningParams): Warning {
246+
return new Warning({
247+
message:
248+
`@openfed__requestScoped(key: "${key}") is declared on only one field ("${fieldCoords}") in this subgraph.` +
249+
` The directive is meaningless unless at least 2 fields share the same key so that the second` +
250+
` and subsequent fields can be served from the per-request L1 cache populated by the first.`,
251+
subgraph: {
252+
name: subgraphName,
253+
},
254+
});
255+
}

0 commit comments

Comments
 (0)