apollographql / apollo-feature-requests

🧑‍🚀 Apollo Client Feature Requests | (no 🐛 please).
Other
130 stars 7 forks source link

Umbrella issue: Cache invalidation & deletion #4

Closed hwillson closed 4 years ago

hwillson commented 6 years ago

Migrated from: apollographql/apollo-client#621

legatek commented 6 years ago

Hi there,

I was following the originating thread and wanted to help set the context for this feature. Is there currently a feature doc around this cache invalidation that gives us an idea of what to expect from apollo in the future?

ralphchristianeclipse commented 6 years ago

Why dont we add a prop like

deleted: true

as you can see some of the mapped types

has generated true something like that

deleted: true would be ignored on all queries that are mapped to it

ZwaarContrast commented 6 years ago

An api that would allow for deletion by typename and id would be very helpful indeed. Refetching queries or updating only work for very simple use-cases.

store.delete('User',1)

fbartho commented 6 years ago

I also would appreciate deleting all objects with a given type name, and related, but it would be nice if we could deep clone a cache, safely.

dallonf commented 6 years ago

It'd also be nice to be able to clear out the results of a field, maybe even given certain arguments.

Example: I just deleted a Widget, so all results from Query.widgets should be cleared. Every other Widget (and result of Query.widgetById) that's currently cached is fine to remain in the cache.

Another example: I just disabled a Widget, so now I want to clear results from Query.widgets(hideDisabled: true), but Query.widgets(hideDisabled: false, search: "some name") could remain cached.

Christilut commented 6 years ago

I'm abusing resetStore now but that really ruins the whole point of using GraphQL... And getting errors like Error: Store reset while query was in flight(not completed in link chain) which even makes this (very nasty) workaround a pain to use.

I really don't get why I can't just invalidate a part of the store... Seems like a basic feature

sammysaglam commented 6 years ago

Below, a solution for this; hopefully could work for your use cases. It's kinda hacky :disappointed: , but does the job:

In the example below, we needed to delete an Event -- so, to make it work, simply set your mutation's update to something similar to below:

update: store => {
   const deletedEvent = {
      ...store.data.get(`Event:${eventId /* <-- put your ID */}`),
      id: '__DELETED__',
   };

   store.data.set(`Event:${eventId /* <-- put your ID */}`, deletedEvent);
}

additionally, in your queries (which for me was the query for getting Events), set a filter as such:

eventsData: data.events.filter(({ id }) => id !== '__DELETED__')
Christilut commented 6 years ago

Thanks, that does work (and is so hacky haha, but at this point anything goes) but the query is run before the update so it doesn't filter out the deleted item. I could use a getter for that but then I'm doing all the graphql stuff through vuex getters which seems like I'm going further away from proper solution :stuck_out_tongue:

I'm gonna see if I can a satisfactory solution by combining apollo-vue and some of these hacks

chris-guidry commented 6 years ago

@Christilut - to delete/invalidate a part of the store, I use the function below, and pass in the name of the query that should be deleted. The function will delete all instances of the query, regardless of parameters. I am using React, but in Vue it should basically be the same except the path to ROOT_QUERY might be slightly different.

import { withApollo } from 'react-apollo';
....
deleteStoreQuery = name => {
  try {
    // Apollo 1.x
    // let rootQuery = this.props.client.store.getState().apollo.data.ROOT_QUERY;
    let rootQuery = this.props.client.store.cache.data.data.ROOT_QUERY;
    Object.keys(rootQuery)
      .filter(query => query.indexOf(name) === 0)
      .forEach(query => {
        delete rootQuery[query];
      });
  } catch (error) {
    console.error(`deleteStoreQuery: ${error}`);
  }
};

In React, 'withApollo' gets exported as part of the component. The Vue equivalent would be required in order for the store to be available.

AdamYee commented 6 years ago

Suggestion for an api name – client.resetQuery to invalidate (and delete?) cache. Similar to client.resetStore.

usage:

client.resetQuery({ query, variables })
herclogon commented 6 years ago

Suggestion for an api name – client.resetQuery to invalidate (and delete?) cache. Similar to client.resetStore.

usage:

client.resetQuery({ query, variables })

