Source

flux/src/flux-cache/flux-cache.js

//#region Imports

import cloneDeep from 'lodash/cloneDeep';
import { emit, onEmit } from '@psionic/emit';
import { FluxManager } from '../flux-manager/flux-manager';

//#endregion

//#region Protected Classes

/**
 * Class representing a Flux cache. A Flux cache has a defined `fetch` function which can be used to
 * fetch the data asynchronously. After the data has been fetched, it will be cached. The next time the
 * data needs to be fetched, it will be taken from the cache, unless it was marked as stale.
 *
 * New `FluxCache`s should be created with the `createFluxCache` function -- NOT with a `new FluxCache()`
 * constructor. Otherwise, the Flux object will not be instantiated in the general FluxManager singleton, and
 * functionality may break.
 *
 * @public
 * @memberof module:@psionic/flux
 * @alias module:@psionic/flux.FluxCache
 */
class FluxCache {

    //#region Public Variables

    //#endregion

    //#region Private Variables

    /**
     * Tracks whether the cache is currently stale or not.
     * @private
     * @type {boolean}
     */
    #stale = true;

    /**
     * The data to track in the Flux cache.
     * @private
     * @type {*}
     */
    #data;

    /**
     * The ID of the Flux object.
     * @private
     * @type {string}
     */
    #id;

    /**
     * The function to call to fetch the data from outside the cache.
     * @private
     * @type {function}
     */
    #fetch;

    /**
     * The promise tracking the currently running "fetch" promise.
     * @private
     * @type {Promise<*>}
     */
    #fetchPromise;

    /**
     * The number of milliseconds after which the data in the cache should be marked as stale.
     * @private
     * @type {Number}
     */
    #staleAfter;

    /**
     * Flag indicating whether or not the cache should automatically fetch new data when the cache gets marked as stale.
     * @private
     * @type {boolean}
     */
    #autofetchOnStale;

    /**
     * The function to call to cancel the active stale setter timer.
     * @private
     * @type {function}
     */
    #cancelStaleSetter;

    //#endregion

    //#region Constructor

