From 76825c06def8f91f53e6fb346d0459c46340c406 Mon Sep 17 00:00:00 2001 From: BoD Date: Thu, 1 Aug 2024 15:39:21 +0200 Subject: [PATCH 1/7] Add Declarative API section --- design-docs/Expiration.md | 89 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/design-docs/Expiration.md b/design-docs/Expiration.md index 0619cc0e..85a445b5 100644 --- a/design-docs/Expiration.md +++ b/design-docs/Expiration.md @@ -17,7 +17,7 @@ by default, unless a max age is defined on the client. To do this, let's store both dates in the Record. Implementation: we can remove the current `Record.date` field, and instead use the `Record.metadata` map which can store arbitrary data per field. -## API +## Programmatic API ```kotlin @@ -125,3 +125,90 @@ return resolvedField ``` Note: the `maxStale` duration is to allow for a per-operation override of the max age / expiration date. + +## Declarative API + +### Schema directives + +These directives will land in a `cache` v0.1 [Apollo Spec](https://specs.apollo.dev/). + +```graphql +""" +Indicates that a field (or a type's fields) should be considered stale after the given duration +in seconds has passed since it has been received. + +When applied on a type, all fields of the type inherit the max age. + +When applied on a field whose parent type has a max age, the field's max age takes precedence. + +```graphql +type User @maxAge(seconds: 10) { + id: ID! + email: String @maxAge(seconds: 20) +} +\``` + +`User.id` is considered stale after 10 seconds, and `User.email` after 20 seconds. +""" +directive @maxAge(seconds: Int!) on FIELD_DEFINITION | OBJECT + +""" +Indicates that a field should be considered stale after the given duration in seconds has passed +since it has been received. + +When applied on a field whose parent type has a max age, the field's max age takes precedence. + +`@maxAgeField` is the same as `@maxAge` but can be used on type system extensions for services +that do not own the schema like client services: + +```graphql +# extend the schema to set a max age on User.email. +extend type User @maxAgeField(name: "email", seconds: 20) +\``` + +`User.email` is considered stale after 20 seconds. +""" +directive @maxAgeField(name: String!, seconds: Int!) repeatable on OBJECT +``` + +### Codegen changes + +#### Option A: Add max age info to `ObjectType` + +```kotlin +class ObjectType internal constructor( + name: String, + keyFields: List, + implements: List, + embeddedFields: List, + typeMaxAge: Int?, // NEW! Contains the value of the @maxAge directive on the type (or null if not set) + fieldsMaxAge: Map?, // NEW! Contains the value of the @maxAge directive on the type's fields (and of @maxAgeField on the type) (or null if not set) +) : CompiledNamedType(name) {... } +``` + +With this option, a `CacheResolver` can access the max age information directly from the `ObjectType` that is passed to it. + +Note: currently we pass only the parent type name to `CacheResolver` but we can pass the `CompiledNamedType` instead. + +- Pro: the `CacheResolver` is autonomous, no need to do any 'plumbing' to pass it the generated information. +- Con: generates more fields for everybody, even users not using the feature (albeit with null values for them). + +#### Option B: Generate a dedicated file for max age info + +We generate a file looking like this: + +```kotlin +object Expiration { + val maxAges: Map = mapOf( + "MyType" to 20, + "MyType.id" to 10, + // ... + ) +} +``` + +This is the approach we took for the `Pagination` feature where we need a list of connection types. + +- Pro: no codegen impact for non-users of the feature, the file can be generated only when there are fields selected that have a max age in + the schema. +- Con: more 'plumbing' - it requires to manually pass `Expiration.maxAges` to the constructor of the `CacheResolver`. From 800b58df60d144b40fc5f512b156a8ad6a41ad85 Mon Sep 17 00:00:00 2001 From: BoD Date: Fri, 2 Aug 2024 18:18:40 +0200 Subject: [PATCH 2/7] Dig more about reusing Apollo Server's @cacheControl directive --- design-docs/Expiration.md | 145 ++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 15 deletions(-) diff --git a/design-docs/Expiration.md b/design-docs/Expiration.md index 85a445b5..7511998d 100644 --- a/design-docs/Expiration.md +++ b/design-docs/Expiration.md @@ -130,45 +130,160 @@ Note: the `maxStale` duration is to allow for a per-operation override of the ma ### Schema directives +#### Existing backend directive + +Apollo Server [has a `@cacheControl` directive](https://www.apollographql.com/docs/apollo-server/performance/caching) that can be be applied +on fields and types to set a max age. This is used by the server to set a `Cache-Control` HTTP header on the response. + +Here's its definition: +```graphql +enum CacheControlScope { + PUBLIC + PRIVATE +} + +directive @cacheControl( + maxAge: Int + scope: CacheControlScope + inheritMaxAge: Boolean +) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION +``` + +It could be beneficial to re-use this directive on the client side, rather that inventing a new one. This raises a few questions. + +##### Default max age + +Apollo Server [uses heuristics](https://www.apollographql.com/docs/apollo-server/performance/caching/#default-maxage) to decide on the +default max age when no directive is applied: +- root fields are not cacheable by default (maxAge=0) +- same for fields that return a composite type +- non root fields that return a leaf type have the maxAge of their parent field (which is 0 by default, not cacheable) + +On the backend it is reasonable to avoid over-caching, which can lead to bugs. + +On the client side however, users opt-in to the cache globally and are expecting everything to be cached indefinitely by default +(which is the current behavior). The max age is an additional configuration. Because of this, the backend heuristics which essentially +default to not cacheable would probably be confusing to a client user. + +If we don't have these heuristics, the `inheritMaxAge` argument becomes unneeded, and we can remove it. If it is removed, then the `maxAge` +argument should be required. + +##### Meaning of scope + +On the client side the meaning of the `scope` argument is unclear for the moment, but could be useful if we want to share a cache between +users. This is probably out of scope for now and can either be removed or ignored. + +##### Ability to use backend and client directives at the same time + +The ability to use both server side cache control (expiration date computed from the `Cache-Control` or `Age` header in the response), and +client side cache control (max ages configured on the client) is desirable. The client app can override the server side values. + +If we use the same directive for both, there is a potential conflict: clients will have `@cacheControl` directives in their schema that are +meant for the server, and mustn't be interpreted by the codegen. + +However, this can be solved thanks to the `@link` mechanism which ensures that the codegen only considers the directives that are properly +namespaced. An [alias](https://specs.apollo.dev/link/v1.0/#example-import-an-aliased-name) can be used to avoid a conflict. + +##### Meaning of applying the directive to a type + +On the server side, applying the `@cacheControl` directive to a type means that all fields that return this type have the configured max +age. + +```graphql +type Query { + me: User! + user(id: ID!): User +} + +type User @cacheControl(maxAge: 10) { + id: ID! + name: String! + picture: String @cacheControl(maxAge: 20) +} +``` +is equivalent to: +```graphql +type Query { + me: User! @cacheControl(maxAge: 10) + user(id: ID!): User @cacheControl(maxAge: 10) +} + +type User { + id: ID! + name: String! + picture: String @cacheControl(maxAge: 20) +} +``` + +Another way to interpret it could be that it represents the default max age for the type's fields. In that case it would be equivalent to: +```graphql +type Query { + me: User! + user(id: ID!): User +} + +type User { + id: ID! @cacheControl(maxAge: 10) + name: String! @cacheControl(maxAge: 10) + picture: String @cacheControl(maxAge: 20) +} +``` + +I think the backend meaning is more intuitive and expressive and is the one we should go with. In that case the same meaning should +be used when object coordinates are passed to `SchemaCoordinatesMaxAgeProvider`. + +#### New client directives + +I propose we use a simplified version of the backend directive. + These directives will land in a `cache` v0.1 [Apollo Spec](https://specs.apollo.dev/). ```graphql """ -Indicates that a field (or a type's fields) should be considered stale after the given duration -in seconds has passed since it has been received. +Indicates that a field or a type should be considered stale after the given max +age in seconds has passed since it has been received. -When applied on a type, all fields of the type inherit the max age. +When applied to a type, the max age applies to all fields in the schema that +are of that type. -When applied on a field whose parent type has a max age, the field's max age takes precedence. +When applied to a field whose parent type has a max age, the field's max age +takes precedence. ```graphql -type User @maxAge(seconds: 10) { +type Query { + me: User + user(id: ID!): User @cacheControl(maxAge: 5) +} + +type User @cacheControl(maxAge: 10) { id: ID! - email: String @maxAge(seconds: 20) + email: String } \``` -`User.id` is considered stale after 10 seconds, and `User.email` after 20 seconds. +`Query.me` is considered stale after 10 seconds, and `Query.user` after 5 +seconds. """ -directive @maxAge(seconds: Int!) on FIELD_DEFINITION | OBJECT +directive @cacheControl(maxAge: Int!) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION """ -Indicates that a field should be considered stale after the given duration in seconds has passed -since it has been received. +Indicates that a field should be considered stale after the given duration in +seconds has passed since it has been received. -When applied on a field whose parent type has a max age, the field's max age takes precedence. +When applied to a field whose parent type has a max age, the field's max age +takes precedence. -`@maxAgeField` is the same as `@maxAge` but can be used on type system extensions for services -that do not own the schema like client services: +`@cacheControlField` is the same as `@cacheControl` but can be used on type +system extensions for services that do not own the schema like client services: ```graphql # extend the schema to set a max age on User.email. -extend type User @maxAgeField(name: "email", seconds: 20) +extend type User @cacheControlField(name: "email", maxAge: 20) \``` `User.email` is considered stale after 20 seconds. """ -directive @maxAgeField(name: String!, seconds: Int!) repeatable on OBJECT +directive @cacheControlField(name: String!, maxAge: Int!) repeatable on OBJECT | INTERFACE ``` ### Codegen changes From 2a75ca646441a106391e2b3d07afffa57cddb4d3 Mon Sep 17 00:00:00 2001 From: BoD Date: Fri, 2 Aug 2024 18:19:52 +0200 Subject: [PATCH 3/7] Fix typo --- design-docs/Expiration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/design-docs/Expiration.md b/design-docs/Expiration.md index 7511998d..67572ed2 100644 --- a/design-docs/Expiration.md +++ b/design-docs/Expiration.md @@ -132,8 +132,8 @@ Note: the `maxStale` duration is to allow for a per-operation override of the ma #### Existing backend directive -Apollo Server [has a `@cacheControl` directive](https://www.apollographql.com/docs/apollo-server/performance/caching) that can be be applied -on fields and types to set a max age. This is used by the server to set a `Cache-Control` HTTP header on the response. +Apollo Server [has a `@cacheControl` directive](https://www.apollographql.com/docs/apollo-server/performance/caching) that can be applied +to fields and types to set a max age. This is used by the server to set a `Cache-Control` HTTP header on the response. Here's its definition: ```graphql From d97f091bb6dea50ecb6bafdc1f04ca40865f775b Mon Sep 17 00:00:00 2001 From: BoD Date: Tue, 6 Aug 2024 09:00:51 +0200 Subject: [PATCH 4/7] Re-use the backend @cacheControl directive --- design-docs/Expiration.md | 145 ++++++++++++++------------------------ 1 file changed, 53 insertions(+), 92 deletions(-) diff --git a/design-docs/Expiration.md b/design-docs/Expiration.md index 67572ed2..8f9ab15b 100644 --- a/design-docs/Expiration.md +++ b/design-docs/Expiration.md @@ -149,34 +149,30 @@ directive @cacheControl( ) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION ``` -It could be beneficial to re-use this directive on the client side, rather that inventing a new one. This raises a few questions. +It would be beneficial to re-use this directive on the client side, rather that inventing a new one. This raises a few questions. + +##### Meaning of scope + +On the client side the meaning of the `scope` argument is unclear for the moment, but could be useful in the future if we want to share a +cache between users. This is out of scope for now and the argument could be left unused. ##### Default max age Apollo Server [uses heuristics](https://www.apollographql.com/docs/apollo-server/performance/caching/#default-maxage) to decide on the default max age when no directive is applied: -- root fields are not cacheable by default (maxAge=0) +- root fields have the default maxAge (which is 0 by default) - same for fields that return a composite type -- non root fields that return a leaf type have the maxAge of their parent field (which is 0 by default, not cacheable) - -On the backend it is reasonable to avoid over-caching, which can lead to bugs. +- non root fields that return a leaf type inherit the maxAge of their parent field -On the client side however, users opt-in to the cache globally and are expecting everything to be cached indefinitely by default -(which is the current behavior). The max age is an additional configuration. Because of this, the backend heuristics which essentially -default to not cacheable would probably be confusing to a client user. - -If we don't have these heuristics, the `inheritMaxAge` argument becomes unneeded, and we can remove it. If it is removed, then the `maxAge` -argument should be required. - -##### Meaning of scope - -On the client side the meaning of the `scope` argument is unclear for the moment, but could be useful if we want to share a cache between -users. This is probably out of scope for now and can either be removed or ignored. +The default max age [is configurable](https://www.apollographql.com/docs/apollo-server/performance/caching/#setting-a-different-default-maxage) +on the backend and has a default value of 0. We must also make it configurable on the client side, but should make it a required argument +rather than having a default, making the behavior more predictable. ##### Ability to use backend and client directives at the same time The ability to use both server side cache control (expiration date computed from the `Cache-Control` or `Age` header in the response), and -client side cache control (max ages configured on the client) is desirable. The client app can override the server side values. +client side cache control (max ages configured on the client) is desirable. The client app should be able to override the server side +values. If we use the same directive for both, there is a potential conflict: clients will have `@cacheControl` directives in their schema that are meant for the server, and mustn't be interpreted by the codegen. @@ -184,70 +180,32 @@ meant for the server, and mustn't be interpreted by the codegen. However, this can be solved thanks to the `@link` mechanism which ensures that the codegen only considers the directives that are properly namespaced. An [alias](https://specs.apollo.dev/link/v1.0/#example-import-an-aliased-name) can be used to avoid a conflict. -##### Meaning of applying the directive to a type - -On the server side, applying the `@cacheControl` directive to a type means that all fields that return this type have the configured max -age. - -```graphql -type Query { - me: User! - user(id: ID!): User -} +#### Directive definitions -type User @cacheControl(maxAge: 10) { - id: ID! - name: String! - picture: String @cacheControl(maxAge: 20) -} -``` -is equivalent to: -```graphql -type Query { - me: User! @cacheControl(maxAge: 10) - user(id: ID!): User @cacheControl(maxAge: 10) -} +We can re-use the backend `@cacheControl` directive on the client, and add a variant `@cacheControlField`, to configure fields via +extensions. -type User { - id: ID! - name: String! - picture: String @cacheControl(maxAge: 20) -} -``` +These definitions will land in a `cache` v0.1 [Apollo Spec](https://specs.apollo.dev/). -Another way to interpret it could be that it represents the default max age for the type's fields. In that case it would be equivalent to: ```graphql -type Query { - me: User! - user(id: ID!): User +""" +Possible values for the `@cacheControl` `scope` argument (unused on the client). +""" +enum CacheControlScope { + PUBLIC + PRIVATE } -type User { - id: ID! @cacheControl(maxAge: 10) - name: String! @cacheControl(maxAge: 10) - picture: String @cacheControl(maxAge: 20) -} -``` - -I think the backend meaning is more intuitive and expressive and is the one we should go with. In that case the same meaning should -be used when object coordinates are passed to `SchemaCoordinatesMaxAgeProvider`. - -#### New client directives - -I propose we use a simplified version of the backend directive. - -These directives will land in a `cache` v0.1 [Apollo Spec](https://specs.apollo.dev/). - -```graphql """ -Indicates that a field or a type should be considered stale after the given max -age in seconds has passed since it has been received. +Configures cache settings for a field or type. -When applied to a type, the max age applies to all fields in the schema that -are of that type. +- `maxAge`: The maximum amount of time the field's cached value is valid, in seconds. The default value is configurable. +- `inheritMaxAge`: If true, the field inherits the `maxAge` of its parent field. If set to `true`, `maxAge` must not be provided. +- `scope`: Unused on the client. -When applied to a field whose parent type has a max age, the field's max age -takes precedence. +When applied to a type, the settings apply to all schema fields that return this type. + +Field-level settings override type-level settings. ```graphql type Query { @@ -261,29 +219,34 @@ type User @cacheControl(maxAge: 10) { } \``` -`Query.me` is considered stale after 10 seconds, and `Query.user` after 5 -seconds. +`Query.me` is valid for 10 seconds, and `Query.user` for 5 seconds. """ -directive @cacheControl(maxAge: Int!) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION +directive @cacheControl( + maxAge: Int + inheritMaxAge: Boolean + scope: CacheControlScope +) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION """ -Indicates that a field should be considered stale after the given duration in -seconds has passed since it has been received. - -When applied to a field whose parent type has a max age, the field's max age -takes precedence. +Configures cache settings for a field. -`@cacheControlField` is the same as `@cacheControl` but can be used on type -system extensions for services that do not own the schema like client services: +`@cacheControlField` is the same as `@cacheControl` but can be used on type system extensions for services that do not own the schema like +client services: ```graphql # extend the schema to set a max age on User.email. extend type User @cacheControlField(name: "email", maxAge: 20) \``` -`User.email` is considered stale after 20 seconds. +`User.email` is valid for 20 seconds. """ -directive @cacheControlField(name: String!, maxAge: Int!) repeatable on OBJECT | INTERFACE +directive @cacheControlField( + name: String! + maxAge: Int + inheritMaxAge: Boolean + scope: CacheControlScope +) repeatable on OBJECT | INTERFACE + ``` ### Codegen changes @@ -296,15 +259,13 @@ class ObjectType internal constructor( keyFields: List, implements: List, embeddedFields: List, - typeMaxAge: Int?, // NEW! Contains the value of the @maxAge directive on the type (or null if not set) - fieldsMaxAge: Map?, // NEW! Contains the value of the @maxAge directive on the type's fields (and of @maxAgeField on the type) (or null if not set) -) : CompiledNamedType(name) {... } + typeMaxAge: MaxAge?, // NEW! Contains the value of the @cacheControl directive on the type (or null if not set) + fieldsMaxAge: Map?, // NEW! Contains the value of the @cacheControl directive on the type's fields (and of @cacheControlField on the type) (or null if not set) +) : CompiledNamedType(name) { ... } ``` With this option, a `CacheResolver` can access the max age information directly from the `ObjectType` that is passed to it. -Note: currently we pass only the parent type name to `CacheResolver` but we can pass the `CompiledNamedType` instead. - - Pro: the `CacheResolver` is autonomous, no need to do any 'plumbing' to pass it the generated information. - Con: generates more fields for everybody, even users not using the feature (albeit with null values for them). @@ -314,9 +275,9 @@ We generate a file looking like this: ```kotlin object Expiration { - val maxAges: Map = mapOf( - "MyType" to 20, - "MyType.id" to 10, + val maxAges: Map = mapOf( + "MyType" to MaxAge.Value(20), + "MyType.id" to MaxAge.Inherit, // ... ) } From fb4b98e71b31926cddb6e38110b3fd66a3c76ff0 Mon Sep 17 00:00:00 2001 From: BoD Date: Wed, 7 Aug 2024 17:33:17 +0200 Subject: [PATCH 5/7] Update programmatic API to make it closer and compatible with the @CacheControl directive --- design-docs/Expiration.md | 46 +++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/design-docs/Expiration.md b/design-docs/Expiration.md index 8f9ab15b..e53fc704 100644 --- a/design-docs/Expiration.md +++ b/design-docs/Expiration.md @@ -42,14 +42,16 @@ class ExpirationCacheResolver(private val maxAgeProvider: MaxAgeProvider) : Cach interface MaxAgeProvider { /** * Returns the max age for the given type and field. - * @return null if no max age is defined for the given type and field. */ - fun getMaxAge(maxAgeContext: maxAgeContext): Duration? + fun getMaxAge(maxAgeContext: maxAgeContext): Duration } -class maxAgeContext( - val field: CompiledField, - val parentType: String, +class MaxAgeContext( + /** + * The path of the field to get the max age of. + * The first element is the root object, the last element is the field to get the max age of. + */ + val fieldPath: List, ) // Note: using a class instead of arguments allows for future evolutions. @@ -60,25 +62,39 @@ class GlobalMaxAgeProvider(private val maxAge: Duration) : MaxAgeProvider { override fun getMaxAge(maxAgeContext: maxAgeContext): Duration = maxAge } +sealed interface MaxAge { + class Duration(val duration: kotlin.time.Duration) : MaxAge + data object Inherit : MaxAge +} + /** * A provider that returns a max age based on [schema coordinates](https://github.com/graphql/graphql-spec/pull/794). - * The given coordinates must be object (e.g. `MyType`) or field (e.g. `MyType.myField`) coordinates. - * If a field matches both field and object coordinates, the field ones are used. + * The given coordinates must be object/interface/union (e.g. `MyType`) or field (e.g. `MyType.myField`) coordinates. + * + * The max age of a field is determined as follows: + * - If the field has a [MaxAge.Duration] max age, return it. + * - Else, if the field has a [MaxAge.Inherit] max age, return the max age of the parent field. + * - Else, if the field's type has a [MaxAge.Duration] max age, return it. + * - Else, if the field's type has a [MaxAge.Inherit] max age, return the max age of the parent field. + * - Else, if the field is a root field, or the field's type is composite, return the default max age. + * - Else, return the max age of the parent field. + * + * Then the lowest of the field's max age and its parent field's max age is returned. */ class SchemaCoordinatesMaxAgeProvider( - private val coordinatesToDurations: Map, - private val defaultMaxAge: Duration? = null, + private val coordinatesToMaxAges: Map, + private val defaultMaxAge: Duration, ) : MaxAgeProvider { - override fun getMaxAge(maxAgeContext: maxAgeContext): Duration? { + override fun getMaxAge(maxAgeContext: MaxAgeContext): Duration { // ... } } // Example usage: val maxAgeProvider = SchemaCoordinatesMaxAgeProvider( - coordinatesToDurations = mapOf( - "MyType.myField" to 5.minutes, - "MyType" to 10.minutes, + coordinatesToMaxAges = mapOf( + "MyType" to MaxAge.Duration(10.minutes), + "MyType.myField" to MaxAge.Duration(5.minutes), ), defaultMaxAge = 1.days, ) @@ -101,14 +117,12 @@ val maxStale = // max stale duration from cache headers (if any, or 0) // First consider the field's max age (client side) val fieldMaxAge = // field's max age from the passed MaxAgeProvider -if (fieldMaxAge != null) { - val fieldReceivedDate = // field's received date from the Record +val fieldReceivedDate = // field's received date from the Record if (fieldReceivedDate != null) { val fieldAge = currentDate - fieldReceivedDate val stale = fieldAge - fieldMaxAge if (stale >= maxStale) { // throw cache miss - } } } From bea243796283c6e911234ddc029ad5eb0f10f0fc Mon Sep 17 00:00:00 2001 From: Benoit 'BoD' Lubek Date: Fri, 27 Sep 2024 07:46:44 +0200 Subject: [PATCH 6/7] Update Expiration.md --- design-docs/Expiration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/design-docs/Expiration.md b/design-docs/Expiration.md index e53fc704..652e529c 100644 --- a/design-docs/Expiration.md +++ b/design-docs/Expiration.md @@ -301,4 +301,5 @@ This is the approach we took for the `Pagination` feature where we need a list o - Pro: no codegen impact for non-users of the feature, the file can be generated only when there are fields selected that have a max age in the schema. +- Pro: this can be done in a dedicated Gradle plugin - Con: more 'plumbing' - it requires to manually pass `Expiration.maxAges` to the constructor of the `CacheResolver`. From 681299cb9e645113c3db890a5516661570c9f6f5 Mon Sep 17 00:00:00 2001 From: Benoit 'BoD' Lubek Date: Tue, 1 Oct 2024 11:33:22 +0200 Subject: [PATCH 7/7] Add a note about codegen --- design-docs/Expiration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/design-docs/Expiration.md b/design-docs/Expiration.md index 652e529c..b886d938 100644 --- a/design-docs/Expiration.md +++ b/design-docs/Expiration.md @@ -303,3 +303,5 @@ This is the approach we took for the `Pagination` feature where we need a list o the schema. - Pro: this can be done in a dedicated Gradle plugin - Con: more 'plumbing' - it requires to manually pass `Expiration.maxAges` to the constructor of the `CacheResolver`. + +In the end this (B) is the approach we're taking, and using an Apollo Kotlin Compiler Plugin to do so.