Help me please sort out, how to use resetQuery you suggest in case when three components should reset data to each other. For example: First component: query { blocks(is_visible: true) {name} }, second: query { blocks(has_data: true) {name} }, and third one: query { blocks {name} }, and I add block by mutation blockAdd.

Should every component know queries for each other to reset? The case also well described here: https://github.com/apollographql/apollo-feature-requests/issues/29

redbmk commented 6 years ago

Why dont we add a prop like

deleted: true

as you can see some of the mapped types

has generated true something like that

deleted: true would be ignored on all queries that are mapped to it

This feels like the best approach to me. Apollo should be able to detect that User:1 was returned in an another query and delete that cache. Or in the case of an array that contains User:1, it should be able to remove that item from the array, or at the very least mark that query as stale and tell it to refetch.

Manually keeping track of all queries that might be affected and all combinations of parameters is very tedious.

However, there's no real standard for that marking something as deleted afaik. Maybe there should be one, but in the meantime I think the following approach would work best.

An api that would allow for deletion by typename and id would be very helpful indeed. Refetching queries or updating only work for very simple use-cases.

store.delete('User',1)

If a standard is established later for marking an item as deleted from the server, then most the code will already be in place - the main difference being we wouldn't need to call store.delete anymore and it would all be automatic.

AdamYee commented 6 years ago

@herclogon in your example, since those three separate queries are independently cached, invalidating after you add a block would look something like this in the mutation's update:

client.resetQuery({query: isVisibleQuery});
client.resetQuery({query: hasDataQuery});
client.resetQuery({query: allBlocksQuery});

Perhaps there could also be a client.resetNamedQuery(queryName /* operationName */) that invalidates any query with that operationName, regardless of query params. But that's just for convenience.

herclogon commented 6 years ago

@AdamYee Going this way each mutation should know queries which must be re-fetched. When you have a lot of components it will increase code viscosity, cause you must add resetQuery to mutation on "every" component add. In case when each component has his own queries (as graphql recommend, if I understand documentation right) and mutations it looks very strange. Or do you have global (single) mutation list for whole application?

I assume that a mutation should be able to mark part of cache dirty using cache path or object type and id maybe. And each watchQuery which contains this dirty record should re-fetches a new data automatically. For now we are using reFetchObservableQueries method after some mutations, however this leads to over-fetching.

AdamYee commented 6 years ago

@herclogon You're correct. This is the challenge when managing cache! I'm more just trying to help guide the discussion for the potential API.

Or do you have global (single) mutation list for whole application?

I do realize and have experienced the increase in code viscosity (complexity?). In our codebase, we use a central mechanism that tracks cached queries which we care about and updates them after certain mutations. Shameless plug - my colleague (@haytko) was able to extract this pattern into a link, https://www.npmjs.com/package/apollo-link-watched-mutation. I think you'll find it solves that need to manage cache in one place.

Something like resetQuery would be useful in a number of situations, but is by no means the end all solution.

jpikora commented 6 years ago

Maybe additionally, or instead of client.resetQuery(...), how about invalidateQueries key directly in mutation options, similarly to refetchQueries.

The params could be the same in both options (passing query names, or specific objects with query and variables), but the difference would be that by invalidating query it is just marked as dirty, meaning that only once the query is reused it would be force fetched. It probably should respect fetchPolicy, e.g. cache-only won't refetch even if invalidated, but otherwise there would be a server hit. If the query is being watched, it is reused immediately, thus refetched with current variables right away. By refetching invalidated query, it would be marked as clean once again and additional query calls would be resolved normally according to fetchPolicy.

I believe we would be able to happily resolve queries with filters from cache and after mutation invalidate all relevant queries at once, e.g. by query name regardless of variables. Of course it would require to know which queries to invalidate after certain mutation, but if we don't have live queries, we always have to know this on client side.

herclogon commented 6 years ago

@jpikora As for me it will be useful to have not invalidateQueries, but invalidateCache in directly mutation options, to make part of cache dirty using not query or query name, but cache path or cache object id. For now in my app I use watchQuery for each visible component, declaration query of each component in mutation looks not so cool.

joshjg commented 6 years ago

