Source: LruCache.js

import {LruMap} from "./LruMap";

/** @namespace @swarmy/lru-cache */

const DEFAULT_MAX_SIZE = 500;

let nextHandlerKey = 0;
const keyToChangedHandler = new Map();
const valueTypeToActiveChangeHandlerKeys = new Map();
const allTypesActiveChangeHandlerKeys = new Set();

const addToActiveHandlerKeys = (key, valueTypes) => {
  if (valueTypes === null || (Array.isArray(valueTypes) && valueTypes.length === 0)) {
    allTypesActiveChangeHandlerKeys.add(key);
  }
  else {
    const types = Array.isArray(valueTypes) ? valueTypes : [valueTypes];
    types.forEach(valueType => {
      let activeChangeHandlerKeys = valueTypeToActiveChangeHandlerKeys.get(valueType);
      if (typeof activeChangeHandlerKeys === "undefined") {
        activeChangeHandlerKeys = new Set();
        valueTypeToActiveChangeHandlerKeys.set(valueType, activeChangeHandlerKeys);
      }
      activeChangeHandlerKeys.add(key);
    });
  }
};

const removeFromActiveHandlerKeys = (key, valueTypes) => {
  if (valueTypes === null || (Array.isArray(valueTypes) && valueTypes.length === 0)) {
    allTypesActiveChangeHandlerKeys.delete(key);
  }
  else {
    const types = Array.isArray(valueTypes) ? valueTypes : [valueTypes];
    types.forEach(valueType => {
      valueTypeToActiveChangeHandlerKeys.get(valueType).delete(key);
    });
  }
};

const getActiveHandlerKeys = valueTypes => {
  const types = typeof valueTypes === "string" ? [valueTypes] : valueTypes;
  const result = new Set();
  types.forEach(valueType => {
    const activeChangeHandlerKeys = valueTypeToActiveChangeHandlerKeys.get(valueType);
    if (typeof activeChangeHandlerKeys !== "undefined") {
      activeChangeHandlerKeys.forEach(key => {
        result.add(key);
      });
    }
  });
  allTypesActiveChangeHandlerKeys.forEach(key => {
    result.add(key);
  });
  return result;
};

const hasActiveHandler = valueType => {
  if (allTypesActiveChangeHandlerKeys.size > 0) {
    return true;
  }
  const activeChangeHandlerKeys = valueTypeToActiveChangeHandlerKeys.get(valueType);
  return typeof activeChangeHandlerKeys === "undefined" ? false : activeChangeHandlerKeys.size > 0;
};

/**
 * Register a handler that is called when value(s) get updated/inserted/removed in/to/from a cache.
 * If the cache has already exceeded its maxSize, there is no way to know if a cache.set (or setAll) is an insert or
 * an update (because JS does not yet provide weak references), so an insert event can be insert or update.
 * The returned handle can be used to unregister the handler, or to deactivate/activate it (initial state is active).
 * @memberof @swarmy/lru-cache
 * @function
 * @param {function} changedHandler - A function that will be called with an object parameter of the following shape:
 *                   {
 *                     valueTypes: Set(),
 *                     <valueType>: {
 *                       inserts: [{key, value, alternateKeys, order}],
 *                       clearRemoves: [{key, value, alternateKeys, order}],
 *                       lruRemoves: [{key, value, alternateKeys, order}],
 *                       deleteRemoves: [{key}],
 *                     },
 *                     ...
 *                   }
 *                   The order can be used to determine e.g. if an entry was first inserted and then deleted, or first
 *                   deleted and then re-inserted (can happen in cache transactions).
 * @param {Array | string} valueTypes - An array or a single string specifying the cache value types the handler should be called for (default: null)
 *                 If null, it will be called for all object types
 *                 If not null and a bulk (transactional) change has multiple valueTypes of which only some are of
 *                 interest for the handler, then also the other types will be part of the argument (if at least one other active listener for the other types exist)
 * @return {object} handlerHandle - An object with methods unregister, activate, deactivate and isRegistered and with fields isActive, valueType and changedHandler
 */
