cache/scopedCache.js

import {getCache} from '../cache.js';
import {reusePromiseForever} from '../reusePromises.js';

/**
 * A wrapper class for accessing the cache.
 * Prefixes a string before each key to avoid conflicts.
 */
export class ScopedCache {
  /**
   * Create a new ScopedCache object by passing in the key prefix descired
   * @param {string} keyPrefix
   * @param {number} [version] Cache version, bump this to invalidate existing cache entries for a scope
   * @param {object} [cacheObject=null] Manually supply a cache object to use
   */
  constructor(keyPrefix, version = 0, cacheObject = null) {
    this.prefix = keyPrefix;
    this.version = version;

    this.cache = null;
    this.cacheOverride = cacheObject;
  }

  /**
   * Initialise the cache for this scope
   */
  async initCache() {
    return await reusePromiseForever(this, this._initCache);
  }

  /**
   * Internal cache initialisation
   */
  async _initCache() {
    if (this.cache) {
      return this.cache;
    }

    if (!!this.cacheOverride) {
      this.cache = this.cacheOverride;
    } else {
      this.cache = await getCache();
    }

    // check and flush cache if version mismatch
    const cacheVersion = await this.cache.get(this.generateScopedKey('%%version%%'));
    if (cacheVersion !== undefined && cacheVersion != this.version) {
      // find all cache entries with this scope, and remove them
      const keys = await this.cache.getKeys(`${this.prefix}_`);

      await Promise.allSettled(keys.map((key) => {
        // set expire date to 1 millisecond ago (this basically deletes it)
        return this.cache.set(key, {}, -1);
      }));
    }

    // set our new cache version with a very very long ttl
    await this.cache.set(this.generateScopedKey('%%version%%'), this.version, Number.MAX_SAFE_INTEGER);
    this._initCachePromise = null;

    return this.cache;
  }

  /**
   * Generate a scoped key by adding our prefix to the incoming key
   * @param {string} inKey
   * @return {string} Scoped key
   */
  generateScopedKey(inKey) {
    return `${this.prefix}_${inKey}`;
  }

  /**
     * Get a cached object
     * @public
     * @async
     * @param {string} key Unique key name for this cache entry
     * @param {boolean} [getFullObject] Get the full cache entry, including expiry time, even if expired
     * @param {boolean} [force=false] Force set, bypassing transaction blocks
     * @return {(Object|undefined)} Returns the object in the cache, or undefined if not present
     */
  async get(key, getFullObject = false, force = false) {
    return this.getGlobal(this.generateScopedKey(key), getFullObject, force);
  }

  /**
     * Get a cached object from the global cache (skipping the scope prefix)
     * @public
     * @async
     * @param {string} key Unique key name for this cache entry
     * @param {boolean} [getFullObject] Get the full cache entry, including expiry time, even if expired
     * @param {boolean} [force=false] Force set, bypassing transaction blocks
     * @return {(Object|undefined)} Returns the object in the cache, or undefined if not present
     */
  async getGlobal(key, getFullObject = false, force = false) {
    const cache = await this.initCache();
    return cache.get(key, getFullObject, force);
  }

  /**
     * Set a key in our cache
     * @public
     * @async
     * @param {string} key Unique key name for this cache entry
     * @param {Object} value
     * @param {(Function|number)} [ttl=3600000] How long the cache entry should last in milliseconds
     *  Can be a number or a function that will return a number
     *  Default 1 hour
     */
  async set(key, value, ttl = 3600000) {
    return this.setGlobal(this.generateScopedKey(key), value, ttl);
  }

  /**
     * Set a key in our global cache, skipping the scoped prefix
     * @public
     * @async
     * @param {string} key Unique key name for this cache entry
     * @param {Object} value
     * @param {(Function|number)} [ttl=3600000] How long the cache entry should last in milliseconds
     *  Can be a number or a function that will return a number
     *  Default 1 hour
     */
  async setGlobal(key, value, ttl = 3600000) {
    const cache = await this.initCache();
    return cache.set(key, value, ttl);
  }

  /**
     * A helper "wrap" function that will return a cached value if present
     *  This will call the supplied function to fetch it if the value isn't present in the cache
     * @public
     * @async
     * @param {string} key Unique key name for this cache entry
     * @param {function} fn Fetch function that will be called if the cache entry is not present
     * @param {(function|number)} [ttl] How long the cache entry should last in milliseconds
     *  Can be a number or a function that will return a number
     */
  async wrap(key, fn, ttl) {
    return this.wrapGlobal(this.generateScopedKey(key), fn, ttl);
  }

  /**
     * A helper "wrap" function that will return a cached value if present (in the global scope)
     *  This will call the supplied function to fetch it if the value isn't present in the cache
     * @public
     * @async
     * @param {string} key Unique key name for this cache entry
     * @param {function} fn Fetch function that will be called if the cache entry is not present
     * @param {(function|number)} [ttl] How long the cache entry should last in milliseconds
     *  Can be a number or a function that will return a number
     */
  async wrapGlobal(key, fn, ttl) {
    const cache = await this.initCache();
    return cache.wrap(key, fn, ttl);
  }

  /**
   * Run a series of functions in a single transaction
   * @param {functions} func
   * @return {promise}
   */
  async runTransaction(func) {
    const cache = await this.initCache();
    return cache.runTransaction(func, (key) => {
      return this.generateScopedKey(key);
    });
  }

  /**
   * Block if we have any pending transactions
   * @return {Promise}
   */
  async blockOnPendingTransactions() {
    const cache = await this.initCache();
    return cache.blockOnPendingTransactions();
  }

  /**
   * Get an array of all the cached keys matching the supplied prefix
   * @param {string} [prefix='']
   * @return {array<string>}
   */
  async getKeys(prefix = '') {
    const cache = await this.initCache();
    return (await cache.getKeys(`${this.prefix}_${prefix}`)).map((x) => {
      // return keys without our scoped cache prefix
      return x.slice(this.prefix.length + 1);
    });
  }
}

export default ScopedCache;