If anyone is looking for a workaround in the meantime, good news, there is one. Just pass an update function like this to your mutation.

const update = (proxy) => proxy.data.delete('AnyCacheKey');

And if you don't know the exact key, but want to match against a regex you could do something like this:

const update = (proxy) => Object.keys(proxy.data.data).forEach((key) => {
    if (key.match(/someregex/) {
        proxy.data.delete(key);
    }
});
beeplin commented 6 years ago

@joshjg It works according to the apollo devTools/cache panel~!

Is there any downside effect when deleting from proxy.data directly in such a way?

martinseanhunt commented 6 years ago

@joshjg Thanks so much for this, it's far simpler than the workaround that I had implemented! One thing I noticed is that if the mutation you're using this on changes data that's being displayed on the current page without a re-route after the mutation completes then the Query does not seem to get updated. You need to manually call refetch to get it to grab the up to date information from the server. This is at least the case in the current version of react-apollo that I'm using.

If anyone is interested I wrote up an implementation example of the above idea for dealing with paginated queries here https://medium.com/@martinseanhunt/how-to-invalidate-cached-data-in-apollo-and-handle-updating-paginated-queries-379e4b9e4698

Tam commented 6 years ago

@joshjg I'm using proxy.data.delete('Person:ecb8fe44-c7ba-11e8-97ac-b7982643657d') and can confirm it works by comparing proxy.data.data before and after. However, all queries, whether they reference the deleted item or not, return undefined immediately after the delete is run and remain like that until the query is run again. Is there a way to fix this?

Baloche commented 6 years ago

@Tam I had the same issue. I figured it was because the delete method actually removes the entry related to the provided key from the cache but does not removes the references of this entry. Here is what I finally come up with :


import isArray from 'lodash/isArray'
import isPlainObject from 'lodash/isPlainObject'
import { InMemoryCache } from 'apollo-cache-inmemory'

/**
 * Recursively delete all properties matching with the given predicate function in the given value
 * @param {Object} value
 * @param {Function} predicate
 * @return the number of deleted properties or indexes
 */
function deepDeleteAll(value, predicate) {
  let count = 0
  if (isArray(value)) {
    value.forEach((item, index) => {
      if (predicate(item)) {
        value.splice(index, 1)
        count++
      } else {
        count += deepDeleteAll(item, predicate)
      }
    })
  } else if (isPlainObject(value)) {
    Object.keys(value).forEach(key => {
      if (predicate(value[key])) {
        delete value[key]
        count++
      } else {
        count += deepDeleteAll(value[key], predicate)
      }
    })
  }
  return count
}

/**
 * Improve InMemoryCache prototype with a function deleting an entry and all its
 * references in cache.
 */
InMemoryCache.prototype.delete = function(entry) {
  // get entry id
  const id = this.config.dataIdFromObject(entry)

  // delete all entry references from cache
  deepDeleteAll(this.data.data, ref => ref && (ref.type === 'id' && ref.id === id))

  // delete entry from cache (and trigger UI refresh)
  this.data.delete(id)
}

In this manner, delete can be used within the update function :

const update = cache => cache.delete(entry)
infogulch commented 5 years ago

In many libraries, it's not uncommon for destructive actions on collections to return the destroyed items. What I'd love to see is an optional extension to queries & mutations that's analogous, where your mutation resolver can return an object field that indicates deleted status. Something like:

mutation DeleteEvent($id: ID) {
  deleteEvent(eventId: $id) {
    id
    __typename
    __deleted    # hypothetical optional built-in boolean field: true indicates Event with this id no longer exists
  }
}

Then any cache can watch query and mutation results just like normal and when it encounters an object with __deleted = true it knows to remove it from the cache. This would be a very natural, granular, flexible, and even automatic.

I could see this fitting in nicely when deleting:

I don't know if the actual mechanism can look like what I showed above without some really big changes to the whole ecosystem, but maybe there's another mechanism to do this.

developdeez commented 5 years ago

withApollo

@Christilut - to delete/invalidate a part of the store, I use the function below, and pass in the name of the query that should be deleted. The function will delete all instances of the query, regardless of parameters. I am using React, but in Vue it should basically be the same except the path to ROOT_QUERY might be slightly different.

import { withApollo } from 'react-apollo';
....
deleteStoreQuery = name => {
  try {
    // Apollo 1.x
    // let rootQuery = this.props.client.store.getState().apollo.data.ROOT_QUERY;
    let rootQuery = this.props.client.store.cache.data.data.ROOT_QUERY;
    Object.keys(rootQuery)
      .filter(query => query.indexOf(name) === 0)
      .forEach(query => {
        delete rootQuery[query];
      });
  } catch (error) {
    console.error(`deleteStoreQuery: ${error}`);
  }
};

In React, 'withApollo' gets exported as part of the component. The Vue equivalent would be required in order for the store to be available.

Doesn't seem to work. I see the query is removed from the list but even calling refetch after has no change to UI.

chris-guidry commented 5 years ago

@developdeez at the end of the component do you have something along these lines?

export default compose(
  withRouter,
  withApollo,
)(MyComponent);
developdeez commented 5 years ago

@chris-guidry Yep.

Parent: export default withAuth(session => session && session.currentuser)( withRouter(withLocation(ProfileSearch)) );

Child (search panel): export default SearchCriteriaPanel;

chris-guidry commented 5 years ago

@developdeez withApollo needs to be included in the export.

developdeez commented 5 years ago

@chris-guidry Same result :/

LennardWesterveld commented 5 years ago

I have also the need for cache invalidation in a other way with caching tags, if there is developer wo want to spend payed time on it and come out with a solution you can pm me and please read this slack message on the community. https://apollographql.slack.com/archives/C10HTKHPC/p1544566073213400

Hi all GraphQL fans, I’m having some technical difficulties with the GraphQL Caching part in combination with server side rendering for a react app. What I have is a GraphQL API server (Drupal) and a SSR react frontend.

The difficulties what I have now are

  1. Cache invalidation’ I have on my GraphQL server caching tags and I created for that a field that output the following query: cache { tags } output: { cache: { tags: ['setting:contact', 'media:1', 'node:1'] } } Now I want to have cache invalidation in the InMemoryCache but that’s not available right now. I see the solution as how dataIdFromObject works that you can set caching tags for a whole query or just parts of the query where the cache field is available.
  2. Persistent cache When using SSR now the examples that are on the Apollo website are recreating the InMemoryCache on every SSR request. I think this is done because other wise the initial state to the client (browser) are too large because the cache gets bigger and bigger. I would like to keep the cache and only send the used cache for that page to the client (browser). In this way you can create a extra caching layer with option 1 (Cache invalidation) so that you have a faster SSR rendering because you don’t need to do a api call to the GraphQL Server.

I’m wondering if other people already solved those issues and how? Or someone who can help me to solve those problems I’m willing to pay and I like that the outcome also get shared in the community so that other people can use those solutions.

daryn-k commented 5 years ago

I found a solution for myself. It's a workaround, but works anyway. I'm using the cache as key-value storage without any additional complexity. It's a solution to invalidate query in ROOT_QUERY (not Entity:id)

The idea is the next:

1) Use withApollo from 'react-apollo', which gives a instance of apollo client to the props 2) Make writeQuery: client.writeQuery({ query, data }), where a) query is a query you want to remove (invalidate), select only__typename b) data is a data with __typename: null