export const registerCacheChangedHandler = (changedHandler, valueTypes = null) => {
  const key = nextHandlerKey;
  nextHandlerKey += 1;
  const handler = {
    changedHandler,
    valueTypes,
    isActive: true,
    unregister: () => {
      keyToChangedHandler.delete(key);
      removeFromActiveHandlerKeys(key, valueTypes);
    },
  };
  handler.activate = () => {
    handler.isActive = true;
    addToActiveHandlerKeys(key, valueTypes);
  };
  handler.deactivate = () => {
    handler.isActive = false;
    removeFromActiveHandlerKeys(key, valueTypes);
  };
  handler.isRegistered = () => keyToChangedHandler.has(key);
  keyToChangedHandler.set(key, handler);
  addToActiveHandlerKeys(key, valueTypes);
  return handler;
};


const handleTransactionChangeObject = changeObject => {
  const activeHandlerKeys = getActiveHandlerKeys(changeObject.valueTypes);
  const errors = [];
  let handled = 0;
  activeHandlerKeys.forEach(key => {
    const handler = keyToChangedHandler.get(key);
    try {
      handler.changedHandler(changeObject);
    }
    catch (error) {
      errors.push(error);
    }
    finally {
      handled += 1;
    }
  });
  if (errors.length > 0) {
    let message = "handleTransactionChangeObject: " + String(errors.length) + " of " + String(handled) + " handlers threw an error: ";
    errors.forEach(error => {
      message += error.message + ", ";
    });
    const error = new Error(message);
    error.errors = errors;
    throw error;
  }
};


let transactionChangeObject = null;
let changeOrder = 0;
let runningTransactions = 0;

/** Pass a callback or a promise. All cache changes happening inside the callback or promise will be batched into a single
 *  change object that will be dispatched to handlers after the callback/promise has finished. If this is called while there
 *  is already another transaction in progress, the two transactions will just be batched together.
 * @memberof @swarmy/lru-cache
 * @function
 * @param {function | Promise} callbackOrPromise - callback or promise to be executed within the transaction
 * @return {undefined} void
 */
export const cacheTransaction = callbackOrPromise => {
  if (transactionChangeObject === null) {
    transactionChangeObject = {
      valueTypes: new Set(),
    };
  }
  runningTransactions += 1;
  if (typeof callbackOrPromise.finally === "function") {
    callbackOrPromise.finally(() => {
      runningTransactions -= 1;
      if (runningTransactions === 0) {
        const changeObject = transactionChangeObject;
        transactionChangeObject = null;
        changeOrder = 0;
        handleTransactionChangeObject(changeObject);
      }
    });
  }
  else {
    try {
      callbackOrPromise();
    }
    finally {
      runningTransactions -= 1;
      if (runningTransactions === 0) {
        const changeObject = transactionChangeObject;
        transactionChangeObject = null;
        changeOrder = 0;
        handleTransactionChangeObject(changeObject);
      }
    }
  }
};

const handleChange = (valueType, keyValueAlternateKeys, fieldNameAdd, fieldNamesUnchanged) => {
  let changeObject = transactionChangeObject;
  const batchChanges = changeObject !== null;
  if (changeObject === null) {
    changeObject = {
      valueTypes: new Set(),
    };
  }
  if (changeObject.valueTypes.has(valueType)) {
    // Copying the original entry is not just done to add the order, but is mandatory to get the value
    // at the point of change and not the current cache value in the change event!
    changeObject[valueType][fieldNameAdd].push({...keyValueAlternateKeys, order: changeOrder++});
  }
  else {
    changeObject.valueTypes.add(valueType);
    changeObject[valueType] = {
      [fieldNameAdd]: [{...keyValueAlternateKeys, order: changeOrder++}],
    };
    fieldNamesUnchanged.forEach(fieldName => {
      changeObject[valueType][fieldName] = [];
    })
  }
  if (!batchChanges) {
    handleTransactionChangeObject(changeObject);
  }
};

let handleChanges = true;

