Configuring the cache

The Ferry cache normalizes incoming data for your GraphQL operations and stores it using a Store. This allows the client to respond to future queries for the same data without sending unnecessary network requests.

Data Normalization

Normalization involves the following steps:

  1. The cache generates a unique ID for every identifiable object included in the response.
  2. The cache stores the objects by ID in a flat lookup table.
  3. Whenever an incoming object is stored with the same ID as an existing object, the fields of those objects are merged.
    • If the incoming object and the existing object share any fields, the incoming object overwrites the cached values for those fields.
    • Fields that appear in only the existing object or only the incoming object are preserved.

Normalization constructs a partial copy of your data graph on your client, in a format that's optimized for reading and updating the graph as your application changes state.

Passing the possibleTypes Map

When the schema contains unions, interfaces queries use fragments, the cache needs a little help to correctly normalize/denormalize data. In this case, pass the possibleTypes parameter to the cache constructor. This is a Map containing subtype information from the schema. It is generated by ferry_generator and located in your schema.gql.dart file.

If you run into DeserializationErrors when using the cache and not passing the possibleTypes map, this is probably the issue you're running into.

Generating Unique Identifiers

By default, the cache generates a unique identifier using an object's __typename and any uniquely identifying fields, called keyFields. By default, the id or _id field (whichever is defined) is used as a keyField, but this behavior can be customized. These two values are separated by a colon (:).

For example, an object with a __typename of Task and an id of 14 is assigned a default identifier of Task:14.

Customizing Identifier Generation by Type

If one of your types defines its primary key with a field besides id or _id, you can customize how the cache generates unique identifiers for that type. To do so, you define TypePolicy for the type and include a Map of all your typePolicies when instantiating the cache.

Include a keyFields field in relevant TypePolicy objects, like so:

final cache = Cache(
typePolicies: {
'Product': TypePolicy(
// In most inventory management systems, a single UPC code uniquely
// identifies any product.
keyFields: {
'upc': true,
},
),
'Person': TypePolicy(
// In some user account systems, names or emails alone do not have to
// be unique, but the combination of a person's name and email is
// uniquely identifying.
keyFields: {
'name': true,
'email': true,
},
),
'Book': TypePolicy(
// If one of the keyFields is an object with fields of its own, you can
// include those nested keyFields by using a nested array of strings:
keyFields: {
'title': true,
'author': {
'name': true,
},
},
),
},
);

This example shows three typePolicies: one for a Product type, one for a Person type, and one for a Book type. Each TypePolicy's keyFields array defines which fields on the type together represent the type's primary key.

The Book type above uses a subfield as part of its primary key. The Book's author field must be an object that includes a name field for this to be valid.

In the example above, the resulting identifier string for a Book object has the following structure:

Book:{"author":{"name":"Ray Bradbury"}, "title":"Fahrenheit 451"}

An object's primary key fields are always listed alphabetically to ensure uniqueness.

Note that these keyFields strings always refer to the actual field names as defined in your schema, meaning the ID computation is not sensitive to field aliases.

Disabling Normalization

You can instruct Ferry not to normalize objects of a certain type. This can be useful for metrics and other transient data that's identified by a timestamp and never receives updates.

To disable normalization for a type, define a TypePolicy for the type (as shown in Customizing Identifier Generation by Type) and set the policy's keyFields field to an empty Map.

Objects that are not normalized are instead embedded within their parent object in the cache. You can't access these objects directly, but you can access them via their parent.

TypePolicy Fields

To customize how the cache interacts with specific types in your schema, you can provide an object mapping __typename strings to TypePolicy objects when you create a new Cache.

A TypePolicy object can include the following fields:

class TypePolicy {
/// Allows defining the primary key fields for this type.
///
/// Pass a `true` value for any fields you wish to use as key fields. You can
/// also use child fields.
///
/// ```dart
/// final bookTypePolicy = TypePolicy(
/// keyFields: {
/// 'title': true,
/// 'author': {
/// 'name': true,
/// }
/// },
/// );
/// ```
///
/// If you don't wish to normalize this type, simply pass an empty `Map`. In
/// that case, we won't normalize this type and it will be reachable from its
/// parent.
Map<String, dynamic> keyFields;
/// Set to `true` if this type is the root Query in your schema.
bool queryType;
/// Set to `true` if this type is the root Mutation in your schema.
bool mutationType;
/// Set to `true` if this type is the root Subscription in your schema.
bool subscriptionType;
/// Allows defining [FieldPolicy]s for this type.
Map<String, FieldPolicy> fields;
TypePolicy({
this.keyFields,
this.queryType = false,
this.mutationType = false,
this.subscriptionType = false,
this.fields = const {},
});
}

Overriding Root Operation Types (Uncommon)

In addition to keyFields, a TypePolicy can indicate that it represents the root query, mutation, or subscription type by setting queryType, mutationType, or subscriptionType as true:

final cache = Cache(
typePolicies: {
'UnconventionalRootQuery': TypePolicy(
queryType: true,
),
},
);

The cache normally obtains __typename information by adding the __typename field to every GraphQL operation selection set it sends to the server. The __typename of the root query or mutation is almost always simply "Query" or "Mutation", so the cache assumes those common defaults unless instructed otherwise in a TypePolicy.

The fields Property

The final property within TypePolicy is the fields property, which is a map from string field names to FieldPolicy objects.

This API is heavily inspired by Apollo.

The 'merge' function is useful for paginated queries, where you want to merge the results of a query with the results of a previous query also in the cache.

Here is an example of how this could be used:

Let's assume we have a query for reviews, which returns a list of reviews.

type Query {
reviews(limit: int, offset: int): [Review]

This is a query has two arguments, limit and offset, which are used to paginate the results.

We can use the merge function to merge the results of the query with the results of any previous queries.

final cache = Cache(
typePolicies: {
'Query': TypePolicy(
fields: {
'reviews': FieldPolicy(
keyArgs: const [], // every reviews query is cached together, do not consider arguments for paging
merge: (existing, incoming, options) {
final merged = (LinkedHashSet<dynamic>(
equals: jsonMapEquals,
hashCode: const DeepCollectionEquality().hash)
..addAll(existing ?? [])
..addAll(incoming ?? []))
.toList();
return merged.toList();
},
),
},
),
}
);

This shows a custom merge function for the reviews query. First, the keyArgs are set to an empty list, meaning that every query for reviews is cached together. Usually, every query would be cached separately (e.g reviews(limit: 10, offset: 0) and reviews(limit: 10, offset: 10) would be cached separately. Setting the keyArgs to an empty list means that the queries are cached.

Then, the merge function is defined, which takes the existing reviews and the incoming reviews and merges them together.

Here, we use a LinkedHashSet to merge the reviews, which is a set that preserves the order of the elements, but does not allow duplicates. This is useful because the reviews are paginated, so we want to preserve the order of the reviews, but we don't want to show the same review twice if one would be returned in multiple pages.

Now the cache merge all the pages in a single list of all reviews.

For more advanced use cases, you can check out the documentation of Apollo for merging and pagination: Apollo.

Ferry's API is very similar.