It should be something like this:

      const what = 'post'
      const id = 25
      const string = `query { ${what}(id: ${id}) { __typename } }`
      const query = gql`${string}`
      const data = { [what]: { __typename: null } }

      client.writeQuery({ query, data })

After some manipulations I've created HOC which invalidates a specific query after ComponentWIllUnmount

My custom HOC withApollo:

// @flow
import React, { Component } from 'react'
import { withApollo } from 'react-apollo'
import { gql } from 'apollo-boost'

type Props = {
  client: any
}

type Params = {
  options: Function
}

export default ({ options }: Params) => (WrappedComponent: any) => {
  class Enhancer extends Component<Props> {
    componentWillUnmount() {
      const { variables: { what, id } } = options(this.props)
      const string = `query { ${what}(id: ${id}) { __typename } }`
      const query = gql`${string}`
      const data = { [what]: { __typename: null } }
      const { client } = this.props

      client.writeQuery({ query, data })
    }

    render() {
      return <WrappedComponent {...this.props}/>
    }
  }

  return withApollo(Enhancer)
}

And React component, enhanced by custom withApollo HOC:

// @flow
import React from 'react'
import { gql } from 'apollo-boost'
import { graphql, compose } from 'react-apollo'
import NewsPost from 'App/components/news/NewsPost'
import withApollo from 'App/enhancers/withApollo'