const handleInsert = (valueType, keyValueAlternateKeys) => {
  if (handleChanges) {
    handleChange(valueType, keyValueAlternateKeys, "inserts", ["clearRemoves", "lruRemoves", "deleteRemoves"]);
  }
};

const handleClearRemove = (valueType, keyValueAlternateKeys) => {
  if (handleChanges) {
    handleChange(valueType, keyValueAlternateKeys, "clearRemoves", ["inserts", "lruRemoves", "deleteRemoves"]);
  }
};

const handleLruRemove = (valueType, keyValueAlternateKeys) => {
  if (handleChanges) {
    handleChange(valueType, keyValueAlternateKeys, "lruRemoves", ["clearRemoves", "inserts", "deleteRemoves"]);
  }
};

const handleDeleteRemove = (valueType, keyValueAlternateKeys) => {
  if (handleChanges) {
    handleChange(valueType, keyValueAlternateKeys, "deleteRemoves", ["clearRemoves", "lruRemoves", "inserts"]);
  }
};

const asyncWrap = syncFunction => (...args) => new Promise((resolve, reject) => {
  setTimeout(() => {
    try {
      const result = syncFunction(...args);
      resolve(result);
    }
    catch (e) {
      reject(e);
    }
  }, 0);
});

const wrapInTransaction = (valueType, transactionCallback) => {
  if (hasActiveHandler(valueType)) {
    cacheTransaction(transactionCallback);
  }
  else {
    try {
      handleChanges = false;
      transactionCallback();
    }
    finally {
      handleChanges = true;
    }
  }
};

const setAll = (valueType, lruMap, alternateKeyToKey, keyValueAlternateKeysArray, dispatchLruRemoves) => {
  if (!Array.isArray(keyValueAlternateKeysArray)) {
    throw new Error("LruCache::setAll: keyValueAlternateKeysArray must be an array");
  }
  wrapInTransaction(valueType, () => {
    keyValueAlternateKeysArray.forEach(({key, value, alternateKeys}) => {
      let entry = lruMap.getWithoutLruChange(key);
      let altKeys = Array.isArray(alternateKeys) ? alternateKeys : [];
      if (altKeys.length === 0 && typeof alternateKeys === "string") {
        altKeys = [alternateKeys];
      }
      altKeys.forEach(altKey => {
        if (alternateKeyToKey.has(altKey) && alternateKeyToKey.get(altKey) !== key) {
          throw new Error("LruCache::setAll: alternate key '" + altKey + "' is given for key '" + key + "' and value type '" + valueType + "' but is already used for key '" + alternateKeyToKey.get(altKey) + "'");
        }
      });
      altKeys = new Set(altKeys);
      if (typeof entry === "undefined") {
        entry = {
          key,
          value,
          alternateKeys: altKeys,
        };
      }
      else {
        entry.value = value;
        entry.alternateKeys = new Set([...entry.alternateKeys, ...altKeys]);
      }
      const removed = lruMap.set(key, entry);
      altKeys.forEach(altKey => {
        alternateKeyToKey.set(altKey, key);
      });
      handleInsert(valueType, entry);
      if (removed !== null) {
        removed.value.alternateKeys.forEach(altKey => {
          alternateKeyToKey.delete(altKey);
        });
        if (dispatchLruRemoves) {
          handleLruRemove(valueType, removed.value);
        }
      }
    });
  });
};

/** Cannot be instantiated directly! Use 'getCache' to get a cache instance.
 *  By default, cache events are dispatched only for inserts/updates and deletes.
 *  To dispatch also LRU removes and/or clear removes, use the corresponding setters.
 * @class LruCache
 * @param {string} valueType - The value type of this cache
 * @param {maxSize} maxSize - The maximum number of entries for the given value type (default: 500)
 */
