Skip to content

Commit 41292d0

Browse files
Milinda Diasclaude
authored andcommitted
feat(composition): entity caching directives, proto config types, and generated bindings
Extracted from jensneuse/entity-caching-v2 (PR #2777) — composition + proto layer only. - composition: @openfed__entityCache / @openfed__queryCache / @openfed__requestScoped directive definitions, extraction in normalization-factory, ConfigurationData types, warnings, and tests (incl. fuzz + mapping rules) - proto: additive DataSourceConfiguration fields 17-21 and new messages (EntityCacheConfiguration, RootFieldCacheConfiguration, EntityKeyMapping, EntityCacheFieldMapping, CachePopulateConfiguration, CacheInvalidateConfiguration, RequestScopedFieldConfiguration) - regenerated bindings: router/gen, connect-go, connect (incl. graphqlmetrics regen side-effects), composition-go bundle - shared: router-config serializer for the new proto fields Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a73636b commit 41292d0

21 files changed

Lines changed: 10974 additions & 889 deletions

File tree

composition-go/index.global.js

Lines changed: 204 additions & 204 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

composition/src/errors/errors.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,6 +1705,288 @@ export function oneOfRequiredFieldsError({ requiredFieldNames, typeName }: OneOf
17051705
);
17061706
}
17071707