type Props = {
  match: { params: { id: number } },
  data: any
}

const GET_POST = gql`
  query ($id: Int!) {
    post (id: $id) {
      id
      title
      date
      content
      views
      comments
    }
  }
`

const Post = (props: Props) => {
  const { data: { loading, post } } = props

  if (loading) return <div>Loading</div>
  if (!post) return <div>404</div>

  return (
    <div>
      <NewsPost post={post}/>
    </div>
  )
}

export default compose(
  graphql(GET_POST, {
    options: (props) => ({
      variables: {
        id: parseInt(props.match.params.id, 10)
      }
    })
  }),
  withApollo({
    options: (props) => ({
      variables: {
        what: 'post',
        id: parseInt(props.match.params.id, 10)
      }
    })
  })
)(Post)

Please, don't care about flow-types, it's temporary.

Ok, let's take a look at the cache:

Before:

ROOT_QUERY
    post({"id":37}): Post
        ▾Post:37

After component WillUnmount:

ROOT_QUERY
     post({"id":37}): // <- it's empty and ready to be updated again!

But it's a workaround. I believe someday they make the real cache invalidation.

Pruxis commented 5 years ago

Are there any updates on this, it would be nice to have a list of actual requirements instead of having a list of hacks.

daryn-k commented 5 years ago

After many weeks finally I can say that Apollo is incompatible with server-side rendering because of no cache invalidation in Apollo. Any workarounds are bad. My congrats! Please don't use my example above, because you will get a memory leak 😕

benjamn commented 5 years ago

@daryn-k You should be creating a new ApolloClient instance with a new cache for each SSR request (on the server). Otherwise you're potentially reusing data from previous requests made by different users. If you're properly discarding the caches from previous requests, I don't understand why cache invalidation would be a problem for SSR. Do you mean that cache invalidation on the client somehow does not work well with SSR?

daryn-k commented 5 years ago

@benjamn I've got what do you mean. Yes, I'm creating a new ApolloClient instance for every new request to the server. But it's not problem. The real problem is next:

  1. I go to mysite.com/post/25. Server makes a request to the GraphQL server and pass the data to the client in html-code. Right?
  2. Client gets this data from html and restores it like that: new InMemoryCache().restore(window.__APOLLO_STATE__).
  3. The data cached!
  4. After that I go to an another page and return to mysite.com/post/25. Client shows me the cached data - with old comments, number of views etc... What if the data is updated on server? I need make a request every time to avoid that.
  5. Well, I can do that with fetchPolicy: 'network-only'!
  6. But then I get a twice fetching when I go to this page first time: on server and on client.

How to do that on the client-side: don't make a request on first openning the page and make request in second, third openning the page? Right. I need to invalidate the cache in componentWillUnmount(). But, as we know, there is no cache invalidation in Apollo. I just can destroy entire cache. but it's not a solution. On the other hand, without SSR everything looks good.

Thanks for your attention. I hope for your help.

daryn-k commented 5 years ago

Ok, guys. I would like to say I like Apollo and its declarative style, which allows you to write 10x less and cleaner code. It's really super cool. But cache invalidation was a problem so far. I found a solution for myself. If you want to invalidate something, use client.clearStore(). This function destoys whole your cache as you know. To keep some data persistently use old good Redux.

I hope you figured out already that to keep absolutely all your data in Redux store is not a good solution. But you can use Redux still to keep some very small and compact data of your whole app, for example, user data. Obviously, client.clearStore() doesn't affect your Redux store. If so, cache invalidation works as we wanted.

I would recommend include Redux data with HOC enhancer. Example:

export default compose(
  withPost, // <- Apollo's graphql enhancer
  ...
  withApollo,  // <- Apollo's client
  withUser //  // <- This one enhances your component with Redux data
)(Post)

