The Local Store
When we create our Artemis client we usually pass a :store
option. The store is a local cache of the results of GraphQL queries. Depending on the fetch policy we set, Artemis might try to use the cache to resolve the data being queried. Let’s look at what a store is.
Creating a Store
The GQLStore
protocol is an abstraction defining a local store that we can locally execute operations against. Each store implements -read
,-write
, -read-fragment
, and -write-fragment
functions, which are, respectively, called by artemis.core/read
, artemis.core/write
, artemis.core/read
, and artemis.core/write
. Our client will call each of these functions on the store at specific times, expecting that the store has correctly implemented them.
Reading and Writing
When we define our store, the -read
implementation will be called with the parsed GraphQL document, the variables map, and a boolean return-partial?
as arguments. Return partial is specified at query-time by the executing code and states whether or a partially fulfilled query (i.e. a query that might return values for only some fields instead of all) is accepted.
The -read
implementation should use these three arguments to attempt to fulfill a query (either a fully or partially) and return the result. If the store isn’t able to fulfill the query, it should return nil
.
The -write
implementation is responsible for taking some data and writing it to a local cache, then returning the updated store.
-read-fragment
and -write-fragment
work pretty much the same way, but also take an argument that is a reference to a particular node we want to update. What that reference looks like is up to the store’s implementation.
As long as you implement these four functions you can build any kind of store you’d like – DataScript, IndexedDB, Local Storage, whatever you want – Artemis will call those functions with the right information at the right times.
Check out the API docs for more on creating your own store.
Mapgraph Store
While you can build your own GQLStore
, it can potentially be complicated to implement, so Artemis comes with a default store built atop Mapgraph.
The default store presents a normalized, in-memory database of linked entities. All entities are flatly-stored based on a reference value; nested entities are replaced with a reference lookup.
We can specify the reference values for our entities by passing a :id-fn
when creating our store. For example, to use each entity’s :id
as the lookup we can do:
(create-store :id-fn (fn [entity] (:id entity)))
It’s important to note that lookups should be unique across entities, so make sure you’re id-fn
returns a value that is unique to the entity it’s passed. One approach is to use UIDs. It’s also common to prefix the ID with the entities __typename
value. Here’s an example of the second approach:
(create-store :id-fn (fn [entity] (str (:__typename entity) (:id entity))))
The default :id-fn
is :id
, so if the ID value is unique across all of your entities, you may not even need to specify an :id-fn
.
Automatic Cache Updates
Because our entities are normalized, we often times get correct cache updates for free after we execute queries and mutations. Let’s say we perform the following query:
{
post(id: 1) {
id
score
}
}
Then we execute a mutation:
mutation {
upvotePost(id: 1) {
id
score
}
}
The ID value on both results matches up, so the score field will automatically be updated across our entire UI.
Cache Redirects
In some cases, a query requests data that already exists in the store under a different key. A very common example of this is when your UI has a list view and a detail view that both use the same data. The list view might run the following query:
{
books {
id
title
abstract
}
}
When a specific book is selected, the detail view displays an individual item using this query:
{
book(id: $id) {
id
title
abstract
}
}
We know that the data is already in the client cache, but because it’s been requested as part of a different query, the store doesn’t know that. In order to tell the store where to look for the data, we can point it in the right direction using cache redirects.
When creating our store we can supply a map of redirects via the :cache-redirects
option. Each key in the map is a field name that we want to redirect whenever the store can’t resolve a result, and the value to the key is a function that returns a the reference we want to be redirected to. The function will be called with a map that contains the following:
{:store <the client's store>
:parent-entity <the parent of entity for the field we're on>
:variables <the map of GraphQL variables>}
Assuming that our store is normalizing on the :id
field, our book entity would be stored by ID. With that said, let’s take our example above and implement a cache redirect for the book
node:
(def cache-redirects
{:book (fn [{:keys [variables]}]
(:id variables))})
(create-store :cache-redirects cache-redirects)
What we’ve done above is tell our store that if we’re not able to resolve the book
field on any query, run the redirect function specified (which returns the :id
variables we’re querying with) and try to query the selection set for book from that point in the cache.
Partial Returns
Sometimes it’s useful to display partially available data while waiting for the remaining data to be loaded. For example, you might query for a list of books using:
{
books {
id
title
}
}
If the user clicks on a specific book, you want to get more information for that book:
{
book(id: $id) {
id
title
abstract
author {
id
firstName
lastName
}
}
}
Depending on how your UI is designed, it might be ok to start rendering the title of the book (since it’s already been cached) while the remaining information is being fetched.
By default, our store will only return data for a query that it’s able to fulfill entirely. You can, however, pass a :return-partial? true
option when reading data:
(a/query! client book-doc {:id 1} :return-partial? true)
You can also pass :return-partial?
when using read
and read-fragment
:
(a/read store book-doc {:id 1} :return-partial? true)
(a/read-fragment store book-fragment-doc 1 :return-partial? true)
Clearing the Store
In some cases you may want to clear your Mapgraph store. You can easily do this by calling artemis.stores.mapgraph.core/clear
. The clear
function will clear out all cached entities.
Serializing the Cache
The entities stored in the Mapgraph cache are just regular Clojure data, so we can easily serialize it by calling pr-str
.
(pr-str (:entities (a/store client)))
If we wanted to store our entities to localStorage everytime our store changes, for example, we can use pr-str
in combination with the watch-store
function:
(a/watch-store client
(fn [old-store new-store]
(.setItem js/localStorage
"app-entities"
(pr-str (:entities new-store)))))
Then we could hydrate our cache by passing in our entities at bootstrap:
(create-store :entities (or (.getItem js/localStorage "app-entities") {}))
If you’re going to persist the cache on every update, it would be wise to debounce your function. The Google Closure library that comes within ClojureScript provides a nice option via the goog.async.Debouncer
class.