Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
NEGATIVE_CACHE_TTL,
PARTIAL_CACHE_LOAD,
OPENFED_QUERY_CACHE,
OPENFED_REQUEST_SCOPED,
SHADOW_MODE,
} from '../utils/string-constants';
import {
Expand Down Expand Up @@ -127,6 +128,7 @@ import {
OPENFED_ENTITY_CACHE_DEFINITION,
OPENFED_IS_DEFINITION,
OPENFED_QUERY_CACHE_DEFINITION,
OPENFED_REQUEST_SCOPED_DEFINITION,
SPECIFIED_BY_DEFINITION,
SUBSCRIPTION_FILTER_DEFINITION,
TAG_DEFINITION,
Expand Down Expand Up @@ -1106,3 +1108,21 @@ export const IS_DEFINITION_DATA = newDirectiveDefinitionData({
node: OPENFED_IS_DEFINITION,
requiredArgumentNames: new Set<ArgumentName>([FIELDS]),
});

export const REQUEST_SCOPED_DEFINITION_DATA = newDirectiveDefinitionData({
argumentDataByName: new Map<ArgumentName, DirectiveArgumentData>([
[
KEY,
newDirectiveArgumentData({
directive: `@${OPENFED_REQUEST_SCOPED}`,
name: KEY,
namedTypeKind: Kind.SCALAR_TYPE_DEFINITION,
typeNode: REQUIRED_STRING_TYPE_NODE,
}),
],
]),
locations: new Set<DirectiveLocation>([FIELD_DEFINITION_UPPER]),
name: OPENFED_REQUEST_SCOPED,
node: OPENFED_REQUEST_SCOPED_DEFINITION,
requiredArgumentNames: new Set<ArgumentName>([KEY]),
});
13 changes: 13 additions & 0 deletions composition/src/router-configuration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export type RequiredFieldConfiguration = {
disableEntityResolver?: boolean;
};

export type RequestScopedConfiguration = {
fieldName: FieldName;
typeName: TypeName;
// L1 cache key used to store/lookup this field's value for the duration of a request.
// Format: "{subgraphName}.{key}" where `key` is the @openfed__requestScoped(key:) argument.
// All fields in the same subgraph declaring @openfed__requestScoped with the same key share
// the same L1 entry — the first one to resolve populates it, subsequent ones inject
// from it (subject to widening checks and alias-aware normalization).
l1Key: string;
};

export type ConfigurationData = {
fieldNames: Set<FieldName>;
isRootNode: boolean;
Expand Down Expand Up @@ -175,6 +186,8 @@ export type EntityCachingConfiguration = {
cachePopulateConfigurations: Array<CachePopulateConfiguration>;
// Attached to an entity type's ConfigurationData (e.g. "Product") from @openfed__entityCache.
entityCacheConfigurations: Array<EntityCacheConfiguration>;
// Attached to a field's ConfigurationData from @openfed__requestScoped.
requestScopedConfigurations: Array<RequestScopedConfiguration>;
// Attached to the Query type's ConfigurationData from @openfed__queryCache.
queryCacheConfigurations?: Array<QueryCacheConfig>;
};
Expand Down
1 change: 1 addition & 0 deletions composition/src/router-configuration/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function getOrInitializeEntityCaching(configurationData: ConfigurationDat
cacheInvalidateConfigurations: [],
cachePopulateConfigurations: [],
entityCacheConfigurations: [],
requestScopedConfigurations: [],
};
}

Expand Down
1 change: 1 addition & 0 deletions composition/src/utils/string-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export const REASON = 'reason';
export const REQUEST = 'request';
export const REQUIRE_FETCH_REASONS = 'openfed__requireFetchReasons';
export const REQUIRE_ONE_SLICING_ARGUMENT = 'requireOneSlicingArgument';
export const OPENFED_REQUEST_SCOPED = 'openfed__requestScoped';
export const REQUIRES = 'requires';
export const REQUIRES_SCOPES = 'requiresScopes';
export const RESOLVABLE = 'resolvable';
Expand Down
3 changes: 3 additions & 0 deletions composition/src/v1/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
OPENFED_ENTITY_CACHE,
OPENFED_IS,
OPENFED_QUERY_CACHE,
OPENFED_REQUEST_SCOPED,
DEPRECATED,
EDFS_KAFKA_PUBLISH,
EDFS_KAFKA_SUBSCRIBE,
Expand Down Expand Up @@ -84,6 +85,7 @@ import {
OPENFED_ENTITY_CACHE_DEFINITION,
OPENFED_IS_DEFINITION,
OPENFED_QUERY_CACHE_DEFINITION,
OPENFED_REQUEST_SCOPED_DEFINITION,
} from './directive-definitions';

export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap<DirectiveName, DirectiveDefinitionNode> = new Map<
Expand All @@ -101,6 +103,7 @@ export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap<DirectiveName, DirectiveD
[OPENFED_CACHE_POPULATE, OPENFED_CACHE_POPULATE_DEFINITION],
[OPENFED_QUERY_CACHE, OPENFED_QUERY_CACHE_DEFINITION],
[OPENFED_IS, OPENFED_IS_DEFINITION],
[OPENFED_REQUEST_SCOPED, OPENFED_REQUEST_SCOPED_DEFINITION],
[DEPRECATED, DEPRECATED_DEFINITION],
[EDFS_KAFKA_PUBLISH, EDFS_KAFKA_PUBLISH_DEFINITION],
[EDFS_KAFKA_SUBSCRIBE, EDFS_KAFKA_SUBSCRIBE_DEFINITION],
Expand Down
19 changes: 19 additions & 0 deletions composition/src/v1/constants/directive-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
NEGATIVE_CACHE_TTL,
PARTIAL_CACHE_LOAD,
OPENFED_QUERY_CACHE,
OPENFED_REQUEST_SCOPED,
SHADOW_MODE,
} from '../../utils/string-constants';
import {
Expand Down Expand Up @@ -937,3 +938,21 @@ export const OPENFED_IS_DEFINITION: DirectiveDefinitionNode = {
name: stringToNameNode(OPENFED_IS),
repeatable: false,
};

// @openfed__requestScoped(key: String!) on FIELD_DEFINITION
export const OPENFED_REQUEST_SCOPED_DEFINITION: DirectiveDefinitionNode = {
arguments: [
{
kind: Kind.INPUT_VALUE_DEFINITION,
name: stringToNameNode(KEY),
type: {
kind: Kind.NON_NULL_TYPE,
type: stringToNamedTypeNode(STRING_SCALAR),
},
},
],
kind: Kind.DIRECTIVE_DEFINITION,
locations: stringArrayToNameNodeArray([FIELD_DEFINITION_UPPER]),
name: stringToNameNode(OPENFED_REQUEST_SCOPED),
repeatable: false,
};
55 changes: 54 additions & 1 deletion composition/src/v1/normalization/normalization-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ import {
type EntityKeyMappingConfig,
type FieldMappingConfig,
type QueryCacheConfig,
type RequestScopedConfiguration,
} from '../../router-configuration/types';
import { printTypeNode } from '@graphql-tools/merge';
import {
Expand All @@ -226,6 +227,7 @@ import {
fieldAlreadyProvidedWarning,
invalidExternalFieldWarning,
nonExternalConditionalFieldWarning,
requestScopedSingleFieldWarning,
singleSubgraphInputFieldOneOfWarning,
unimplementedInterfaceOutputTypeWarning,
queryCacheReturnEntityMissingEntityCacheWarning,
Expand Down Expand Up @@ -385,6 +387,7 @@ import {
LITERAL_OPEN_BRACE,
LITERAL_SPACE,
OPENFED_QUERY_CACHE,
OPENFED_REQUEST_SCOPED,
} from '../../utils/string-constants';
import { MAX_INT32 } from '../../utils/integer-constants';
import {
Expand Down Expand Up @@ -418,6 +421,7 @@ import {
type UpsertInputObjectResult,
type ValidateDirectiveParams,
type QueryCacheDirectiveNode,
type RequestScopedDirectiveNode,
} from './types/types';
import {
getOrInitializeEntityCaching,
Expand Down Expand Up @@ -535,6 +539,7 @@ export class NormalizationFactory {
referencedDirectiveNames = new Set<DirectiveName>();
referencedTypeNames = new Set<TypeName>();
renamedParentTypeName = '';
requestScopedFieldCoordsByL1Key = new Map<string, Array<string>>();
schemaData: SchemaData;
subgraphName: SubgraphName;
unvalidatedExternalFieldCoords = new Set<string>();
Expand Down Expand Up @@ -4305,7 +4310,7 @@ export class NormalizationFactory {
}
// validateDirectives() has already guaranteed the argument types (Int maxAge, Boolean flags), so the
// generic ConstDirectiveNode is narrowed once to the precise typed node — mirroring
// EntityCacheDirectiveNode. Optional args may be absent (definition defaults
// EntityCacheDirectiveNode/RequestScopedDirectiveNode. Optional args may be absent (definition defaults
// are not materialized onto the usage AST), so each defaults here and present args override.
const queryCacheDirective = fieldData.directivesByName.get(OPENFED_QUERY_CACHE)![0] as QueryCacheDirectiveNode;
let maxAgeSeconds = 0;
Expand Down Expand Up @@ -5195,6 +5200,38 @@ export class NormalizationFactory {
return [];
}

// Attaches a single field annotated with @openfed__requestScoped to its type's configurationData. A field
// is both a reader and writer of the coordinate L1 — no receiver/provider. Fields with the same `key` share
// the same L1 entry: whichever is resolved first populates it, subsequent ones inject from it.
extractRequestScopedConfig(fieldData: FieldData) {
const directives = fieldData.directivesByName.get(OPENFED_REQUEST_SCOPED);
if (!directives) {
return;
}
// validateDirectives() (run earlier in normalize()) has already guaranteed a single, non-repeated
// @openfed__requestScoped with a required String `key`, so the generic ConstDirectiveNode can be
// narrowed to the precise typed node — mirroring handleComposeDirective()/ComposeDirectiveNode.
const directive = directives[0] as RequestScopedDirectiveNode;
const keyArg = directive.arguments.find((arg) => arg.name.value === KEY);
if (!keyArg) {
return;
}

const config: RequestScopedConfiguration = {
fieldName: fieldData.name,
typeName: fieldData.renamedParentTypeName,
l1Key: `${this.subgraphName}.${keyArg.value.value}`,
};
const configurationData = getValueOrDefault(this.configurationDataByTypeName, fieldData.renamedParentTypeName, () =>
newConfigurationData(false, fieldData.renamedParentTypeName),
);
getOrInitializeEntityCaching(configurationData).requestScopedConfigurations.push(config);
// Track field coords per L1 key so the single-field warning can be emitted after the walk completes.
getValueOrDefault(this.requestScopedFieldCoordsByL1Key, config.l1Key, () => []).push(
`${config.typeName}.${config.fieldName}`,
);
}

addFieldNamesToConfigurationData(fieldDataByFieldName: Map<string, FieldData>, configurationData: ConfigurationData) {
const externalFieldNames = new Set<string>();
for (const [fieldName, fieldData] of fieldDataByFieldName) {
Expand Down Expand Up @@ -5385,6 +5422,7 @@ export class NormalizationFactory {
for (const [fieldName, fieldData] of parentData.fieldDataByName) {
if (isObject) {
this.handleFieldCacheDirectives(fieldData);
this.extractRequestScopedConfig(fieldData);

// @openfed__queryCache extraction and @openfed__is placement validation. Key field sets and
// entity-cache configs are already finalized at this point in normalize() (populated by
Expand Down Expand Up @@ -5498,6 +5536,21 @@ export class NormalizationFactory {
}
}
}
// @openfed__requestScoped is meaningless unless >= 2 fields share a key (there'd be no second reader to
// benefit), so warn for any key used on only one field across the subgraph. extractRequestScopedConfig()
// populated requestScopedFieldCoordsByL1Key during the type walk above.
for (const [l1Key, fieldCoordsList] of this.requestScopedFieldCoordsByL1Key) {
if (fieldCoordsList.length === 1) {
this.warnings.push(
requestScopedSingleFieldWarning({
subgraphName: this.subgraphName,
// l1Key is `${this.subgraphName}.${key}`, so strip the prefix to recover the original key.
key: l1Key.slice(this.subgraphName.length + 1),
fieldCoords: fieldCoordsList[0],
}),
);
}
}
this.isSubgraphEventDrivenGraph = this.edfsDirectiveReferences.size > 0;
// this is where @provides and @requires configurations are added to the ConfigurationData
this.addValidConditionalFieldSetConfigurations();
Expand Down
14 changes: 14 additions & 0 deletions composition/src/v1/normalization/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ export type ComposeDirectiveArgumentNode = {
readonly loc?: Location;
};

export type RequestScopedDirectiveNode = {
readonly arguments: ReadonlyArray<RequestScopedArgumentNode>;
readonly kind: Kind.DIRECTIVE;
readonly name: NameNode;
readonly loc?: Location;
};

export type RequestScopedArgumentNode = {
readonly kind: Kind.ARGUMENT;
readonly name: NameNode;
readonly value: StringValueNode; // key: String! — guaranteed by validateDirectives()
readonly loc?: Location;
};

export type EntityCacheDirectiveNode = {
readonly arguments:
| readonly [MaxAgeArgumentNode]
Expand Down
3 changes: 3 additions & 0 deletions composition/src/v1/normalization/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
CACHE_POPULATE_DEFINITION_DATA,
IS_DEFINITION_DATA,
QUERY_CACHE_DEFINITION_DATA,
REQUEST_SCOPED_DEFINITION_DATA,
} from '../../directive-definition-data/directive-definition-data';
import {
AS,
Expand All @@ -93,6 +94,7 @@ import {
COST,
OPENFED_IS,
OPENFED_QUERY_CACHE,
OPENFED_REQUEST_SCOPED,
DEPRECATED,
EDFS_KAFKA_PUBLISH,
EDFS_KAFKA_SUBSCRIBE,
Expand Down Expand Up @@ -488,6 +490,7 @@ export function initializeDirectiveDefinitionDatas(): Map<string, DirectiveDefin
[OPENFED_CACHE_POPULATE, CACHE_POPULATE_DEFINITION_DATA],
[OPENFED_QUERY_CACHE, QUERY_CACHE_DEFINITION_DATA],
[OPENFED_IS, IS_DEFINITION_DATA],
[OPENFED_REQUEST_SCOPED, REQUEST_SCOPED_DEFINITION_DATA],
[DEPRECATED, DEPRECATED_DEFINITION_DATA],
[EDFS_KAFKA_PUBLISH, KAFKA_PUBLISH_DEFINITION_DATA],
[EDFS_KAFKA_SUBSCRIBE, KAFKA_SUBSCRIBE_DEFINITION_DATA],
Expand Down
6 changes: 6 additions & 0 deletions composition/src/v1/warnings/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export type InvalidRepeatedComposedDirectiveWarningParams = {
printedDirective: string;
};

export type RequestScopedSingleFieldWarningParams = {
subgraphName: SubgraphName;
key: string;
fieldCoords: string;
};

export type QueryCacheReturnEntityMissingEntityCacheWarningParams = {
subgraphName: SubgraphName;
fieldCoords: FieldName;
Expand Down
17 changes: 17 additions & 0 deletions composition/src/v1/warnings/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { QUOTATION_JOIN } from '../../utils/string-constants';
import {
type InvalidRepeatedComposedDirectiveWarningParams,
type QueryCacheReturnEntityMissingEntityCacheWarningParams,
type RequestScopedSingleFieldWarningParams,
type SingleFederatedInputFieldOneOfWarningParams,
type SingleSubgraphInputFieldOneOfWarningParams,
} from './params';
Expand Down Expand Up @@ -193,6 +194,22 @@ export function singleSubgraphInputFieldOneOfWarning({
});
}

export function requestScopedSingleFieldWarning({
subgraphName,
key,
fieldCoords,
}: RequestScopedSingleFieldWarningParams): Warning {
return new Warning({
message:
`@openfed__requestScoped(key: "${key}") is declared on only one field ("${fieldCoords}") in this subgraph.` +
` The directive is meaningless unless at least 2 fields share the same key so that the second and` +
` subsequent fields can be served from the per-request L1 cache populated by the first.`,
subgraph: {
name: subgraphName,
},
});
}

export function singleFederatedInputFieldOneOfWarning({
fieldName,
typeName,
Expand Down
Loading
Loading