My graphql enhancer:

const withPost = graphql(GET_POST, {
  options: ({ match }) => ({
    variables: {
      id: parseInt(match.params.id, 10)
    }
  })
})

My Redux enhancer:

// @flow
// Please don't care about flow types any
import React, { Component } from 'react'
import { connect } from 'react-redux'

type Props = any

const withUser = (WrappedComponent) => {
  class Enhancer extends Component<Props> {
    render() {
      return (
        <WrappedComponent
          {...this.props.ownProps}
          user={this.props.user}
        />
      )
    }
  }

  return connect(
    (state, ownProps) => ({
      ownProps,
      user: state.user
    })
  )(Enhancer)
}

export default withUser

After that you'll get { data, client, user } = this.props in your component. Destroy freely your graphql data with client.clearStore() in your componentWillUnmount(), because you're keeping the persistent data in your small Redux store. I hope you got my general idea.

DPflasterer commented 5 years ago

Has there been any progress on this issue/feature request? Similar to what I've seen in bug reports but I have not seen mentioned in this feature request, I was hoping for a cache-policy that refreshes queries after a configurable amount of time. For example query is run and cached for two minutes then if called again after 2 minutes it invalidates the cache and fetches the query.

Manually calling a clearQuery method would be a great addition and I want that, but I think a "timeout" cache-policy would be the easiest implementation and cover most use cases.

timhwang21 commented 5 years ago

Is there any need for open source contributors on this issue? To me this is one of the biggest obstacles for effective usage of Apollo client. Would love to help if it will help solve the issue.

pmrt commented 5 years ago
  1. But then I get a twice fetching when I go to this page first time: on server and on client.

@daryn-k Have you tried passing in ssrMode: true as options when creating the ApolloClient?

https://www.apollographql.com/docs/react/features/server-side-rendering

It's working just fine for me with nextjs, no duplicated queries at all.

Regarding the issue, any news? I wish I could invalidate only my post list every 30 minutes or the comments every 5 minutes or something like that, without any hacks. It's not that hard to implement digging the cache object but I really think an official way is a must-have.

daryn-k commented 5 years ago

@pmrt, Hi! I'm just using Redux for a persistent data. And now everything works good. What about cache invalidation in Apollo? I clear all data and don't care about that anymore.

pachuka commented 5 years ago

@DPflasterer, I was hoping for a cache-policy that refreshes queries after a configurable amount of time. For example query is run and cached for two minutes then if called again after 2 minutes it invalidates the cache and fetches the query.

@pmrt, I wish I could invalidate only my post list every 30 minutes or the comments every 5 minutes or something like that, without any hacks. It's not that hard to implement digging the cache object but I really think an official way is a must-have.

Not sure if I'm misunderstanding your requirements, but this seems straightforward if your post list and comments are individual queries you can use pollingInterval which is in milliseconds(ex: 30 mins = 30 * 60000 ms). More info: https://www.apollographql.com/docs/react/essentials/queries#refetching

const DogPhoto = ({ breed }) => (
  <Query
    query={GET_DOG_PHOTO}
    variables={{ breed }}
    skip={!breed}
    pollInterval={500}
  >
    {({ loading, error, data, startPolling, stopPolling }) => {
      if (loading) return null;
      if (error) return `Error!: ${error}`;

      return (
        <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
      );
    }}
  </Query>
);

Now I can see a need to manually invalidate portions of the Apollo cache, but this would only be necessary when you want to update data via Apollo Client based on events external to the Apollo/GraphQL interaction such as when you receive websocket messages in a multi-client scenario.

timhwang21 commented 5 years ago

@pachuka that wouldn't work, as every poll will hit the cache. What the posters above want is for all calls (polled or not) to have a cache-first fetch policy for n minutes, and after those n minutes, switch to network first until called, and back to cache-first once called.

pmrt commented 5 years ago

@pachuka Yeah, as @timhwang21 points out: polling will poll every X ms but that's not what we want, that would be useful eg. if you were building a trading website to refresh the stock price every X ms. That's a different thing.

