Skip to content

Commit cd52465

Browse files
committed
Merge branch 'milinda/requestScoped-directive' into milinda/queryCache-directive
# Conflicts: # composition/src/router-configuration/types.ts # composition/src/v1/normalization/normalization-factory.ts # composition/src/v1/warnings/warnings.ts # connect-go/gen/proto/wg/cosmo/node/v1/node.pb.go # connect/src/wg/cosmo/node/v1/node_pb.ts # proto/wg/cosmo/node/v1/node.proto # router/gen/proto/wg/cosmo/node/v1/node.pb.go # shared/src/router-config/builder.ts
2 parents 9058b72 + 93b5cbc commit cd52465

11 files changed

Lines changed: 171 additions & 195 deletions

File tree

composition/src/router-configuration/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export type RequiredFieldConfiguration = {
8787
disableEntityResolver?: boolean;
8888
};
8989

90-
export type RequestScopedFieldConfig = {
90+
export type RequestScopedConfiguration = {
9191
fieldName: FieldName;
9292
typeName: TypeName;
9393
// L1 cache key used to store/lookup this field's value for the duration of a request.
@@ -186,7 +186,7 @@ export type EntityCachingConfiguration = {
186186
entityCacheConfigurations: Array<EntityCacheConfiguration>;
187187
// Attached to the Mutation/Subscription type's ConfigurationData from @openfed__cachePopulate.
188188
cachePopulateConfigurations: Array<CachePopulateConfig>;
189-
requestScopedFields?: Array<RequestScopedFieldConfig>;
189+
requestScopedConfigurations: Array<RequestScopedConfiguration>;
190190
// Attached to the Query type's ConfigurationData from @openfed__queryCache.
191191
queryCacheConfigurations?: Array<QueryCacheConfig>;
192192
};

composition/src/router-configuration/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function getOrInitializeEntityCaching(configurationData: ConfigurationDat
2222
cacheInvalidateConfigurations: [],
2323
entityCacheConfigurations: [],
2424
cachePopulateConfigurations: [],
25+
requestScopedConfigurations: [],
2526
};
2627
}
2728
return configurationData.entityCaching;

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

Lines changed: 46 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ import {
217217
type CachePopulateConfig,
218218
type EntityKeyMappingConfig,
219219
type FieldMappingConfig,
220-
type RequestScopedFieldConfig,
221220
type QueryCacheConfig,
221+
type RequestScopedConfiguration,
222222
} from '../../router-configuration/types';
223223
import { printTypeNode } from '@graphql-tools/merge';
224224
import {
@@ -229,10 +229,10 @@ import {
229229
fieldAlreadyProvidedWarning,
230230
invalidExternalFieldWarning,
231231
nonExternalConditionalFieldWarning,
232+
requestScopedSingleFieldWarning,
232233
singleSubgraphInputFieldOneOfWarning,
233234
unimplementedInterfaceOutputTypeWarning,
234235
queryCacheReturnEntityMissingEntityCacheWarning,
235-
requestScopedSingleFieldWarning,
236236
} from '../warnings/warnings';
237237
import { upsertDirectiveSchemaAndEntityDefinitions, upsertParentsAndChildren } from './walkers';
238238
import {
@@ -542,6 +542,7 @@ export class NormalizationFactory {
542542
referencedDirectiveNames = new Set<DirectiveName>();
543543
referencedTypeNames = new Set<TypeName>();
544544
renamedParentTypeName = '';
545+
requestScopedFieldCoordsByL1Key = new Map<string, Array<string>>();
545546
schemaData: SchemaData;
546547
subgraphName: SubgraphName;
547548
unvalidatedExternalFieldCoords = new Set<string>();
@@ -5214,81 +5215,36 @@ export class NormalizationFactory {
52145215
return [];
52155216
}
52165217

5217-
extractRequestScopedFields() {
5218-
// Gather fields annotated with @openfed__requestScoped across all types in this subgraph.
5219-
// A field is both a reader and writer of the coordinate L1 — no receiver/provider.
5220-
// Fields with the same `key` share the same L1 entry: whichever is resolved first
5221-
// populates it, subsequent ones inject from it.
5222-
type Collected = {
5223-
typeName: string;
5224-
fieldName: string;
5225-
fieldCoords: string;
5226-
key: string;
5227-
l1Key: string;
5228-
};
5229-
const collected: Array<Collected> = [];
5230-
5231-
for (const [, parentData] of this.parentDefinitionDataByTypeName) {
5232-
if (parentData.kind !== Kind.OBJECT_TYPE_DEFINITION && parentData.kind !== Kind.INTERFACE_TYPE_DEFINITION) {
5233-
continue;
5234-
}
5235-
const typeName = getParentTypeName(parentData);
5236-
for (const [fieldName, fieldData] of parentData.fieldDataByName) {
5237-
const directives = fieldData.directivesByName.get(OPENFED_REQUEST_SCOPED);
5238-
if (!directives) {
5239-
continue;
5240-
}
5241-
// validateDirectives() (run earlier in normalize()) has already guaranteed a single, non-repeated
5242-
// @openfed__requestScoped with a required String `key`, so the generic ConstDirectiveNode can be
5243-
// narrowed to the precise typed node — mirroring handleComposeDirective()/ComposeDirectiveNode.
5244-
const directive = directives[0] as RequestScopedDirectiveNode;
5245-
const keyArg = directive.arguments.find((arg) => arg.name.value === KEY);
5246-
if (!keyArg) {
5247-
continue;
5248-
}
5249-
5250-
const fieldCoords = `${typeName}.${fieldName}`;
5251-
const key = keyArg.value.value;
5252-
const l1Key = `${this.subgraphName}.${key}`;
5253-
collected.push({ typeName, fieldName, fieldCoords, key, l1Key });
5254-
}
5255-
}
5256-
5257-
if (collected.length === 0) {
5218+
// Attaches a single field annotated with @openfed__requestScoped to its type's configurationData. A field
5219+
// is both a reader and writer of the coordinate L1 — no receiver/provider. Fields with the same `key` share
5220+
// the same L1 entry: whichever is resolved first populates it, subsequent ones inject from it.
5221+
extractRequestScopedConfig(fieldData: FieldData) {
5222+
const directives = fieldData.directivesByName.get(OPENFED_REQUEST_SCOPED);
5223+
if (!directives) {
52585224
return;
52595225
}
5260-
5261-
// Warn when a key is used on only one field — @openfed__requestScoped is meaningless
5262-
// unless >= 2 fields share the key (there'd be no second reader to benefit).
5263-
const coordsByKey = new Map<string, Array<string>>();
5264-
for (const c of collected) {
5265-
getValueOrDefault(coordsByKey, c.key, () => []).push(c.fieldCoords);
5266-
}
5267-
for (const [key, coordsList] of coordsByKey) {
5268-
if (coordsList.length === 1) {
5269-
this.warnings.push(
5270-
requestScopedSingleFieldWarning({
5271-
subgraphName: this.subgraphName,
5272-
key,
5273-
fieldCoords: coordsList[0],
5274-
}),
5275-
);
5276-
}
5226+
// validateDirectives() (run earlier in normalize()) has already guaranteed a single, non-repeated
5227+
// @openfed__requestScoped with a required String `key`, so the generic ConstDirectiveNode can be
5228+
// narrowed to the precise typed node — mirroring handleComposeDirective()/ComposeDirectiveNode.
5229+
const directive = directives[0] as RequestScopedDirectiveNode;
5230+
const keyArg = directive.arguments.find((arg) => arg.name.value === KEY);
5231+
if (!keyArg) {
5232+
return;
52775233
}
52785234

5279-
// Group by type and attach to configurationData.
5280-
const byType = new Map<string, Array<RequestScopedFieldConfig>>();
5281-
for (const c of collected) {
5282-
const list = byType.get(c.typeName) ?? [];
5283-
list.push({ fieldName: c.fieldName, typeName: c.typeName, l1Key: c.l1Key });
5284-
byType.set(c.typeName, list);
5285-
}
5286-
for (const [typeName, fields] of byType) {
5287-
const configurationData = getValueOrDefault(this.configurationDataByTypeName, typeName, () =>
5288-
newConfigurationData(false, typeName),
5289-
);
5290-
getOrInitializeEntityCaching(configurationData).requestScopedFields = fields;
5291-
}
5235+
const config: RequestScopedConfiguration = {
5236+
fieldName: fieldData.name,
5237+
typeName: fieldData.renamedParentTypeName,
5238+
l1Key: `${this.subgraphName}.${keyArg.value.value}`,
5239+
};
5240+
const configurationData = getValueOrDefault(this.configurationDataByTypeName, fieldData.renamedParentTypeName, () =>
5241+
newConfigurationData(false, fieldData.renamedParentTypeName),
5242+
);
5243+
getOrInitializeEntityCaching(configurationData).requestScopedConfigurations.push(config);
5244+
// Track field coords per L1 key so the single-field warning can be emitted after the walk completes.
5245+
getValueOrDefault(this.requestScopedFieldCoordsByL1Key, config.l1Key, () => []).push(
5246+
`${config.typeName}.${config.fieldName}`,
5247+
);
52925248
}
52935249

52945250
addFieldNamesToConfigurationData(fieldDataByFieldName: Map<string, FieldData>, configurationData: ConfigurationData) {
@@ -5483,6 +5439,8 @@ export class NormalizationFactory {
54835439
this.extractCacheInvalidateConfig(fieldData);
54845440
this.extractCachePopulateConfig(fieldData);
54855441
}
5442+
5443+
this.extractRequestScopedConfig(fieldData);
54865444
} else if (fieldData.externalFieldDataBySubgraphName.get(this.subgraphName)?.isDefinedExternal) {
54875445
externalInterfaceFieldNames.push(fieldName);
54885446
}
@@ -5573,6 +5531,21 @@ export class NormalizationFactory {
55735531
}
55745532
}
55755533
}
5534+
// @openfed__requestScoped is meaningless unless >= 2 fields share a key (there'd be no second reader to
5535+
// benefit), so warn for any key used on only one field across the subgraph. extractRequestScopedConfig()
5536+
// populated requestScopedFieldCoordsByL1Key during the type walk above.
5537+
for (const [l1Key, fieldCoordsList] of this.requestScopedFieldCoordsByL1Key) {
5538+
if (fieldCoordsList.length === 1) {
5539+
this.warnings.push(
5540+
requestScopedSingleFieldWarning({
5541+
subgraphName: this.subgraphName,
5542+
// l1Key is `${this.subgraphName}.${key}`, so strip the prefix to recover the original key.
5543+
key: l1Key.slice(this.subgraphName.length + 1),
5544+
fieldCoords: fieldCoordsList[0],
5545+
}),
5546+
);
5547+
}
5548+
}
55765549
this.isSubgraphEventDrivenGraph = this.edfsDirectiveReferences.size > 0;
55775550
// this is where @provides and @requires configurations are added to the ConfigurationData
55785551
this.addValidConditionalFieldSetConfigurations();
@@ -5581,8 +5554,6 @@ export class NormalizationFactory {
55815554
// @openfed__queryCache extraction and @openfed__is placement validation — must run after key field sets
55825555
// are available (queryCache reads keyFieldSetDatasByTypeName to build argument→key mappings)
55835556
this.processRootFieldCacheDirectives();
5584-
// this is where @openfed__requestScoped configurations are added to the ConfigurationData
5585-
this.extractRequestScopedFields();
55865557
// Check that explicitly defined operations types are valid objects and that their fields are also valid
55875558
for (const operationType of Object.values(OperationTypeNode)) {
55885559
const operationTypeNode = this.schemaData.operationTypes.get(operationType);

composition/src/v1/warnings/params.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type InvalidRepeatedComposedDirectiveWarningParams = {
2020
export type RequestScopedSingleFieldWarningParams = {
2121
subgraphName: SubgraphName;
2222
key: string;
23-
fieldCoords: FieldName;
23+
fieldCoords: string;
2424
};
2525

2626
export type QueryCacheReturnEntityMissingEntityCacheWarningParams = {

composition/src/v1/warnings/warnings.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,22 @@ export function singleSubgraphInputFieldOneOfWarning({
194194
});
195195
}
196196

197+
export function requestScopedSingleFieldWarning({
198+
subgraphName,
199+
key,
200+
fieldCoords,
201+
}: RequestScopedSingleFieldWarningParams): Warning {
202+
return new Warning({
203+
message:
204+
`@openfed__requestScoped(key: "${key}") is declared on only one field ("${fieldCoords}") in this subgraph.` +
205+
` The directive is meaningless unless at least 2 fields share the same key so that the second and` +
206+
` subsequent fields can be served from the per-request L1 cache populated by the first.`,
207+
subgraph: {
208+
name: subgraphName,
209+
},
210+
});
211+
}
212+
197213
export function singleFederatedInputFieldOneOfWarning({
198214
fieldName,
199215
typeName,
@@ -239,22 +255,6 @@ export function invalidRepeatedComposedDirectiveWarning({
239255
});
240256
}
241257

242-
export function requestScopedSingleFieldWarning({
243-
subgraphName,
244-
key,
245-
fieldCoords,
246-
}: RequestScopedSingleFieldWarningParams): Warning {
247-
return new Warning({
248-
message:
249-
`@openfed__requestScoped(key: "${key}") is declared on only one field ("${fieldCoords}") in this subgraph.` +
250-
` The directive is meaningless unless at least 2 fields share the same key so that the second` +
251-
` and subsequent fields can be served from the per-request L1 cache populated by the first.`,
252-
subgraph: {
253-
name: subgraphName,
254-
},
255-
});
256-
}
257-
258258
export function queryCacheReturnEntityMissingEntityCacheWarning({
259259
subgraphName,
260260
fieldCoords,

composition/tests/v1/directives/request-scoped.test.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { describe, expect, test } from 'vitest';
22
import {
3-
DIRECTIVE_DEFINITION_BY_NAME,
43
FIRST_ORDINAL,
54
invalidDirectiveError,
65
invalidRepeatedDirectiveErrorMessage,
@@ -12,9 +11,9 @@ import {
1211
} from '../../../src';
1312
import { createSubgraphWithDefaultName, normalizeSubgraphFailure, normalizeSubgraphSuccess } from '../../utils/utils';
1413

15-
describe('@openfed__requestScoped', () => {
16-
describe('registry', () => {
17-
test('the directive is materialized in the normalized subgraph output', () => {
14+
describe('@openfed__requestScoped tests', () => {
15+
describe('registry tests', () => {
16+
test('that the directive is materialized in the normalized subgraph output', () => {
1817
const { directiveDefinitionByName } = normalizeSubgraphSuccess(
1918
createSubgraphWithDefaultName(`
2019
type Query {
@@ -32,8 +31,8 @@ describe('@openfed__requestScoped', () => {
3231
});
3332
});
3433

35-
describe('configuration extraction', () => {
36-
test('≥ 2 fields sharing the same key produce a subgraph-prefixed l1Key and no warning', () => {
34+
describe('configuration extraction tests', () => {
35+
test('that ≥ 2 fields sharing the same key produce a subgraph-prefixed l1Key and no warning', () => {
3736
const result = normalizeSubgraphSuccess(
3837
createSubgraphWithDefaultName(`
3938
type Query {
@@ -47,16 +46,16 @@ describe('@openfed__requestScoped', () => {
4746
ROUTER_COMPATIBILITY_VERSION_ONE,
4847
);
4948
const config = result.configurationDataByTypeName.get('Query');
50-
expect(config!.entityCaching?.requestScopedFields).toBeDefined();
51-
expect(config!.entityCaching?.requestScopedFields).toHaveLength(2);
52-
expect(config!.entityCaching!.requestScopedFields!.map((f) => f.l1Key)).toEqual([
49+
expect(config!.entityCaching?.requestScopedConfigurations).toBeDefined();
50+
expect(config!.entityCaching?.requestScopedConfigurations).toHaveLength(2);
51+
expect(config!.entityCaching!.requestScopedConfigurations!.map((f) => f.l1Key)).toEqual([
5352
'subgraph-default-a.me',
5453
'subgraph-default-a.me',
5554
]);
5655
expect(result.warnings).toHaveLength(0);
5756
});
5857

59-
test('works on a non-entity object type field', () => {
58+
test('that it works on a non-entity object type field', () => {
6059
const result = normalizeSubgraphSuccess(
6160
createSubgraphWithDefaultName(`
6261
type Query {
@@ -67,13 +66,13 @@ describe('@openfed__requestScoped', () => {
6766
ROUTER_COMPATIBILITY_VERSION_ONE,
6867
);
6968
const config = result.configurationDataByTypeName.get('Query');
70-
expect(config!.entityCaching?.requestScopedFields).toBeDefined();
71-
expect(config!.entityCaching?.requestScopedFields).toHaveLength(2);
72-
expect(config!.entityCaching!.requestScopedFields![0].fieldName).toBe('currentLocale');
73-
expect(config!.entityCaching!.requestScopedFields![0].l1Key).toBe('subgraph-default-a.locale');
69+
expect(config!.entityCaching?.requestScopedConfigurations).toBeDefined();
70+
expect(config!.entityCaching?.requestScopedConfigurations).toHaveLength(2);
71+
expect(config!.entityCaching!.requestScopedConfigurations![0].fieldName).toBe('currentLocale');
72+
expect(config!.entityCaching!.requestScopedConfigurations![0].l1Key).toBe('subgraph-default-a.locale');
7473
});
7574

76-
test('a key declared on only one field still populates config but emits a warning', () => {
75+
test('that a key declared on only one field still populates config but emits a warning', () => {
7776
const result = normalizeSubgraphSuccess(
7877
createSubgraphWithDefaultName(`
7978
type Query {
@@ -86,8 +85,8 @@ describe('@openfed__requestScoped', () => {
8685
ROUTER_COMPATIBILITY_VERSION_ONE,
8786
);
8887
const config = result.configurationDataByTypeName.get('Query');
89-
expect(config!.entityCaching?.requestScopedFields).toHaveLength(1);
90-
expect(config!.entityCaching!.requestScopedFields![0].l1Key).toBe('subgraph-default-a.lonely');
88+
expect(config!.entityCaching?.requestScopedConfigurations).toHaveLength(1);
89+
expect(config!.entityCaching!.requestScopedConfigurations![0].l1Key).toBe('subgraph-default-a.lonely');
9190
expect(result.warnings).toStrictEqual([
9291
requestScopedSingleFieldWarning({
9392
subgraphName: 'subgraph-default-a',
@@ -98,8 +97,8 @@ describe('@openfed__requestScoped', () => {
9897
});
9998
});
10099

101-
describe('validation', () => {
102-
test('missing key argument is a failure', () => {
100+
describe('validation tests', () => {
101+
test('that a missing key argument is a failure', () => {
103102
const { errors } = normalizeSubgraphFailure(
104103
createSubgraphWithDefaultName(`
105104
type Query {
@@ -118,7 +117,7 @@ describe('@openfed__requestScoped', () => {
118117
);
119118
});
120119

121-
test('the directive is not repeatable — two on the same field fails', () => {
120+
test('that the directive is not repeatable — two on the same field fails', () => {
122121
const { errors } = normalizeSubgraphFailure(
123122
createSubgraphWithDefaultName(`
124123
type Query {

0 commit comments

Comments
 (0)