Cache
Farfetched provides a way to cache
the result of the Query. Let's dive into internal details of this mechanism.
In the following article, some Effector APIs are used to describe application logic —
createStore
,createEffect
combine
,sample
.
Valid data guarantee
cache
operator provides a guarantee that the cached data won't be saved to Query unless it is valid for the current state of the Query. It means, you can safely deploy a new version of your application with a new expected shape of the data and the old cached data will be automatically invalidated.
Data-flow
Internal implementation of this guarantee is a pretty simple. Farfetched is based on Do not trust remote data principle, so it uses Contract and Validator to validate the data from the remote source.
TIP
Read more detailed description of data-flow in Data flow in Remote Operation article.
cache
operator pushes the data to the Query right after the response parsing stage of the data-flow. It means, same Contract and Validator are used to validate the cached data as any other data from the regular remote source. If the cached data is valid, it is saved to the Query. Otherwise, the cached data is ignored, and the Query is updated with the new data from the remote source.
INFO
User-land code can't access the cached data directly. It is only available through the Query object. So, invalid cached data is not exposed to the user.
To achieve this, Every Query exposes .__.lowLevelAPI.dataSources
which contains an array of data sources that are used to retrieve the data for the Query. By default, the first element of this array is always the original handler of the Query. We can mutate this array to add new data sources to the Query. cache
operator does exactly this, it adds a new data source to the array that is responsible for the cached data.
DANGER
.__.lowLevelAPI.dataSources
is a low-level API that is not recommended using it directly in user-land.
Cache key generation
cache
does not require any manual key generation to work, it uses the SID of the Query and all external Stores that affect Query to create a unique identifier for every cache entry. It means, key generation is fully automatic, and you don't need to worry about it.
Sources extraction
Due to static nature of Effector we can extract all external Stores that affect Query right after application loading and use their values in key generation process.
Every factory has to pass a list of [Sourced]sourced fields used in the Query creation process to field .__.lowLevelAPI.sourced
.
For example, the following Query uses $language
and $region
Stores to define the final value of the field url
:
const locationQuery = createJsonQuery({
request: {
url: {
source: combine({ language: $language, region: $region }),
fn: (_params, { language, region }) =>
region === 'us'
? `https://us-west.salo.com/${language}/location`
: `https://eu-cent.salo.com/${language}/location`,
},
},
});
Of course, we can just save both $language
and $region
to .__.lowLevelAPI.sourced
and use them in key generation process, but it is not the best solution. Final URL does not include the value of $region
directly, it cares only if it is "us"
or not, so we have to emphasize this fact in .__.lowLevelAPI.sourced
. To solve this issue, let's check internal implementation of Sourced fields.
INFO
Sourced fields are special fields in Farfetched that are allows to use any combination of Stores and functions to define the final value of the field.
Under the hood Farfetched uses special helper normalizeSourced
that transforms any Sourced field to simple Stores, in our case it would be something like this:
// internal function in Farfetched's sources
function normalizedSourced($store, start, transform) {
const $result = createStore(null);
sample({
clock: start,
source: $store,
fn: (store, params) => transform(params, store),
target: $result,
});
return $result;
}
// this transformation applied to the field `url` to get the final value
const $url = normalizedSourced(
combine({ language: $language, region: $region }),
query.start,
(_params, { language, region }) =>
region === 'us'
? `https://us-west.salo.com/${language}/location`
: `https://eu-cent.salo.com/${language}/location`
);
After that, we can use $url
in .__.lowLevelAPI.sources
, it will contain only related data and could be used as a part of cache entry key. Same transformation applies for every sourced field to extract only significant data.
DANGER
normalizeSourced
is a low-level API function that is used internally in Farfetched. It is not recommended using it directly in user-land.
Static nature of Effector allows us to perform this transformation under the hood and use only related data from to formulate cache entry key. It is an essential part of stable key generation that leads us to higher cache-hit rate.
SID
Every Query has a unique identifier — SID. Effector provides a couple of plugins for automatic SIDs generation.
Babel plugin
If your project already uses Babel, you do not have to install any additional packages, just modify your Babel config with the following plugin:
{
"plugins": ["effector/babel-plugin"]
}
INFO
Read more about effector/babel-plugin
configuration in the Effector's documentation.
SWC plugin
SWC is a blazing fast alternative to Babel. If you are using it, you can install @effector/swc-plugin
to get the same DX as with Babel.
pnpm install --dev @effector/swc-plugin @swc/core
yarn add --dev @effector/swc-plugin @swc/core
npm install --dev @effector/swc-plugin @swc/core
Now just modify your .swcrc
config to enable installed plugin:
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"experimental": {
"plugins": ["@effector/swc-plugin"]
}
}
}
INFO
Read more about @effector/swc-plugin
configuration in the plugin documentation.
Vite
If you are using Vite, please read the recipe about it.
Hashing algorithm
So, the key is a hash of the following data:
SID
of the Queryparams
of the particular call of the Query- current values of all external Stores that affect Query
To get short and unique key, we stringify all data, concatenate it and then hash it with SHA-1.
TIP
SHA-1 is a cryptographically broken, but we use it for key generation only, so it is safe to use it in this case.
Adapter replacement
Sometimes it's necessary to replace current cache adapter with a different one. E.g. it's impossible to use localStorage
on server-side during SSR, so you have to replace it with some in-memory adapter. To do this Farfetched provides a special property in every adapter .__.$instance
that can be replaced via Fork API.
Adapter internal structure
Fork API allows to replace any Store value in the particular Scope, so we have to provide some "magic" to adapters to make it Store-like.
In general, every adapter is a simple object with the following structure:
const someAdapter = {
get: createEffect(({ key }) => /* ... */),
set: createEffect(({ key, value }) => /* ... */),
purge: createEffect(() => /* ... */),
};
We have to add .__.$instance
property to it to make it replacable via Fork API:
// internal function in Farfetched's sources
function makeAdapterRepalcable(adapter) {
return {
...adapter,
__: {
$instance: createStore(adapter),
},
};
}
DANGER
makeAdapterRepalcable
is a low-level API function that is used internally in Farfetched. It is not recommended using it directly in user-land.
That's it, now we can replace any adapter with another one via Fork API:
// app.ts
import { localStorageCache } from '@farfetched/core';
// Create some adapter to use in the appliaction
const applicationCacheAdapter = localStorageCache();
cache(query, { adapter: applicationCacheAdapter });
// app.test.ts
import { inMemoryCache } from '@farfetched/core';
test('app', async () => {
const scope = fork({
values: [
// Replace its implementation during fork
[applicationCacheAdapter.__.$instance, inMemoryCache()],
],
});
});
Operator cache
does not use .get
, .set
and .purge
methods directly, it extracts them from the .__.$instance
on every hit instead. It allows users to replace adapters in specific environments (such as tests or SSR) without any changes in the application code.
Query interruption
cache
allows skipping Query execution if the result is already in the cache and does not exceed the staleAfter
time. It uses .__.lowLevelAPI.dataSources
as well.
To retrieve new data a Query iterates over all data sources and calls .get
method on them. If the result is not empty it stops iteration and returns the result. So, cache
operator just adds a new data source to the start of the list of data sources.