To put it another way — what you would want is just to invalidate the cache for a given query after an expiration time, eg. 10 min. After 10 min the cache is invalidated so if the user keeps browsing the website (SPA) and he goes back to the page again a new query is requested.

It's not just fetching a given query again every X ms, it would only fetch the expired queries if the user goes back to the page again. Also, switching to 'cache-and-network' after X time, fetching the query and then switching back to 'cache-first' policy rather than invalidating the cache for a given query would give the impression of even "faster" UI for the user, but you get the point!

ClementParis016 commented 5 years ago

This is indeed a pretty common need for any app and it's annoying to have to resort to hacks to work around it.

My current "solution" is a combination of writeFragment/readFragment to add a stale property to the object in the cache after mutation and then force a fetchPolicy: 'network-only' if it's stale when querying again (and set it back as not stale on success). But it still feels very hacky and manual though...

bl42 commented 5 years ago

I really think Apollo-Server should control how long something is valid.

The team even has made a pretty good api for this with apollo engine

https://github.com/apollographql/apollo-server/tree/master/packages/apollo-cache-control

type Post @cacheControl(maxAge: 240) {
  id: Int!
  title: String
  author: Author
  votes: Int @cacheControl(maxAge: 30)
}
const resolvers = {
  Query: {
    post: (_, { id }, _, { cacheControl }) => {
      cacheControl.setCacheHint({ maxAge: 60 });
      return find(posts, { id });
    }
  }
}

Then something from apollo-client

export default graphql(gql`query { ... }`, {
  options: { 
     fetchPolicy: 'cache-first' 
     fetchPolicyPastMaxAge: 'cache-and-network' 
},
})(MyComponent);
wtrocki commented 5 years ago

Are there any PR's for this work? I would love to help with that but I see so many proposals. Is there any approach that Apollo team will favor?

I think it is safe to introduce another cache strategy as it will not affect any existing app and provide technology differentiator for people who look into cache invalidation.

Additionally, the delete method will be really important to have. In most of the cases mutation that deletes stuff can remove an element from cache in update function and update queries but the fact that there is no delete function makes this hard. This is specifically important when apollo-cache-persist is used as production ready applications will very often need to clear the entire store with cache.

I think those will be separate concerns really so it will be better to split them into separate issues:

U-4-E-A commented 5 years ago

The workaround I am thinking about using for this right now is (using fetching books as an example): -

  1. Have an entry in the cache for books, author (and any other types I might need cache control over) as booksCacheTimestamps: {id: "whatever", lastFetched: "whenever"} etc.

  2. Have the original single server query use the @client directive so a client resolver "intercepts" the query then: -

    
    const STALE_MILLISECONDS  = 300000
    import { BOOK_CACHED } from "./queries"
    import { BOOK_FROM_SERVER } from "./serverQueries"

const bookTimestamp = booksCacheTimestamps.filter((item) => {item.id === requestedBookId})