function LruCache(valueType, maxSize = DEFAULT_MAX_SIZE) {
  const self = this instanceof LruCache ? this : Object.create(LruCache.prototype);
  const lruMap = LruMap(maxSize);
  const alternateKeyToKey = new Map();
  let dispatchLruRemoves = false;
  let dispatchClearRemoves = false;

  /** Set whether the cache should also dispatch events for LRU removes
   * @memberof LruCache
   * @function
   * @param {boolean} newValue - true, if LRU removes should be dispatched
   * @returns {undefined} void
   */
  self.dispatchLruRemoves = newValue => {
    dispatchLruRemoves = newValue;
  };

  /** Set whether the cache should also dispatch events for clear removes
   * @memberof LruCache
   * @function
   * @param {boolean} newValue - true, if clear removes should be dispatched
   * @returns {undefined} void
   */
  self.dispatchClearRemoves = newValue => {
    dispatchClearRemoves = newValue;
  };

  /** Insert or update multiple cache entries.
   *  If alternate keys are provided and an already existing entry already has alternate keys, these will be extended.
   *  A corresponding cache changed event will be dispatched.
   *  If inserts lead to cache max size being exceeded and dispatchLruRemoves is set to true, the cache change event will contain both, inserts and removes.
   *  It is even possible that an entry from the inserts is also contained in the removes.
   * @memberof LruCache
   * @function
   * @param {Array} keyValueAlternateKeysArray - array of objects with 'key', 'value' and optional 'alternateKeys'
   * @returns {undefined} void
   */
  self.setAll = keyValueAlternateKeysArray => {
    setAll(valueType, lruMap, alternateKeyToKey, keyValueAlternateKeysArray, dispatchLruRemoves);
  };

  /** Like 'setAll', but returning a Promise that is executed in another event loop.
   * @memberof LruCache
   * @function
   * @param {Array} keyValueAlternateKeysArray - array of objects with 'key', 'value' and optional 'alternateKeys'
   * @returns {Promise}
   */
  self.setAllAsync = asyncWrap(self.setAll);

  /** Insert or update a cache entry.
   *  If alternate keys are provided and an already existing entry already has alternate keys, these will be extended.
   *  A corresponding cache changed event will be dispatched.
   *  If an insert leads to cache max size being exceeded and dispatchLruRemoves is set to true, the cache change event will contain both, insert and remove.
   * @memberof LruCache
   * @function
   * @param {object} keyValueAlternateKeys - object with 'key' and 'value' and optional 'alternateKeys'
   * @returns {undefined} void
   */
  self.set = keyValueAlternateKeys => {
    self.setAll([keyValueAlternateKeys]);
  };

  /** Like 'set', but returning a Promise that is executed in another event loop.
   * @memberof LruCache
   * @function
   * @param {object} keyValueAlternateKeys - object with 'key' and 'value' and optional 'alternateKeys'
   * @returns {Promise}
   */
  self.setAsync = asyncWrap(self.set);

  let entryGetter = null;
  const keyToPromise = new Map();
  const internalGetter = (key, getter, useEntryGetter = false, notFromCache = false, customEntryGetter = null) => {
    let entry; // eslint-disable-line init-declarations
    if (!notFromCache) {
      entry = getter(key);
      if (typeof entry === "undefined" && alternateKeyToKey.has(key)) {
        entry = getter(alternateKeyToKey.get(key));
      }
    }
    let usedEntryGetter = entryGetter;
    if (customEntryGetter !== null) {
      usedEntryGetter = customEntryGetter;
    }
    if (typeof entry === "undefined") {
      if (useEntryGetter && usedEntryGetter !== null) {
        if (keyToPromise.has(key)) {
          return keyToPromise.get(key);
        }
        const keyValueAlternateKeys = usedEntryGetter(key);
        if (!keyValueAlternateKeys) {
          return entry;
        }
        if (typeof keyValueAlternateKeys.then === "function") {
          const promise = keyValueAlternateKeys.then(keyValueAlternateKeysResolved => {
            if (!keyValueAlternateKeysResolved) {
              return keyValueAlternateKeysResolved;
            }
            if (keyToPromise.has(key)) {
              // The condition is necessary, because meanwhile there might have been a self.delete(key)
              self.set(keyValueAlternateKeysResolved);
            }
            return keyValueAlternateKeysResolved.value;
          }).finally(() => {
            keyToPromise.delete(key); // important to use key and not keyValueAlternateKeysResolved.key, because key could also be an alternate key!
          });
          keyToPromise.set(key, promise);
          return promise;
        }
        else {
          self.set(keyValueAlternateKeys);
          return keyValueAlternateKeys.value;
        }
      }
      else if (notFromCache) {
        throw new Error("called get with notFromCache, but no entry getter was set");
      }
      else {
        return entry;
      }
    }
    return entry.value;
  };

  /** Set a getter that can be used to retrieve a cache entry (keyValueAlternateKeys-object) by key in
   *  case it is not yet in the cache.
   *  For values that might be called by alternate key, the getter should also be able to handle this.
   * @memberof LruCache
   * @function
   * @param {function} newEntryGetter - function that takes a key as argument and returns corresponding entry or promise
   * @returns {undefined} void
   */
  self.setEntryGetter = newEntryGetter => {
    entryGetter = newEntryGetter;
  };

  /** Get value from cache by either its key or one of its alternate keys (if exists).
   *  If the value is not found in the cache and an entry-getter was set via setEntryGetter, then:
   *     - If the entry getter returns a Promise, a Promise resolving to the value will be returned.
   *       When the Promise resolves, the entry will be set to the cache. Until the Promise is not resolved,
   *       subsequent calls to get will return the same Promise.
   *     - If the entry getter returns a cache entry (keyValueAlternateKeys-object), this will be set to the cache
   *       and the value will be returned.
   *     - If the entry getter returns null or undefined, undefined will be returned.
   *  If the key is not in the cache and no entry getter is set, undefined will be returned.
   *  Makes the corresponding entry the most recently used (use 'getWithoutLruChange' to avoid this).
   * @memberof LruCache
   * @function
   * @param {string} keyOrAlternateKey - The key or alternate key of the value
   * @param {boolean} notFromCache - If true and an entry getter is set, then the value will not be taken from the
   *                                 cache, but from the entry getter. If no entry getter is set, an error will be
   *                                 thrown. (default: false)
   * @param {function} customEntryGetter - function that takes a key as argument and returns corresponding entry or
   *                                       promised entry. Has precedence over entry gettter set via setEntryGetter.
   * @returns {value | Promise | undefined} value, promised value or undefined
   */
  self.get = (keyOrAlternateKey, notFromCache = false, customEntryGetter = null) => internalGetter(keyOrAlternateKey, lruMap.get, true, notFromCache, customEntryGetter);

  /** Like 'get', but not making the corresponding entry the most recently used.
   *  If the value is retrieved via entry getter, it will of course still become the
   *  most recently used.
   * @memberof LruCache
   * @function
   * @param {string} keyOrAlternateKey - The key or alternate key of the value
   * @param {boolean} notFromCache - If true and an entry getter is set, then the value will not be taken from the
   *                                 cache, but from the entry getter. If no entry getter is set, an error will be
   *                                 thrown. (default: false)
   * @param {function} customEntryGetter - function that takes a key as argument and returns corresponding entry or
   *                                       promised entry. Has precedence over entry gettter set via setEntryGetter.
   * @returns {value | Promise | undefined} value, promised value or undefined
   */
  self.getWithoutLruChange = (keyOrAlternateKey, notFromCache = false, customEntryGetter = null) => internalGetter(keyOrAlternateKey, lruMap.getWithoutLruChange, true, notFromCache, customEntryGetter);

  /** Return whether the cache contains an entry for the given key or alternate key
   * @memberof LruCache
   * @function
   * @param {string} keyOrAlternateKey - The entry key or alternate key
   * @return {boolean} true, if the given key or alternate key is in the cache
   */
  self.has = keyOrAlternateKey => {
    if (lruMap.has(keyOrAlternateKey)) {
      return true;
    }
    else {
      return alternateKeyToKey.has(keyOrAlternateKey);
    }
  };

  /** Delete entry from cache by key.
   *  Here, no alternate key can be used.
   *  A corresponding cache changed event will be dispatched.
   * @memberof LruCache
   * @function
   * @param {string} key - The key of the to be deleted value
   * @returns {boolean} true, if the key was in the cache.
   */
  self.delete = key => {
    const entry = lruMap.getWithoutLruChange(key);
    if (typeof entry === "undefined") {
      wrapInTransaction(valueType, () => {
        handleDeleteRemove(valueType, {key});
      });
      return false;
    }
    lruMap.delete(entry.key);
    entry.alternateKeys.forEach(altKey => {
      alternateKeyToKey.delete(altKey);
    });
    // It is important to wrap even single actions to get consistent behavior,
    // e.g. to always reset 'order', even after a delete
    wrapInTransaction(valueType, () => {
      handleDeleteRemove(valueType, {key});
    });
    return true;
  };

  /** Iterate over the cache from oldest to newest entry.
   *  The given callback gets a cache entry as argument (an object with 'key', 'value' and 'alternateKeys').
   * @memberof LruCache
   * @function
   */
  self.forEach = lruMap.forEach;

  /** Get an Array with all cache entries.
   * @memberof LruCache
   * @function
   * @returns {Array} cache entries (objects with 'key', 'value' and 'alternateKeys')
   */
  self.getEntries = () => lruMap.map(entry => entry);

  /** Clear the cache.
   *  A corresponding cache changed event will be dispatched.
   * @memberof LruCache
   * @function
   * @returns {undefined} void
   */
  self.clear = () => {
    const keyValueArray = lruMap.clear();
    alternateKeyToKey.clear();
    if (dispatchClearRemoves) {
      wrapInTransaction(valueType, () => {
        keyValueArray.forEach(keyValuePair => {
          handleClearRemove(valueType, keyValuePair.value);
        });
      });
    }
  };

  /** Get the number of currently cached objects.
   * @memberof LruCache
   * @function
   * @returns {int} current number of entries in this cache
   */
  self.getSize = lruMap.getSize;

  /** Get the value type of this cache.
   * @memberof LruCache
   * @function
   * @return {string} the value type
   */
  self.getValueType = () => valueType;

  /** Return the current max size of this cache.
   * @memberof LruCache
   * @function
   * @returns {int} max size of this cache
   */
  self.getMaxSize = lruMap.getMaxSize;

  /** Set a new max size for this cache.
   *  If this leads to the removal of cache entries and dispatchLruRemoves is set true, a corresponding cache changed event will be dispatched.
   * @memberof LruCache
   * @function
   * @param {int} newMaxSize - the new max number of entries for this cache.
   * @returns {undefined} void
   */
  self.setMaxSize = newMaxSize => {
    const keyValueArray = lruMap.setMaxSize(newMaxSize);
    wrapInTransaction(valueType, () => {
      keyValueArray.forEach(keyValuePair => {
        keyValuePair.value.alternateKeys.forEach(altKey => {
          alternateKeyToKey.delete(altKey);
        });
        if (dispatchLruRemoves) {
          handleLruRemove(valueType, keyValuePair.value);
        }
      });
    });
  };

  return self;
}


const valueTypeToCache = new Map();


/**
 * Get a LruCache for the given valueType.
 * If a cache for this type already exists, the existing cache instance will be returned (LruCache is a singleton per value type).
 * @memberof @swarmy/lru-cache
 * @function
 * @param {string} valueType - The type of object being cached.
 * @returns {LruCache} - A lru-cache object.
 * @see LruCache
 */
export const getCache = valueType => {
  let lruCache = valueTypeToCache.get(valueType);
  if (typeof lruCache === "undefined") {
    lruCache = LruCache(valueType);
    valueTypeToCache.set(valueType, lruCache);
  }
  return lruCache;
};

/**
 * Clear (invalidate) all existing caches.
 * Will dispatch a single change event with all changes batched together.
 * @memberof @swarmy/lru-cache
 * @function
 * @returns {void}
 */
export const clearAllCaches = () => {
  cacheTransaction(() => {
    valueTypeToCache.forEach(cache => {
      cache.clear();
    });
  });
};