You could get used to this: Managing GraphQL data

Ember.js Berlin - July 13th, 2021

Francesco Novy

@_fnovy

www.fnovy.com

hello@fnovy.com

mydea

Our stack

  • Ember (Octane)
  • GraphQL API (Java)
  • Cloud Connector (on-premise, C#)

 

Ember & GraphQL

Sneak peak: GraphQL

# app/gql/queries/user-by-email.graphql
query userByEmail($email: String!) {
  userByEmail(email: $email) {
    id
    email
    name
    roles {
      id
      name
    }
  }
}
import { queryManager } from 'ember-apollo-client';
import query from 'my-app/gql/queries/user-by-email.graphql';

export default class MyService extends Service {
  @queryManager apollo;
  
  getUserForEmail(email) {
    let variables = { email: 'francesco@fabscale.com' };
    return this.apollo.query({ query, variables }, 'userByEmail');
  }
 }
{
  id: '1',
  email: 'francesco@fabscale.com',
  name: 'Francesco Novy',
  roles: [
    {
      id: '1',
      name: 'Admin',
    },
    {
      id: '6',
      name: 'Maintenance manager',
    },
  ],
}

Apollo caching

  • Apollo allows to cache full requests

  • The idea is good, but...

  • ... manually keeping track of changes & ensuring no stale state remains is tricky.

Example for caching behavior

import query from 'my-app/gql/queries/user-by-email.graphql';


// Will hit the network
let user = await this.apollo.query({ 
  query, 
  variables: { email: 'francesco@fabscale.com'}
}, 'userByEmail');
 
// Will not hit the network, but re-use the previous response
let user2 = await this.apollo.query({ 
  query, 
  variables: { email: 'francesco@fabscale.com'}
}, 'userByEmail');

// Will hit the network, as variables are different
let user = await this.apollo.query({ 
  query, 
  variables: { email: 'anne@fabscale.com'}
}, 'userByEmail');

Cache & mutations are tricky

import query from 'my-app/gql/queries/user-by-email.graphql';
import mutation from 'my-app/gql/mutations/update-user.graphql';

let user = await this.apollo.query({ 
  query, 
  variables: { email: 'francesco@fabscale.com'}
}, 'userByEmail');

await this.apollo.mutate({
  mutation,
  variables: { email: 'francesco@fabscale.com', name: 'Francesco Smith' }
}, 'updateUser');
 
// Will not hit the network, but re-use the previous response
let user2 = await this.apollo.query({ 
  query, 
  variables: { email: 'francesco@fabscale.com'}
}, 'userByEmail');

// user2.name --> still "Francesco Novy"

Cache & mutations are tricky (2)

  • You can manually update the cache after mutations
  • However, this can become tricky when dealing with relationships, list & single results, etc.

Our solution: A custom cache

  • We leverage `fetchPolicy` to force Apollo to make new queries when necessary
  • We keep a custom cache list for each entity type
  • We keep track of when a query was made and invalidate it after a given time
  • We do not manually update any caches, but only cache/invalidate full queries

Usage - queries

import usersQuery from 'fabscale-app/gql/queries/users.graphql';
import userByEmailQuery from 'fabscale-app/gql/queries/user-by-email.graphql';

export default class UserStore extends Service {
  @service storeManager;
  
  loadUsers() {
    return this.storeManager.query({ 
      query: usersQuery, 
      variables: {}
    }, {
      namespace: 'users',
      cacheEntity: 'User',
      cacheSeconds: 300
    });
  }
  
  loadUser(email) {
    return this.storeManager.query({ 
      query: userByEmailQuery, 
      variables: { email }
    }, {
      namespace: 'userByEmail',
      cacheEntity: 'User'
      cacheId: email,
      cacheSeconds: 500
    });
  }
}

Usage - mutations

import editUserMutation from 'fabscale-app/gql/mutations/edit-user.graphql';

export default class UserStore extends Service {
 // ...

  async editUser(email, name) {
    return await this.storeManager.mutate({ 
      mutation: editUserMutation, 
      variables: { email, name }
    }, {
      namespace: 'editUser',
      invalidateCache: [
          {
            cacheEntity: 'User',
          },
          {
            cacheEntity: 'User',
            cacheId: email,
          },
        ],
    });
  }
}

How it works?

export default class StoreManagerService extends Service {
  @queryManager apollo;

  query(
    options,
    {
      namespace,
      cacheEntity,
      cacheId,
      cacheSeconds = 60,
    } = {}
  ) {
    let useCache = this._checkShouldUseCache(options, {
      cacheEntity,
      cacheId,
      cacheSeconds,
    });

    // Generally, use `cache-first` as strategy, to avoid repeated lookups
    // However, if the last query was longer ago than the specified `cacheSeconds`, 
    // use `network-only` to force re-fetching it
    let fetchPolicy = useCache ? 'cache-first' : 'network-only';

    return this.apollo.query(
      Object.assign({ fetchPolicy }, options),
      namespace
    );
  }
}

Checking the cache

export default class StoreManagerService extends Service {
  _checkShouldUseCache({ cacheEntity, cacheId, cacheSeconds }) {
    let { storeCache } = this;

    let cache = storeCache.getCache(cacheEntity);

    let lastLookup = cacheId ? cache.getForId(cacheId) : cache.getOverall();

    let now = +new Date() / 1000;

    // If the last actual query was longer ago than the specified `cacheSeconds`
    // If `cacheSeconds` is 0, we always want to re-fetch
    // If no lookup was made yet, we always want to re-fetch
    let lastLookupIsOld =
      !cacheSeconds || !lastLookup || lastLookup < now - cacheSeconds;

    // We only update the last lookup time if we are re-fetching
    // To avoid the next lookup being pushed out indefinitely
    if (lastLookupIsOld) {
      if (cacheId) {
        cache.setForId(cacheId, now);
      } else {
        cache.setOverall(now);
      }
    }

    return !lastLookupIsOld;
  }

Get used to this?

Resources for data loading

import { Resource } from 'ember-could-get-used-to-this';

// app/helpers/resources/load-user.js
export default class LoadUserResource extends Resource {
  @service userStore;

  @tracked isError = false;
  @tracked error;
  @tracked data;

  get value() {
    let isLoading = this.loadDataTask.isRunning;
    let { isError } = this;
    let data = isError ? this.error : this.data;

    return {
      isLoading,
      data,
      isError,
    };
  }

  // ...
}

Resources for data loading (2)

export default class LoadUserResource extends Resource {
  // ...
  
  setup() {
    let [email] = this.args.positional;

    this.loadDataTask.perform(options);
  }

  teardown() {
    this.loadDataTask.cancelAll();
  }

  @restartableTask
  *loadDataTask(email) {
    this.isError = false;

    try {
      this.data = yield this.userStore.loadUser(email); 
    } catch (error) {
      this.error = error.message;
      this.isError = true;
    }
  }
}

Using resources

{{#let 
  (resources/load-user 'francesco@fabscale.com') 
  as |resource|
}}
  {{#if resource.isLoading}}
    Loading...
  {{else if resource.isError}}
    Error: {{resource.data}}
  {{else}}
    Your name: {{resource.data.name}}
  {{/if}}
{{/let}}

Thank you!

@_fnovy

www.fnovy.com

hello@fnovy.com

mydea

You could get used to this: Managing GraphQL data

By Francesco Novy

You could get used to this: Managing GraphQL data

  • 296