aerogear / offix

GraphQL Offline Client and Server
https://offix.dev
Apache License 2.0
758 stars 45 forks source link

Optimistic cache update example with Hasura #333

Closed antoineol closed 4 years ago

antoineol commented 4 years ago

Feature Request

Is your feature request related to a problem? Please describe.

Is there a way to have an example of optimistic cache updates using Hasura as graphql server (and React hooks for client)? Hasura's usage of subscription is a bit different from what we have in typical Apollo server examples. It's similar to Firebase: it pushes the full, updated data list.

I found your tool in a blog post and was thinking "this is what I need!" to have optimistic UI, but I was not able to make it work. I guess this is due to the Hasura's specific subscription.

Describe the solution you'd like

A working example of Offix + React Hook + Hasura with cache updates on mutation to power optimistic UI updates.

If not currently possible, would you consider it in your roadmap?

Describe alternatives you've considered

Use my own utility functions to get closer to optimistic UI with less boilerplate code than what raw Apollo client requires.

Thanks a lot for this great project!

wtrocki commented 4 years ago

I was actually reading today about hasura subscription model. Generally we are supporting automatic cache updates by using subscribeToMore method. Hasura follows the GraphQL spec so there should not be a difference between Apollo - we can use any client with hasura. Their examples base on Apollo.

The real issue here might be that offix by default do not apply automatic cache updates for subscriptions. This can be fixed by using our cache helper in subscription handler or by using Apollo API directly ( client.cache.write ). I can follow up if that is the issue here.

I'm little bit confused about mentioning optimistic responses. Typically they are only used for mutations so do not apply to subscriptions. I might be missing some key facts. Lets work with the details and find what is causing issue for you - we can document some harsura quirks if needed. Do we need sample app? It is hard to say at this point

antoineol commented 4 years ago

Thanks for your answer! For optimistic, yes I was thinking about mutations (and subscriptions should push the updated list after the mutation). A full sample app may not be required, just the key code with the mutation and its options should be already very helpful.

In case it can help, I created a sandbox for another issue with a sample Hasura app: https://codesandbox.io/s/apollo-subscription-with-auth-2-32uk9 (it's using an instance of Hasura hosted on heroku with an example of JWT authentication for another usecase).

In the meanwhile, I tried what's documented on Hasura tutorials website here, for vanilla cache updates, and as you suggested, I found that it works well for queries but not for subscriptions. I haven't found cache.readQuery equivalent for subscription (it tries to read on queries only). I will probably try with client.cache.write you suggested and see what I can do. If you have a simpler equivalent using offix, I'm interested :)

_Edit: I updated the code with direct client.cache use, it works to read and write in the cache, but the subscription does not take the new values in ROOTSUBSCRIPTION, so the UI doesn't update until the remote server pushes the new list. Below code was updated with this attempt.

Here is my attempt (with hypotheses about the data model and tech stack) that works with queries but not with subscriptions:

// Usage with mutator function generated by useMutation():
mutator({
      variables: { object },
      update: optimisticInsertCacheUpdate({ object, query: gql`{ author { id, name } }`, rootField: 'author' }),
})

// Implementation:

function optimisticInsertCacheUpdate<T>(
  { query, rootField, object, idField }:
    { query: DocumentNode, rootField: keyof Subscription_Root, object: T, idField?: string }) {
  return optimisticCacheUpdateOperation({
    query, rootField, idField, operation: (cacheEntities, id) => {
      // Merge old and new
      const completedObj = { ...object, [id]: uuidv4(), __typename: rootField };
      return [...cacheEntities, completedObj];
    },
  });
}

function optimisticDeleteCacheUpdate<T>(
  { query, rootField, id, idField }:
    { query: DocumentNode, rootField: keyof Subscription_Root, id: any, idField?: keyof T }) {
  return optimisticCacheUpdateOperation({
    query, rootField, idField, operation: (cacheEntities, idF) => {
      return cacheEntities.filter(entity => entity[idF] !== id);
    },
  });
}

function optimisticCacheUpdateOperation<T = any>(
  { query, rootField, idField, operation }:
    {
      query: DocumentNode, rootField: keyof Subscription_Root, operation: (cacheEntities: T[],
                                                                           id: keyof T) => T[], object?: T, idField?: keyof T,
    }) {
  const id = idField || 'id';
  return (cache: DataProxy, { data }: FetchResult<Mutation_Root>) => {
    // Fetch the existing entities from the cache
    const q = subscriptionToQuery(query);
    let cacheEntities: Subscription_Root | null = getGqlClient().cache
      .read({ query: q, optimistic: true, rootId: 'ROOT_SUBSCRIPTION' });
    if (!cacheEntities) {
      cacheEntities = { [rootField]: [] } as unknown as Subscription_Root;
    }
    if (!data) {
      return;
    }
    // @ts-ignore
    const newEntities = operation(cacheEntities[rootField], id);
    getGqlClient().cache.write({ query: q, result: { [rootField]: newEntities }, dataId: 'ROOT_SUBSCRIPTION' });
  };
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    // tslint:disable-next-line:one-variable-per-declaration no-bitwise
    const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

export function subscriptionToQuery(subscription: DocumentNode): DocumentNode {
  console.log(subscription);
  return {
    ...subscription, definitions: subscription.definitions.map((def) => {
      const op = def as OperationDefinitionNode;
      if (op.kind === 'OperationDefinition' && op.operation === 'subscription') {
        return { ...op, operation: 'query' };
      }
      return op;
    }),
  };
}
wtrocki commented 4 years ago

@antoineol Amazing response. Thank you so much for going back with so much details. I we will need some time to get thru this and come up with something that we can put into documentation.

CC @darahayes

wtrocki commented 4 years ago

Since Hasura is using different formats. We can support hasura, graphback etc. Those formats using different cache providers. Having solution independent of graphql mutation format will be desired. Leaving this open as we need to refine how to fix this into docs.

kingsleyzissou commented 4 years ago

We are hoping to resolve this by providing out of the box cache updates in the near future. Feel free to re-open if you have any questions or further issues.