If(bookTimestamp doesn't exist or bookTimestamp exists but (bookTimestamp.lastFetched < (Date.now - STALE_MILLISECONDS))){ client.query BOOK_FROM_SERVER } else { client.query BOOK_CACHED }

Then return the results of whichever query is run. BOOK_CACHED would just run what you would expect to be the original query (i.e. without the @client directive).


3. Update the relevant entry in the timestamps array in the cache after the "intercept" query runs.

If anyone can think of a better solution I would love to hear their input.
sasilver75 commented 5 years ago

If anyone is looking for a workaround in the meantime, good news, there is one. Just pass an update function like this to your mutation.

const update = (proxy) => proxy.data.delete('AnyCacheKey');

And if you don't know the exact key, but want to match against a regex you could do something like this:

const update = (proxy) => Object.keys(proxy.data.data).forEach((key) => {
    if (key.match(/someregex/) {
        proxy.data.delete(key);
    }
});

Can anyone expound on this explanation? I'm quite new to Apollo but already running into this problem -- I'll take as verbose an explanation as you'll give me :)

cnoter commented 5 years ago

I want to share my workaround.

I think the most simple way to handle apollo cache update after delete mutation to deal with the app logic is to change that '_typename:id' entity's certain field's value by getting the query data in mutation's update API.

For example, I have a 'post_page' cache-first query that is called when I am entering the post's page. When I call the post's delete mutation in that page, user is navigated to '/home' page. At the same time, in the mutation's 'update:' api I change that deleted post:id's certain field to 'null'(it can be any thing depends on your logic), this field can 'id' field or dedicated field like deleted_at in post type's graph schema or anything you want.

And then if your user try to enter that deleted post's page again. you can block that navigation and re-navigate to '/home' by checking that specific changed field in router-resolver(in angular term)

I use this method in my app logic. I works pretty well for me :)

U-4-E-A commented 5 years ago

The workaround I am thinking about using for this right now is (using fetching books as an example): -

  1. Have an entry in the cache for books, author (and any other types I might need cache control over) as booksCacheTimestamps: {id: "whatever", lastFetched: "whenever"} etc.
  2. Have the original single server query use the @client directive so a client resolver "intercepts" the query then: -
const STALE_MILLISECONDS  = 300000
import { BOOK_CACHED } from "./queries"
import { BOOK_FROM_SERVER } from "./serverQueries"

const bookTimestamp = booksCacheTimestamps.filter((item) => {item.id === requestedBookId})

If(bookTimestamp doesn't exist or bookTimestamp exists but (bookTimestamp.lastFetched < (Date.now - STALE_MILLISECONDS))){ client.query BOOK_FROM_SERVER } else { client.query BOOK_CACHED }

Then return the results of whichever query is run. BOOK_CACHED would just run what you would expect to be the original query (i.e. without the @client directive).
  1. Update the relevant entry in the timestamps array in the cache after the "intercept" query runs.

If anyone can think of a better solution I would love to hear their input.

OK, an easier solution to this. Posting it here in the hope others may benefit from it.

After a lot of thinking it through, I decided the simplest way for cache invalidation on server calls was to return to add a lastFetched field to my type: -

type Blah {
    id: ID!
    lastFetched: String!
}

When I return the data from the server: -

return {
    {...BlahData, lastFetched: Date.now()}
}

I am using a String type as Int won't handle timestamp length integers

I then use a wrapper component to control the caching...

return <BlahCacheQuery><BlahReliantComponent></BlahCacheQuery>

import React from "react"
import BLAH_QUERY from "./BLAH_QUERY"
import { Query } from "react-apollo"

// 300000 milliseconds (5 minutes)
const BLAH_QUERY_STALE_MAX = 300000

const BlahCacheQuery = (props) => {

  return (
    <Query query={ BLAH_QUERY } variables={{id: props.id}} fetchPolicy="cache-first">
      {({ loading: cacheLoading, error: cacheError, data: cacheData }) => {
        // grab initial cache-first data and render child component
        if (cacheLoading || cacheError) {
          return (React.Children.map(props.children, child => {
            return React.cloneElement(child, {
              loading: cacheLoading, error: cacheError, data: cacheData.blah})
          }))
        } else {
          // if data found && data is NOT stale, render child component
          if (cacheData && (cacheData.blah.lastFetched >= (Date.now() - BLAH_QUERY_STALE_MAX))) {
            return (React.Children.map(props.children, child => {
              return React.cloneElement(child, {
                loading: cacheLoading, error: cacheError, data: cacheData.blah})
            }))
          }
          // if data not returned or data returned but stale, requery with network-only then render child component
          return (<Query query={ BLAH_QUERY } variables={{id: props.id}} fetchPolicy="network-only">
              {({ loading: networkLoading, error: networkError, data: networkData }) => {
                return (React.Children.map(props.children, child => {
                  return React.cloneElement(child, {
                    loading: networkLoading, error: networkError, data: networkData.blah})
                }))
              }}
            </Query>
          )
        }
      }}
    </Query>
  )
}

export default BlahCacheQuery
blvdmitry commented 5 years ago

Don't know how reliable this workaround is but I currently use:

export const updateCache = (entityName) => {
  return (cache) => {
    Object.keys(cache.data.data).forEach((key) => {
      key.match(`^${entityName}$`) && cache.data.delete(key);
    });

    client.reFetchObservableQueries();
  };
};

// Usage
client.mutate({
    mutation: schema.FollowTopic,
    variables: { id },
    update: updateCache('User'),
});