    /**
     * @constructor
     * @private
     *
     * @param {Object} config The configuration object
     * @param {string} config.id The ID to use for the FluxCache; should be unique among all other active Flux objects
     * @param {function} config.fetch The function to call to asynchronously fetch the data to store in the cache, if non-stale
     * data does not already exist in the cache
     * @param {Number} [config.staleAfter] The amount of time to wait before declaring the data in the cache as stale; if this value is
     * not passed, then the cache will not be marked stale in response to the age of the data in the cache
     * @param {boolean} [config.autofetchOnStale] Whether or not to automatically fetch new data when the cache gets marked as stale
     */
    constructor({
        id,
        fetch,
        staleAfter,
        autofetchOnStale,
    }) {
        this.#id = id;
        this.#autofetchOnStale = autofetchOnStale;
        // We want to make sure this fetch function is async so we can treat all potential fetch operations identically
        this.#fetch = async () => {
            return fetch();
        };
        this.#staleAfter = staleAfter;
    }

    //#endregion

    //#region Public Functions

    /**
     * Retrieves the appropriate data from the cache, if it is available and non-stale, or externally via
     * the Flux cache's `fetch` function if the data is not available or is stale in the cache.
     *
     * If the cache is re-marked as stale while the Flux cache is running a `fetch` operation, the existing fetch operation
     * will be restarted from scratch in order to ensure the most up-to-date data is used. See example 2.
     * @public
     *
     * @example
     * // Create a FluxCache object
     * const profileCache = createFluxCache({
     *      id: 'profileCache',
     *      fetch: async () => {
     *          await delay(5000);
     *          return { name: 'John' };
     *      }
     * });
     *
     * // Read the value from the FluxCache for the first time (runs the fetch function passed to the cache; promise should take ~5s)
     * const profile = await profileCache.get(); // { name: 'John' }
     *
     * // Read the value from the FluxCache for the second time (retrieves the stored data from the cache; promise should only take a few milliseconds)
     * const cachedProfile = await profileCache.get();
     *
     * @example
     * // Create a FluxState object
     * const userIDState = createFluxState({
     *      id: 'userIDState',
     *      value: 'John',
     * });
     *
     * // Create a FluxCache object that relies on the userIDState Flux object
     * const userCache = createFluxCache({
     *      id: 'userCache',
     *      fetch: async () => {
     *          const userID = userIDState.get();
     *          // Mock an asynchronous data fetching delay
     *          await delay(delayTime)
     *          return { name: userID };
     *      },
     *      dependsOn: [userIDState],
     * });
     *
     * // Initiate a `get` request for the cache
     * const getPromise = userCache.get();
     *
     * // Before the `get` request resolves, change the userIDState value; this will cause the above `get` operation to restart w/ the new data
     * userIDState.set('Roni');
     *
     * // Wait for the `getPromise` to resolve and retrieve the result
     * const result = await getPromise; // { name: 'Roni' } since the userIDState changed before the `get` operation resolved
     *
     * @returns {Promise<*>} Resolves with the data from either the cache or from the external fetch function
     */
    async get() {
        // If there is non-stale data in the cache, clone the data and return the result
        if (this.#data && !this.#stale) {
            return cloneDeep(this.#data);
        }
        // If there is a fetch operation already in progress, simply return that promise
        else if (this.#fetchPromise) {
            return this.#fetchPromise;
        }
        // Otherwise, initiate a new external fetch operation, and store the results in the cache
        else {
            // The data could become marked as stale again while a fetch operation is occurring. For this reason, we want to keep trying the
            // fetch operation until it either resolves, or it rejects w/o the "Stale Data" error.
            this.#fetchPromise = (async () => {

                // Function that creates a promise race between the fetch operation and a stale data listener
                const fetchVsStaleDataRace = async () => {
                    let listener;

                    // Result should hold the result of a promise race between the fetch operation and the stale data listener
                    const result = await Promise.race([
                        this.#fetch(),
                        new Promise((_, reject) => {
                            listener = onEmit(`_FLUX_${this.#id}-markedStale`, () => {
                                reject('_FLUX_ Stale Data');
                            });
                        }),
                    ]);

                    // Cancel the stale data listener once the promise race resolves, regardless of who won
                    listener?.cancel?.();

                    // Return the result
                    return result;
                }

                // Keep attempting the fetch vs stale data promise race until the fetch operation is the one to win (in resolution or rejection)
                while (true) {
                    try {
                        return await fetchVsStaleDataRace();
                    } catch (err) {
                        // Throw the error if the `fetch` function itself was what failed
                        if (err !== '_FLUX_ Stale Data') {
                            throw err;
                        }
                    }
                }
            })();

            // Wait for the fetch promise to resolve
            const result = await this.#fetchPromise;

            // Clear out the fetch promise
            this.#fetchPromise = null;

            // Emit the updated event
            this.#emitUpdatedEvent();

            // Cache the result of the fetch promise
            this.#cacheData(result);

            // Return the result of the fetch promise
            return result;
        }
    }

    /**
     * Getter for the `stale` flag on the cache.
     * @public
     *
     * @example
     * // Create a FluxCache object
     * const profileCache = createFluxCache({
     *      id: 'profileCache',
     *      fetch: async () => {
     *          return { name: 'John' };
     *      },
     *      staleAfter: 5000, // Data will be marked as stale 5s after it is cached
     * });
     *
     * // The cache is always stale when first initialized
     * let isStale = profileCache.getStale(); // true
     *
     * // Fetch the profile to remove the stale state
     * profileCache.get();
     *
     * // The cache will no longer be stale, since the profile was just fetched
     * isStale = profileCache.getStale(); // false
     *
     * // If we wait 5 seconds, the data will become stale since we set `staleAfter` to `5000`
     * await delay(5000);
     * isStale = profileCache.getStale(); // true
     *
     * @returns {boolean} The flag indicating whether the data in the cache is currently stale or not
     */
    getStale() {
        return this.#stale;
    }

    /**
     * Get a flag indicating whether the cache is currently loading data or not.
     * @public
     *
     * @example
     * // Create a FluxCache object
     * const profileCache = createFluxCache({
     *      id: 'profileCache',
     *      fetch: async () => {
     *          // Add an artifical 500ms delay before the data loads
     *          await delay(500);
     *          return { name: 'John' };
     *      },
     * });
     *
     * // The cache is not loading data in the beginning
     * let isStale = profileCache.getIsLoading(); // false
     *
     * // Initiate the load of the data
     * profileCache.get();
     *
     * // The cache is now loading data
     * isStale = profileCache.getIsLoading(); // true
     *
     * // Wait for 500ms for the data to fully load
     * await delay(500);
     *
     * // The cache is no longer loading data
     * isStale = profileCache.getIsLoading(); // false
     *
     * @returns {boolean} Flag indicating whether the cache is actively loading data or not
     */
    getIsLoading() {
        return Boolean(this.#fetchPromise);
    }

    /**
     * Updates the function this cache uses to externally fetch the data it stores. Calling this function will automatically
     * mark the cache as stale.
     * @public
     *
     * @example
     * // Create a FluxCache object
     * const profileCache = createFluxCache({
     *      id: 'profileCache',
     *      fetch: async () => {
     *          return { name: 'John' };
     *      }
     * });
     *
     * // Fetch the initial profile; the cache will not be marked as stale anymore
     * profileCache.get();
     *
     * // Update the fetch function to retrieve a different user's profile; the cache will immediately be marked as stale
     * profileCache.updateFetch(async () => {
     *      return { name: 'Roni' };
     * });
     *
     * @param {function} fetch The new function to call to fetch data to store in this cache
     */
    updateFetch(fetch) {
        // We want to make sure this fetch function is async so we can treat all potential fetch operations identically
        this.#fetch = async () => {
            return fetch();
        }
        this.#markStale();
    }

    /**
     * Manually sets the stale state of the FluxCache. If setting the stale state to `false`, and the `staleAfter` value is set for the FluxCache,
     * the stale timer will start immediately after setting the stale state to `false`.
     * @public
     *
     * @example
     * // Create a FluxCache object
     * const profileCache = createFluxCache({
     *      id: 'profileCache',
     *      fetch: async () => {
     *          return { name: 'John' };
     *      }
     * });
     *
     * // Set the stale state of the cache to `false`, manually
     * profileCache.setStale(false);
     *
     * // Set the stale state of the cache to `true`, manually
     * profileCache.setStale(true);
     *
     * @param {boolean} isStale Flag indicating whether the cache is stale or not
     */
    setStale(isStale) {
        isStale ? this.#markStale() : this.#unmarkStale();
    }

    /**
     * Manually clears the data from the cache. Usually not needed unless a large amount of data is being stored in the cache and you wish to free
     * up memory usage. Calling this function will automatically mark the cache as stale.
     * @public
     *
     * @example
     * // Create a FluxCache object
     * const profileCache = createFluxCache({
     *      id: 'profileCache',
     *      fetch: async () => getLargeAmountOfData(),
     * });
     *
     * // Fetch the data for the cache
     * await profileCache.get();
     *
     * // If the data is no longer needed, it can be manually cleared from the cache to free up memory
     * profileCache.clear();
     */
    clear() {
        this.#data = undefined;
        this.#markStale();
    }

    /**
     * Returns whatever data is currently stored in the cache, regardless of whether that data is stale or not. To note, unless manually cleared,
     * the data from the last successful `get` call will remain stored in the cache until another `get` call resolves and updates the cache. This
     * means it is possible to use this function while another `get` operation is running to retrieve the results from the last successfuly `get`
     * operation.
     * @public
     *
     * @example
     * // Create a FluxState object
     * const userIDState = createFluxState({
     *      id: 'userIDState',
     *      value: 'John',
     * });
     *
     * // Create a FluxCache object
     * const profileCache = createFluxCache({
     *      id: 'profileCache',
     *      fetch: async () => {
     *          // Mock a 5s delay before the data is resolved and stored in the cache
     *          await delay(5000);
     *          const userID = await userIDState.get();
     *          return { name: userID };
     *      },
     * });
     *
     * // Fetch the first reading and store it in the cache
     * await profileCache.get(); // { name: 'John' }
     *
     * // Update the `userIDState` to trigger a stale profile cache
     * userIDState.set('Roni');
     *
     * // Start a new `get` operation to store the new data in the cache (but don't `await` the result)
     * profileCache.get(); // Will resolve to { name: 'Roni' } after 5 seconds
     *
     * // Retrieve the data currently in the cache before the new data resolves and updates the cache
     * profileCache.getCachedData(); // { name: 'John' }
     *
     * @returns {*} The data currently stored in the cache, whether it is stale or not
     */
    getCachedData() {
        return cloneDeep(this.#data);
    }

    /**
     * Get the Flux object's ID.
     * @public
     *
     * @return {string} The Flux object's ID
     */
    getID() {
        return this.#id;
    }

    //#endregion

    //#region Private Functions

    /**
     * Emits the "flux object updated" event.
     * @private
     */
    #emitUpdatedEvent() {
        emit(`_FLUX_${this.#id}-updated`);
    }

    /**
     * Caches the given data, and handles any stale timer logic, if needed.
     * @private
     *
     * @param {*} data The data to store in the cache
     */
    #cacheData(data) {
        this.#data = data;
        this.#unmarkStale();
    }

    /**
     * Marks the cache as stale, while canceling any stale timer that may have otherwise been set. If the `autofetchOnStale` flag
     * is set to true, this will also initiate a new fetch of the data.
     * @private
     */
    #markStale() {
        this.#stale = true;

        // If there is an active timeout function, cancel it
        if (this.#cancelStaleSetter) {
            this.#cancelStaleSetter();
            this.#cancelStaleSetter = null;
        }

        // Ask the manager to mark all Flux objects depending on this object as stale
        FluxManager.markAllObjectsRelyingOnObjAsStale(this.#id);

        // Emit the updated event
        this.#emitUpdatedEvent();

        // Emit the stale data event
        emit(`_FLUX_${this.#id}-markedStale`);

        // If the `autofetchOnStale` flag is enabled, start a new fetch of the data
        if (this.#autofetchOnStale) {
            this.get();
        }
    }

    /**
     * Unmarks the cache as stale. Regardless of the cache's stale status before this call, the stale timer will
     * be reset (if a `staleAfter` timer has been provided when constructing the FluxCache).
     * @private
     */
    #unmarkStale() {
        this.#stale = false;

        // If a `staleAfter` timer has been provided, perform the necessary stale timer logic
        if (this.#staleAfter) {

            // Cancel any active timeout functions
            if (this.#cancelStaleSetter) {
                this.#cancelStaleSetter();
            }

            // Create the timeout function and the function to cancel it
            const staleTimeout = setTimeout(() => {
                this.#markStale();
            }, this.#staleAfter);
            this.#cancelStaleSetter = () => clearTimeout(staleTimeout);

        }

        // Emit the updated event
        this.#emitUpdatedEvent();
    }

    //#endregion
}