1708+
// Entity caching directive error messages.
1709+
// These are reported during composition when subgraph schemas use entity caching directives
1710+
// incorrectly. Each error corresponds to a validation rule in validateAndExtractEntityCachingConfigs().
1711+
1712+
// @openfed__entityCache requires the type to be a federation entity (must have @key)
1713+
export function entityCacheWithoutKeyErrorMessage(typeName: string): string {
1714+
return `Type "${typeName}" has @openfed__entityCache but no @key directive.`;
1715+
}
1716+
1717+
// @openfed__queryCache must only be defined on fields of the root query type.
1718+
// Mutation/Subscription fields use @openfed__cachePopulate or @openfed__cacheInvalidate.
1719+
// The root query type may be renamed via `schema { query: MyQuery }`; this validation runs
1720+
// after resolving the canonical root type, so the field coords reported here reflect the
1721+
// declared (possibly renamed) type.
1722+
export function queryCacheOnNonQueryFieldErrorMessage(fieldCoords: string): string {
1723+
return (
1724+
`@openfed__queryCache must only be defined on fields of the root query type; found on "${fieldCoords}".` +
1725+
` Use @openfed__cachePopulate or @openfed__cacheInvalidate on mutation or subscription fields.`
1726+
);
1727+
}
1728+
1729+
// @openfed__queryCache requires the return type to be a federation entity — non-entity types have no @key for cache key construction
1730+
export function queryCacheOnNonEntityReturnTypeErrorMessage(fieldCoords: string, returnType: string): string {
1731+
return (
1732+
`Field "${fieldCoords}" has @openfed__queryCache but returns non-entity type "${returnType}".` +
1733+
` @openfed__queryCache requires the return type to be an entity with @key.`
1734+
);
1735+
}
1736+
1737+
// Shared validation for maxAge across @openfed__entityCache, @openfed__queryCache, and @openfed__cachePopulate
1738+
export function maxAgeNotPositiveIntegerErrorMessage(directiveName: string, value: number): string {
1739+
return `@${directiveName} maxAge must be a positive integer, got "${value}".`;
1740+
}
1741+
1742+
// Validation for @openfed__entityCache negativeCacheTTL. 0 disables negative caching; negative values rejected.
1743+
export function negativeCacheTTLNotNonNegativeIntegerErrorMessage(directiveName: string, value: number): string {
1744+
return `@${directiveName} negativeCacheTTL must be a non-negative integer, got "${value}".`;
1745+
}
1746+
1747+
// @openfed__is maps arguments to @key fields — it's meaningless without @openfed__queryCache since only @openfed__queryCache uses argument-to-key mappings
1748+
export function isWithoutQueryCacheErrorMessage(argumentName: string, fieldCoords: string): string {
1749+
return `@openfed__is on argument "${argumentName}" of field "${fieldCoords}" has no effect without @openfed__queryCache.`;
1750+
}
1751+
1752+
// @openfed__is(fields: "...") must reference a field that appears in the entity's @key directive
1753+
export function isReferencesUnknownKeyFieldErrorMessage(
1754+
isField: string,
1755+
argumentName: string,
1756+
fieldCoords: string,
1757+
entityType: string,
1758+
): string {
1759+
return (
1760+
`@openfed__is(fields: "${isField}") on argument "${argumentName}" of field "${fieldCoords}"` +
1761+
` references unknown @key field "${isField}" on type "${entityType}".`
1762+
);
1763+
}
1764+
1765+
// Each @key field may only be mapped to one argument — duplicates would create ambiguous cache keys
1766+
export function duplicateKeyFieldMappingErrorMessage(fieldCoords: string, keyField: string): string {
1767+
return `Multiple arguments on field "${fieldCoords}" map to @key field "${keyField}".`;
1768+
}
1769+
1770+
// @openfed__cacheInvalidate is for side-effect operations — Query fields should use @openfed__queryCache instead
1771+
export function cacheInvalidateOnNonMutationSubscriptionFieldErrorMessage(fieldCoords: string): string {
1772+
return `@openfed__cacheInvalidate is only valid on Mutation or Subscription fields, found on "${fieldCoords}".`;
1773+
}
1774+
1775+
// @openfed__cacheInvalidate needs to know which entity to evict — non-entity return types have no cache key
1776+
export function cacheInvalidateOnNonEntityReturnTypeErrorMessage(fieldCoords: string, returnType: string): string {
1777+
return `Field "${fieldCoords}" has @openfed__cacheInvalidate but returns non-entity type "${returnType}".`;
1778+
}
1779+
1780+
// @openfed__cachePopulate is for side-effect operations — Query fields should use @openfed__queryCache instead
1781+
export function cachePopulateOnNonMutationSubscriptionFieldErrorMessage(fieldCoords: string): string {
1782+
return `@openfed__cachePopulate is only valid on Mutation or Subscription fields, found on "${fieldCoords}".`;
1783+
}
1784+
1785+
// @openfed__cachePopulate needs to know which entity to write — non-entity return types have no cache key
1786+
export function cachePopulateOnNonEntityReturnTypeErrorMessage(fieldCoords: string, returnType: string): string {
1787+
return `Field "${fieldCoords}" has @openfed__cachePopulate but returns non-entity type "${returnType}".`;
1788+
}
1789+
1790+
// A field cannot both populate and invalidate — these are contradictory cache operations
1791+
export function cacheInvalidateAndPopulateMutualExclusionErrorMessage(fieldCoords: string): string {
1792+
return (
1793+
`Field "${fieldCoords}" has both @openfed__cacheInvalidate and @openfed__cachePopulate.` +
1794+
` A field must use one or the other, not both.`
1795+
);
1796+
}
1797+
1798+
export function explicitTypeMismatchErrorMessage(
1799+
argumentName: string,
1800+
fieldCoords: string,
1801+
argumentType: string,
1802+
isField: string,
1803+
entityType: string,
1804+
keyFieldType: string,
1805+
): string {
1806+
return `Argument "${argumentName}" on field "${fieldCoords}" has type "${argumentType}" but @openfed__is(fields: "${isField}") targets @key field "${isField}" of type "${keyFieldType}" on entity "${entityType}".`;
1807+
}
1808+
1809+
export function nonKeyFieldSpecErrorMessage(
1810+
argumentName: string,
1811+
fieldCoords: string,
1812+
isField: string,
1813+
entityType: string,
1814+
): string {
1815+
return `Argument "${argumentName}" on field "${fieldCoords}" uses @openfed__is(fields: "${isField}"), but "${isField}" is not a @key field on entity "${entityType}". @openfed__is can only target fields that are part of a @key.`;
1816+
}
1817+
1818+
export function listArgumentToScalarKeySpecErrorMessage(
1819+
argumentName: string,
1820+
fieldCoords: string,
1821+
argumentType: string,
1822+
isField: string,
1823+
entityType: string,
1824+
keyFieldType: string,
1825+
): string {
1826+
return (
1827+
`Argument "${argumentName}" on field "${fieldCoords}" has type "${argumentType}" but @openfed__is(fields: "${isField}") targets @key field "${isField}" of type "${keyFieldType}" on entity "${entityType}".` +
1828+
' List arguments can only map to scalar key fields when the field returns a list of entities, or to list key fields when the key field itself is a list type.'
1829+
);
1830+
}
1831+
1832+
export function scalarArgumentToListKeySpecErrorMessage(
1833+
argumentName: string,
1834+
fieldCoords: string,
1835+
argumentType: string,
1836+
isField: string,
1837+
entityType: string,
1838+
keyFieldType: string,
1839+
): string {
1840+
return (
1841+
`Argument "${argumentName}" on field "${fieldCoords}" has type "${argumentType}" but @openfed__is(fields: "${isField}") targets @key field "${isField}" of type "${keyFieldType}" on entity "${entityType}".` +
1842+
' A scalar argument cannot map to a list key field.'
1843+
);
1844+
}
1845+
1846+
export function explicitIncompleteCompositeKeyErrorMessage(
1847+
fieldCoords: string,
1848+
argumentName: string,
1849+
mappedField: string,
1850+
entityType: string,
1851+
compositeKey: string,
1852+
missingField: string,
1853+
): string {
1854+
return `Field "${fieldCoords}" has argument "${argumentName}" with @openfed__is mapping to @key field "${mappedField}" on entity "${entityType}", but composite @key "${compositeKey}" is incomplete because no argument maps to required key field "${missingField}".`;
1855+
}
1856+
1857+
export function explicitSingularAdditionalNonKeyArgumentErrorMessage(
1858+
fieldCoords: string,
1859+
argumentName: string,
1860+
keyField: string,
1861+
entityType: string,
1862+
extraArgument: string,
1863+
): string {
1864+
return `Field "${fieldCoords}" has argument "${argumentName}" with @openfed__is mapping to @key field "${keyField}" on entity "${entityType}", but also has additional argument "${extraArgument}" which is not mapped to a key field. All arguments must be key arguments — additional arguments may filter the response, making the cache key incomplete.`;
1865+
}
1866+
1867+
export function explicitCompositeAdditionalNonKeyArgumentErrorMessage(
1868+
fieldCoords: string,
1869+
firstArgument: string,
1870+
secondArgument: string,
1871+
compositeKey: string,
1872+
entityType: string,
1873+
extraArgument: string,
1874+
): string {
1875+
return `Field "${fieldCoords}" has arguments "${firstArgument}" and "${secondArgument}" with @openfed__is mappings covering composite @key "${compositeKey}" on entity "${entityType}", but also has additional argument "${extraArgument}" which is not mapped to a key field. All arguments must be key arguments — additional arguments may filter the response, making the cache key incomplete.`;
1876+
}
1877+
1878+
export function batchListValuedKeyRequiresNestedListsErrorMessage(
1879+
fieldCoords: string,
1880+
isField: string,
1881+
entityType: string,
1882+
actualType: string,
1883+
): string {
1884+
return `Field "${fieldCoords}" returns a list of entities, so cache lookup is a batch lookup and requires one key value per entity. Because @openfed__is(fields: "${isField}") targets list-valued @key field "${isField}" on entity "${entityType}", the argument must provide a list of tag lists (e.g., "[[String!]!]!"), not ${actualType}.`;
1885+
}
1886+
1887+
export function explicitBatchAdditionalNonKeyArgumentErrorMessage(
1888+
fieldCoords: string,
1889+
argumentName: string,
1890+
keyField: string,
1891+
entityType: string,
1892+
extraArgument: string,
1893+
): string {
1894+
return `Field "${fieldCoords}" returns a list of entities, so cache lookup is a batch lookup and requires a single key input that determines the returned entities. Argument "${argumentName}" uses @openfed__is to map to @key field "${keyField}" on entity "${entityType}", but additional argument "${extraArgument}" is not mapped to a key field and may filter the response, so the batch key would be incomplete.`;
1895+
}
1896+
1897+
export function explicitScalarArgumentsCannotEstablishBatchMappingErrorMessage(
1898+
fieldCoords: string,
1899+
entityType: string,
1900+
): string {
1901+
return `Field "${fieldCoords}" returns a list of entities, so cache lookup is a batch lookup and requires one key value per entity. Scalar arguments with @openfed__is mapping to @key fields on entity "${entityType}" cannot provide a batch of keys, so they cannot establish cache key mappings for this field. Use list arguments for batch cache lookups.`;
1902+
}
1903+
1904+
export function multipleListArgumentsBatchFactoryMessage(fieldCoords: string, entityType: string): string {
1905+
return (
1906+
`Field "${fieldCoords}" has multiple list arguments mapping to @key fields on entity "${entityType}".` +
1907+
' Batch cache lookups require a single list argument.' +
1908+
' For composite keys, use a single list of input objects instead.'
1909+
);
1910+
}
1911+
1912+
export function inputObjectCompositeTypeMismatchErrorMessage(
1913+
argumentName: string,
1914+
fieldCoords: string,
1915+
keyFields: string,
1916+
entityType: string,
1917+
inputType: string,
1918+
inputFieldName: string,
1919+
inputFieldType: string,
1920+
entityFieldPath: string,
1921+
entityFieldType: string,
1922+
): string {
1923+
return (
1924+
`Argument "${argumentName}" on field "${fieldCoords}" uses @openfed__is(fields: "${keyFields}") mapping to composite @key on entity "${entityType}",` +
1925+
` but input type "${inputType}" field "${inputFieldName}" has type "${inputFieldType}"` +
1926+
` which does not match key field "${entityFieldPath}" of type "${entityFieldType}".`
1927+
);
1928+
}
1929+
1930+
export function inputObjectCompositeMissingFieldErrorMessage(
1931+
argumentName: string,
1932+
fieldCoords: string,
1933+
keyFields: string,
1934+
entityType: string,
1935+
inputType: string,
1936+
missingFieldName: string,
1937+
): string {
1938+
return (
1939+
`Argument "${argumentName}" on field "${fieldCoords}" uses @openfed__is(fields: "${keyFields}") mapping to composite @key on entity "${entityType}",` +
1940+
` but input type "${inputType}" is missing required key field "${missingFieldName}".`
1941+
);
1942+
}
1943+
1944+
export function nestedInputObjectTypeMismatchErrorMessage(
1945+
argumentName: string,
1946+
fieldCoords: string,
1947+
keyFields: string,
1948+
entityType: string,
1949+
inputType: string,
1950+
inputFieldName: string,
1951+
inputFieldType: string,
1952+
entityFieldPath: string,
1953+
entityFieldType: string,
1954+
): string {
1955+
return (
1956+
`Argument "${argumentName}" on field "${fieldCoords}" maps to nested @key "${keyFields}" on entity "${entityType}",` +
1957+
` but input type "${inputType}" field "${inputFieldName}" has type "${inputFieldType}"` +
1958+
` which does not match key field "${entityFieldPath}" of type "${entityFieldType}".`
1959+
);
1960+
}
1961+
1962+
export function nestedInputObjectMissingFieldErrorMessage(
1963+
argumentName: string,
1964+
fieldCoords: string,
1965+
keyFields: string,
1966+
entityType: string,
1967+
inputType: string,
1968+
missingFieldName: string,
1969+
): string {
1970+
return (
1971+
`Argument "${argumentName}" on field "${fieldCoords}" maps to nested @key "${keyFields}" on entity "${entityType}",` +
1972+
` but input type "${inputType}" is missing required key field "${missingFieldName}".`
1973+
);
1974+
}
1975+
1976+
export function nonInputArgumentCannotTargetCompositeKeyErrorMessage(
1977+
argumentName: string,
1978+
fieldCoords: string,
1979+
keyFields: string,
1980+
entityType: string,
1981+
argumentType: string,
1982+
): string {
1983+
return (
1984+
`Argument "${argumentName}" on field "${fieldCoords}" uses @openfed__is(fields: "${keyFields}") targeting composite @key on entity "${entityType}",` +
1985+
` but argument type "${argumentType}" does not provide nested fields for each key field.` +
1986+
' Use separate arguments or an input object that matches the composite key shape.'
1987+
);
1988+
}
1989+
17081990
export function listSizeInvalidSlicingArgumentErrorMessage(
17091991
directiveCoords: DirectiveArgumentCoords,
17101992
argumentName: ArgumentName,

composition/src/router-configuration/types.ts

Lines changed: 92 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;
@@ -97,7 +108,88 @@ export type ConfigurationData = {
97108
provides?: RequiredFieldConfiguration[];
98109
keys?: RequiredFieldConfiguration[];
99110
requireFetchReasonsFieldNames?: Array<FieldName>;
111+
requestScopedFields?: Array<RequestScopedFieldConfig>;
100112
requires?: RequiredFieldConfiguration[];
113+
// Entity caching configuration — attached during composition when subgraph schemas
114+
// use entity caching directives. These are serialized into the router configuration
115+
// and consumed by the router's entity cache module at runtime.
116+
//
117+
// entityCacheConfigurations: attached to the entity type's ConfigurationData (e.g., "Product")
118+
// rootFieldCacheConfigurations: attached to the Query type's ConfigurationData
119+
// cachePopulateConfigurations: attached to the Mutation/Subscription type's ConfigurationData
120+
// cacheInvalidateConfigurations: attached to the Mutation/Subscription type's ConfigurationData
121+
entityCacheConfigurations?: Array<EntityCacheConfig>;
122+
rootFieldCacheConfigurations?: Array<RootFieldCacheConfig>;
123+
cachePopulateConfigurations?: Array<CachePopulateConfig>;
124+
cacheInvalidateConfigurations?: Array<CacheInvalidateConfig>;
125+
};
126+
127+
// Extracted from @openfed__entityCache(maxAge: Int!, negativeCacheTTL: Int, includeHeaders: Boolean, partialCacheLoad: Boolean, shadowMode: Boolean)
128+
// on OBJECT types. Defines per-entity cache TTL and behavior.
129+
export type EntityCacheConfig = {
130+
typeName: TypeName;
131+
maxAgeSeconds: number;
132+
// TTL (in seconds) for caching "not found" entity responses (entity returned null
133+
// from _entities without errors). 0 disables negative caching; composition rejects
134+
// negative values at validation time.
135+
notFoundCacheTtlSeconds: number;
136+
// When true, request headers are included in the cache key (useful for user-specific entities)
137+
includeHeaders: boolean;
138+
// When true, allows partial cache hits — the router fetches only missing entities from the subgraph
139+
partialCacheLoad: boolean;
140+
// When true, the cache runs in shadow mode — cache reads/writes happen but responses always come from the subgraph.
141+
// Useful for warming caches or validating cache correctness without affecting production traffic.
142+
shadowMode: boolean;
143+
};
144+
145+
// Extracted from @openfed__queryCache(maxAge: Int!, includeHeaders: Boolean, shadowMode: Boolean)
146+
// on Query fields. Tells the router which query fields can serve entities from cache.
147+
export type RootFieldCacheConfig = {
148+
fieldName: FieldName;
149+
maxAgeSeconds: number;
150+
includeHeaders: boolean;
151+
shadowMode: boolean;
152+
// The entity type this query field returns (must have @openfed__entityCache)
153+
entityTypeName: TypeName;
154+
// Maps query arguments to entity @key fields so the router can construct cache keys from query arguments.
155+
// Empty for list-returning fields (cache reads are skipped; only cache writes/population apply).
156+
entityKeyMappings: Array<EntityKeyMappingConfig>;
157+
};
158+
159+
// Groups field mappings for a single entity type returned by a @openfed__queryCache field.
160+
export type EntityKeyMappingConfig = {
161+
entityTypeName: TypeName;
162+
fieldMappings: Array<FieldMappingConfig>;
163+
};
164+
165+
// Maps a single query argument to an entity's @key field.
166+
// Example: query { product(productId: ID!) @openfed__queryCache } with @openfed__is(fields: "id") on productId
167+
// → entityKeyField: "id", argumentPath: ["productId"]
168+
// When the argument name matches the @key field name, auto-mapping occurs without @openfed__is.
169+
export type FieldMappingConfig = {
170+
entityKeyField: FieldName;
171+
argumentPath: Array<string>;
172+
isBatch?: boolean;
173+
};
174+
175+
// Extracted from @openfed__cachePopulate(maxAge: Int) on Mutation/Subscription fields.
176+
// Tells the router to populate the entity cache with the mutation's return value.
177+
// maxAgeSeconds overrides the entity's default TTL when provided.
178+
// entityTypeName identifies which cached entity this populate targets — derived from
179+
// the field's return type, which composition validates must be an @openfed__entityCache-marked entity.
180+
export type CachePopulateConfig = {
181+
fieldName: FieldName;
182+
operationType: string;
183+
entityTypeName: TypeName;
184+
maxAgeSeconds?: number;
185+
};
186+
187+
// Extracted from @openfed__cacheInvalidate on Mutation/Subscription fields.
188+
// Tells the router to evict the returned entity from the cache after the operation completes.
189+
export type CacheInvalidateConfig = {
190+
fieldName: FieldName;
191+
operationType: string;
192+
entityTypeName: TypeName;
101193
};
102194

103195
export type Costs = {

0 commit comments

Comments
 (0)