//#endregion

//#region Public Functions

/**
 * Creates a new `FluxCache` with the given ID. If the ID is already taken by another flux object, that object will be returned instead of
 * a new Flux object being created.
 * @public
 * @memberof module:@psionic/flux
 * @alias module:@psionic/flux.createFluxCache
 *
 * @example
 * // Create a new Flux Cache representing a profile
 * const profileCache = createFluxCache({
 *      id: 'profileCache',
 *      fetch: async () => {
 *          return { name: 'John' };
 *      }
 * });
 *
 * @example
 * // If you attempt to create another Flux object with the same ID, the existing Flux object with that ID will be returned instead of
 * // a new one being created:
 * const profileCache = createFluxCache({
 *      id: 'profileCache',
 *      fetch: async () => {
 *          return { name: 'John' };
 *      }
 * });
 *
 * const newProfileCache = createFluxCache({
 *      id: 'profileCache',
 *      fetch: async () => {
 *          return { name: 'Roni' };
 *      }
 * });
 *
 * await profileCache.get(); // { name: 'John' }
 * await newProfileCache.get(); // { name: 'John' } as well, because the `createFluxCache` call simply returned the existing object with ID `profileCache`
 *
 * @example
 * // Sometimes you may want to invalidate a cache based on 1+ dependencies becoming stale; you can use the `dependsOn` config value to specify this
 * const userIDState = createFluxState({
 *      id: 'userIDState',
 *      value: 'original',
 * });
 *
 * const profileCache = createFluxCache({
 *      id: 'profileCache',
 *      fetch: async () => {
 *          const userID = await userIDState.get();
 *          return fetchProfileByUserID(userID);
 *      },
 *      dependsOn: [userIDState],
 * });
 *
 * profileCache.getStale(); // `true`, Caches start off stale
 * await profileCache.get();
 * profileCache.getStale(); // `false`, Data has now been cached
 * userIDState.set('new');
 * profileCache.getStale(); // `true`, One of the cache's dependencies has become stale
 *
 * @param {Object} config The configuration object
 * @param {string} config.id The ID to use for the FluxCache; should be unique among all other active Flux objects
 * @param {function} config.fetch The function to call to asynchronously fetch the data to store in the cache, if non-stale
 * data does not already exist in the cache
 * @param {Array<FluxCache | FluxState>} [config.dependsOn=[]] The array of Flux objects this cache depends on; if any of the
 * Flux objects' values change or become marked as stale, then this cache will also become marked as stale
 * @param {Number} [config.staleAfter] The amount of time to wait before declaring the data in the cache as stale; if this value is
 * not passed, then the cache will not be marked stale in response to the age of the data in the cache
 * @param {boolean} [config.autofetchOnStale=false] If set to true, then when the cache is marked as stale, the cache will automatically
 * fetch new data to replace the stale data
 * @returns {FluxState | FluxCache} The created Flux object, or the old Flux object with the given ID
 */
function createFluxCache({
    id,
    fetch,
    dependsOn=[],
    staleAfter,
    autofetchOnStale=false,
}) {
    return FluxManager.getOrCreateFluxObject(
        new FluxCache({
            id,
            fetch,
            staleAfter,
            autofetchOnStale,
        }),
        dependsOn,
    );
}

//#endregion

//#region Exports

module.exports = {
    createFluxCache,
    FluxCache,
};

//#endregion