Cache.js

import BinaryHeap from '../node_modules/yabh/src/index'
import defaults from './defaults'
import utils from './utils'

const assignMsg = `Cannot assign to read only property`

/**
 * Provide a custom storage medium, e.g. a polyfill for `localStorage`. Default: `null`.
 *
 * Must implement:
 *
 * - `setItem` - Same API as `localStorage.setItem(key, value)`
 * - `getItem` - Same API as `localStorage.getItem(key)`
 * - `removeItem` - Same API as `localStorage.removeItem(key)`
 *
 * @name Cache~StorageImpl
 * @type {object}
 * @property {function} setItem Implementation of `setItem(key, value)`.
 * @property {function} getItem Implementation of `getItem(key)`.
 * @property {function} removeItem Implementation of `removeItem(key)`.
 */

/**
 * Instances of this class represent a "cache"—a synchronous key-value store.
 * Each instance holds the settings for the cache, and provides methods for
 * manipulating the cache and its data.
 *
 * Generally you don't creates instances of `Cache` directly, but instead create
 * instances of `Cache` via {@link CacheFactory#createCache}.
 *
 * @example
 * import CacheFactory from 'cachefactory';
 *
 * const cacheFactory = new CacheFactory();
 * const options = {...};
 * const cache = cacheFactory.createCache('my-cache', options);
 *
 * cache.put('foo', 'bar');
 * console.log(cache.get('foo')); // "bar"
 *
 * @class Cache
 * @param {string} id A unique identifier for the cache.
 * @param {object} [options] Configuration options.
 * @param {number} [options.cacheFlushInterval=null] See {@link Cache#cacheFlushInterval}.
 * @param {number} [options.capacity=Number.MAX_VALUE] See {@link Cache#capacity}.
 * @param {string} [options.deleteOnExpire="none"] See {@link Cache#deleteOnExpire}.
 * @param {boolean} [options.enabled=true] See {@link Cache#enabled}.
 * @param {number} [options.maxAge=Number.MAX_VALUE] See {@link Cache#maxAge}.
 * @param {function} [options.onExpire=null] See {@link Cache#onExpire}.
 * @param {number} [options.recycleFreq=1000] See {@link Cache#recycleFreq}.
 * @param {Cache~StorageImpl} [options.storageImpl=null] See {@link Cache~StorageImpl}.
 * @param {string} [options.storageMode="memory"] See {@link Cache#storageMode}.
 * @param {string} [options.storagePrefix="cachefactory.caches."] See {@link Cache#storagePrefix}.
 * @param {boolean} [options.storeOnReject=false] See {@link Cache#storeOnReject}.
 * @param {boolean} [options.storeOnResolve=false] See {@link Cache#storeOnResolve}.
 */
export default class Cache {
  constructor (id, options = {}) {
    if (!utils.isString(id)) {
      throw new TypeError(`"id" must be a string!`)
    }

    Object.defineProperties(this, {
      // Writable
      $$cacheFlushInterval: { writable: true, value: undefined },
      $$cacheFlushIntervalId: { writable: true, value: undefined },
      $$capacity: { writable: true, value: undefined },
      $$data: { writable: true, value: {} },
      $$deleteOnExpire: { writable: true, value: undefined },
      $$enabled: { writable: true, value: undefined },
      $$expiresHeap: { writable: true, value: new BinaryHeap((x) => x.accessed, utils.equals) },
      $$initializing: { writable: true, value: true },
      $$lruHeap: { writable: true, value: new BinaryHeap((x) => x.accessed, utils.equals) },
      $$maxAge: { writable: true, value: undefined },
      $$onExpire: { writable: true, value: undefined },
      $$prefix: { writable: true, value: '' },
      $$promises: { writable: true, value: {} },
      $$recycleFreq: { writable: true, value: undefined },
      $$recycleFreqId: { writable: true, value: undefined },
      $$storage: { writable: true, value: undefined },
      $$storageMode: { writable: true, value: undefined },
      $$storagePrefix: { writable: true, value: undefined },
      $$storeOnReject: { writable: true, value: undefined },
      $$storeOnResolve: { writable: true, value: undefined },

      // Read-only
      $$parent: { value: options.parent },

      /**
       * The interval (in milliseconds) on which the cache should remove all of
       * its items. Setting this to `null` disables the interval. The default is
       * `null`.
       *
       * @example <caption>Create a cache the clears itself every 15 minutes</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   cacheFlushInterval: 15 * 60 * 1000
       * });
       *
       * @name Cache#cacheFlushInterval
       * @default null
       * @public
       * @readonly
       * @type {number|null}
       */
      cacheFlushInterval: {
        enumerable: true,
        get: () => this.$$cacheFlushInterval,
        set: () => { throw new Error(`${assignMsg} 'cacheFlushInterval'`) }
      },

      /**
       * The maximum number of items that can be stored in the cache. When the
       * capacity is exceeded the least recently accessed item will be removed.
       * The default is `Number.MAX_VALUE`.
       *
       * @example <caption>Create a cache with a capacity of 100</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   capacity: 100
       * });
       *
       * @name Cache#capacity
       * @default Number.MAX_VALUE
       * @public
       * @readonly
       * @type {number}
       */
      capacity: {
        enumerable: true,
        get: () => this.$$capacity,
        set: () => { throw new Error(`${assignMsg} 'capacity'`) }
      },

      /**
       * Determines the behavior of a cache when an item expires. The default is
       * `"none"`.
       *
       * Possible values:
       *
       * - `"none"` - Cache will do nothing when an item expires.
       * - `"passive"` - Cache will do nothing when an item expires. Expired
       * items will remain in the cache until requested, at which point they are
       * removed, and `undefined` is returned.
       * - `"aggressive"` - Cache will remove expired items as soon as they are
       * discovered.
       *
       * @example <caption>Create a cache that deletes items as soon as they expire</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   deleteOnExpire: 'aggressive'
       * });
       *
       * @example <caption>Create a cache that doesn't delete expired items until they're accessed</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   deleteOnExpire: 'passive'
       * });
       *
       * @name Cache#deleteOnExpire
       * @default "none"
       * @public
       * @readonly
       * @type {string}
       */
      deleteOnExpire: {
        enumerable: true,
        get: () => this.$$deleteOnExpire,
        set: () => { throw new Error(`${assignMsg} 'deleteOnExpire'`) }
      },

      /**
       * Marks whether the cache is enabled or not. For a disabled cache,
       * {@link Cache#put} is a no-op. The default is `true`.
       *
       * @example <caption>Create a cache that starts out disabled</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   enabled: false
       * });
       *
       * // The cache is disabled, this is a no-op
       * cache.put('foo', 'bar');
       * console.log(cache.get('foo')); // undefined
       *
       * @name Cache#enabled
       * @default true
       * @public
       * @readonly
       * @type {boolean}
       */
      enabled: {
        enumerable: true,
        get: () => this.$$enabled,
        set: () => { throw new Error(`${assignMsg} 'enabled'`) }
      },

      /**
       * Then unique identifier given to this cache when it was created.
       *
       * @name Cache#id
       * @public
       * @readonly
       * @type {string}
       */
      id: {
        enumerable: true,
        value: id
      },

      /**
       * Represents how long an item can be in the cache before expires. The
       * cache's behavior toward expired items is determined by
       * {@link Cache#deleteOnExpire}. The default value for `maxAge` is
       * `Number.MAX_VALUE`.
       *
       * @example <caption>Create a cache where items expire after 15 minutes</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   // Items expire after 15 minutes
       *   maxAge: 15 * 60 * 1000
       * });
       * const cache2 = cacheFactory.createCache('my-cache2', {
       *   // Items expire after 15 minutes
       *   maxAge: 15 * 60 * 1000,
       *   // Expired items will only be deleted once they are accessed
       *   deleteOnExpire: 'passive'
       * });
       * const cache3 = cacheFactory.createCache('my-cache3', {
       *   // Items expire after 15 minutes
       *   maxAge: 15 * 60 * 1000,
       *   // Items will be deleted from the cache as soon as they expire
       *   deleteOnExpire: 'aggressive'
       * });
       *
       * @name Cache#maxAge
       * @default Number.MAX_VALUE
       * @public
       * @readonly
       * @type {number}
       */
      maxAge: {
        enumerable: true,
        get: () => this.$$maxAge,
        set: () => { throw new Error(`${assignMsg} 'maxAge'`) }
      },

      /**
       * The `onExpire` callback.
       *
       * @callback Cache~onExpireCallback
       * @param {string} key The key of the expired item.
       * @param {*} value The value of the expired item.
       * @param {function} [done] If in `"passive"` mode and you pass an
       * `onExpire` callback to {@link Cache#get}, then the `onExpire` callback
       * you passed to {@link Cache#get} will be passed to your global
       * `onExpire` callback.
       */

      /**
       * A callback to be executed when expired items are removed from the
       * cache when the cache is in `"passive"` or `"aggressive"` mode. The
       * default is `null`. See {@link Cache~onExpireCallback} for the signature
       * of the `onExpire` callback.
       *
       * @example <caption>Create a cache where items expire after 15 minutes</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   // Items expire after 15 minutes
       *   maxAge: 15 * 60 * 1000,
       *   // Expired items will only be deleted once they are accessed
       *   deleteOnExpire: 'passive',
       *   // Try to rehydrate cached items as they expire
       *   onExpire: function (key, value, done) {
       *     // Do something with key and value
       *
       *     // Will received "done" callback if in "passive" mode and passing
       *     // an onExpire option to Cache#get.
       *     if (done) {
       *       done(); // You can pass whatever you want to done
       *     }
       *   }
       * });
       *
       * @name Cache#onExpire
       * @default null
       * @public
       * @readonly
       * @see Cache~onExpireCallback
       * @type {function}
       */
      onExpire: {
        enumerable: true,
        get: () => this.$$onExpire,
        set: () => { throw new Error(`${assignMsg} 'onExpire'`) }
      },

      /**
       * The frequency (in milliseconds) with which the cache should check for
       * expired items. The default is `1000`. The value of this interval only
       * matters if {@link Cache#deleteOnExpire} is set to `"aggressive"`.
       *
       * @example <caption>Create a cache where items expire after 15 minutes checking every 10 seconds</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   // Items expire after 15 minutes
       *   maxAge: 15 * 60 * 1000,
       *   // Items will be deleted from the cache as soon as they expire
       *   deleteOnExpire: 'aggressive',
       *   // Check for expired items every 10 seconds
       *   recycleFreq: 10 * 1000
       * });
       *
       * @name Cache#recycleFreq
       * @default 1000
       * @public
       * @readonly
       * @type {number|null}
       */
      recycleFreq: {
        enumerable: true,
        get: () => this.$$recycleFreq,
        set: () => { throw new Error(`${assignMsg} 'recycleFreq'`) }
      },

      /**
       * Determines the storage medium used by the cache. The default is
       * `"memory"`.
       *
       * Possible values:
       *
       * - `"memory"`
       * - `"localStorage"`
       * - `"sessionStorage"`
       *
       * @example <caption>Create a cache that stores its data in localStorage</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   storageMode: 'localStorage'
       * });
       *
       * @example <caption>Provide a custom storage implementation</caption>
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   storageMode: 'localStorage',
       *   storageImpl: {
       *     setItem: function (key, value) {
       *       console.log('setItem', key, value);
       *       localStorage.setItem(key, value);
       *     },
       *     getItem: function (key) {
       *       console.log('getItem', key);
       *       localStorage.getItem(key);
       *     },
       *     removeItem: function (key) {
       *       console.log('removeItem', key);
       *       localStorage.removeItem(key);
       *     }
       *   }
       * });
       *
       * @name Cache#storageMode
       * @default "memory"
       * @public
       * @readonly
       * @type {string}
       */
      storageMode: {
        enumerable: true,
        get: () => this.$$storageMode,
        set: () => { throw new Error(`${assignMsg} 'storageMode'`) }
      },

      /**
       * The prefix used to namespace the keys for items stored in
       * `localStorage` or `sessionStorage`. The default is
       * `"cachefactory.caches."` which is conservatively long in order any
       * possible conflict with other data in storage. Set to a shorter value
       * to save storage space.
       *
       * @example
       * import CacheFactory from 'cachefactory';
       *
       * const cacheFactory = new CacheFactory();
       * const cache = cacheFactory.createCache('my-cache', {
       *   storageMode: 'localStorage',
       *   // Completely remove the prefix to save the most space
       *   storagePrefix: ''
       * });
       * cache.put('foo', 'bar');
       * console.log(localStorage.get('my-cache.data.foo')); // "bar"
       *
       * @name Cache#storagePrefix
       * @default "cachefactory.caches."
       * @public
       * @readonly
       * @type {string}
       */
      storagePrefix: {
        enumerable: true,
        get: () => this.$$storagePrefix,
        set: () => { throw new Error(`${assignMsg} 'storagePrefix'`) }
      },

      /**
       * If set to `true`, when a promise is inserted into the cache and is then
       * rejected, then the rejection value will overwrite the promise in the
       * cache. The default is `false`.
       *
       * @name Cache#storeOnReject
       * @default false
       * @public
       * @readonly
       * @type {boolean}
       */
      storeOnReject: {
        enumerable: true,
        get: () => this.$$storeOnReject,
        set: () => { throw new Error(`${assignMsg} 'storeOnReject'`) }
      },

      /**
       * If set to `true`, when a promise is inserted into the cache and is then
       * resolved, then the resolution value will overwrite the promise in the
       * cache. The default is `false`.
       *
       * @name Cache#storeOnResolve
       * @default false
       * @public
       * @readonly
       * @type {boolean}
       */
      storeOnResolve: {
        enumerable: true,
        get: () => this.$$storeOnResolve,
        set: () => { throw new Error(`${assignMsg} 'storeOnResolve'`) }
      }
    })

    this.setOptions(options, true)
    this.$$initializing = false
  }

  /**
   * Destroys this cache and all its data and renders it unusable.
   *
   * @example
   * cache.destroy();
   *
   * @method Cache#destroy
   */
  destroy () {
    clearInterval(this.$$cacheFlushIntervalId)
    clearInterval(this.$$recycleFreqId)
    this.removeAll()
    if (this.$$storage) {
      this.$$storage().removeItem(`${this.$$prefix}.keys`)
      this.$$storage().removeItem(this.$$prefix)
    }
    this.$$storage = null
    this.$$data = null
    this.$$lruHeap = null
    this.$$expiresHeap = null
    this.$$prefix = null
    if (this.$$parent) {
      this.$$parent.caches[this.id] = undefined
    }
  }

  /**
   * Disables this cache. For a disabled cache, {@link Cache#put} is a no-op.
   *
   * @example
   * cache.disable();
   *
   * @method Cache#disable
   */
  disable () {
    this.$$enabled = false
  }

  /**
   * Enables this cache. For a disabled cache, {@link Cache#put} is a no-op.
   *
   * @example
   * cache.enable();
   *
   * @method Cache#enable
   */
  enable () {
    this.$$enabled = true
  }

  /**
   * Retrieve an item from the cache, it it exists.
   *
   * @example <caption>Retrieve an item from the cache</caption>
   * cache.put('foo', 'bar');
   * cache.get('foo'); // "bar"
   *
   * @example <caption>Retrieve a possibly expired item while in passive mode</caption>
   * import CacheFactory from 'cachefactory';
   *
   * const cacheFactory = new CacheFactory();
   * const cache = cacheFactory.createCache('my-cache', {
   *   deleteOnExpire: 'passive',
   *   maxAge: 15 * 60 * 1000
   * });
   * cache.get('foo', {
   *   // Called if "foo" is expired
   *   onExpire: function (key, value) {
   *     // Do something with key and value
   *   }
   * });
   *
   * @example <caption>Retrieve a possibly expired item while in passive mode with global onExpire callback</caption>
   * import CacheFactory from 'cachefactory';
   *
   * const cacheFactory = new CacheFactory();
   * const cache = cacheFactory.createCache('my-cache', {
   *   deleteOnExpire: 'passive',
   *   maxAge: 15 * 60 * 1000
   *   onExpire: function (key, value, done) {
   *     console.log('Expired item:', key);
   *     if (done) {
   *       done('foo', key, value);
   *     }
   *   }
   * });
   * cache.get('foo', {
   *   // Called if "foo" is expired
   *   onExpire: function (msg, key, value) {
   *     console.log(msg); // "foo"
   *     // Do something with key and value
   *   }
   * });
   *
   * @method Cache#get
   * @param {string|string[]} key The key of the item to retrieve.
   * @param {object} [options] Configuration options.
   * @param {function} [options.onExpire] TODO
   * @returns {*} The value for the specified `key`, if any.
   */
  get (key, options = {}) {
    if (Array.isArray(key)) {
      const keys = key
      const values = []

      keys.forEach((key) => {
        const value = this.get(key, options)
        if (value !== null && value !== undefined) {
          values.push(value)
        }
      })

      return values
    } else {
      if (utils.isNumber(key)) {
        key = '' + key
      }

      if (!this.enabled) {
        return
      }
    }

    if (!utils.isString(key)) {
      throw new TypeError(`"key" must be a string!`)
    } else if (!options || !utils.isObject(options)) {
      throw new TypeError(`"options" must be an object!`)
    } else if (options.onExpire && !utils.isFunction(options.onExpire)) {
      throw new TypeError(`"options.onExpire" must be a function!`)
    }

    let item

    if (this.$$storage) {
      if (this.$$promises[key]) {
        return this.$$promises[key]
      }

      const itemJson = this.$$storage().getItem(`${this.$$prefix}.data.${key}`)

      if (itemJson) {
        item = utils.fromJson(itemJson)
      }
    } else if (utils.isObject(this.$$data)) {
      item = this.$$data[key]
    }

    if (!item) {
      return
    }

    let value = item.value
    let now = new Date().getTime()

    if (this.$$storage) {
      this.$$lruHeap.remove({
        key: key,
        accessed: item.accessed
      })
      item.accessed = now
      this.$$lruHeap.push({
        key: key,
        accessed: now
      })
    } else {
      this.$$lruHeap.remove(item)
      item.accessed = now
      this.$$lruHeap.push(item)
    }

    if (this.$$deleteOnExpire === 'passive' && 'expires' in item && item.expires < now) {
      this.remove(key)

      if (this.$$onExpire) {
        this.$$onExpire(key, item.value, options.onExpire)
      } else if (options.onExpire) {
        options.onExpire.call(this, key, item.value)
      }
      value = undefined
    } else if (this.$$storage) {
      this.$$storage().setItem(`${this.$$prefix}.data.${key}`, utils.toJson(item))
    }

    return value
  }

  /**
   * Retrieve information about the whole cache or about a particular item in
   * the cache.
   *
   * @example <caption>Retrieve info about the cache</caption>
   * const info = cache.info();
   * info.id; // "my-cache"
   * info.capacity; // 100
   * info.maxAge; // 600000
   * info.deleteOnExpire; // "aggressive"
   * info.cacheFlushInterval; // null
   * info.recycleFreq; // 10000
   * info.storageMode; // "localStorage"
   * info.enabled; // false
   * info.size; // 1234
   *
   * @example <caption>Retrieve info about an item in the cache</caption>
   * const info = cache.info('foo');
   * info.created; // 1234567890
   * info.accessed; // 1234567990
   * info.expires; // 1234569999
   * info.isExpired; // false
   *
   * @method Cache#info
   * @param {string} [key] If specified, retrieve info for a particular item in
   * the cache.
   * @returns {*} The information object.
   */
  info (key) {
    if (key) {
      let item
      if (this.$$storage) {
        const itemJson = this.$$storage().getItem(`${this.$$prefix}.data.${key}`)
        if (itemJson) {
          item = utils.fromJson(itemJson)
        }
      } else if (utils.isObject(this.$$data)) {
        item = this.$$data[key]
      }
      if (item) {
        return {
          created: item.created,
          accessed: item.accessed,
          expires: item.expires,
          isExpired: (new Date().getTime() - item.created) > (item.maxAge || this.$$maxAge)
        }
      }
    } else {
      return {
        id: this.id,
        capacity: this.capacity,
        maxAge: this.maxAge,
        deleteOnExpire: this.deleteOnExpire,
        onExpire: this.onExpire,
        cacheFlushInterval: this.cacheFlushInterval,
        recycleFreq: this.recycleFreq,
        storageMode: this.storageMode,
        storageImpl: this.$$storage ? this.$$storage() : undefined,
        enabled: this.enabled,
        size: this.$$lruHeap && this.$$lruHeap.size() || 0
      }
    }
  }

  /**
   * Retrieve a list of the keys of items currently in the cache.
   *
   * @example
   * const keys = cache.keys();
   *
   * @method Cache#keys
   * @returns {string[]} The keys of the items in the cache
   */
  keys () {
    if (this.$$storage) {
      const keysJson = this.$$storage().getItem(`${this.$$prefix}.keys`)

      if (keysJson) {
        return utils.fromJson(keysJson)
      } else {
        return []
      }
    } else {
      return Object.keys(this.$$data).filter((key) => this.$$data[key])
    }
  }

  /**
   * Retrieve an object of the keys of items currently in the cache.
   *
   * @example
   * const keySet = cache.keySet();
   *
   * @method Cache#keySet
   * @returns {object} The keys of the items in the cache.
   */
  keySet () {
    const set = {}
    this.keys().forEach((key) => {
      set[key] = key
    })
    return set
  }

  /**
   * Insert an item into the cache.
   *
   * @example
   * const inserted = cache.put('foo', 'bar');
   *
   * @method Cache#put
   * @param {string} key The key under which to insert the item.
   * @param {*} value The value to insert.
   * @param {object} [options] Configuration options.
   * @param {boolean} [options.storeOnReject] See {@link Cache#storeOnReject}.
   * @param {boolean} [options.storeOnResolve] See {@link Cache#storeOnResolve}.
   * @returns {*} The inserted value.
   */
  put (key, value, options = {}) {
    const storeOnResolve = options.storeOnResolve !== undefined ? !!options.storeOnResolve : this.$$storeOnResolve
    const storeOnReject = options.storeOnReject !== undefined ? !!options.storeOnReject : this.$$storeOnReject

    const getHandler = (shouldStore, isError) => {
      return (v) => {
        if (shouldStore) {
          this.$$promises[key] = undefined
          if (utils.isObject(v) && 'status' in v && 'data' in v) {
            v = [v.status, v.data, v.headers(), v.statusText]
            this.put(key, v)
          } else {
            this.put(key, v)
          }
        }
        if (isError) {
          if (utils.Promise) {
            return utils.Promise.reject(v)
          } else {
            throw v
          }
        } else {
          return v
        }
      }
    }

    if (!this.$$enabled || !utils.isObject(this.$$data) || value === null || value === undefined) {
      return
    }
    if (utils.isNumber(key)) {
      key = '' + key
    }

    if (!utils.isString(key)) {
      throw new TypeError(`"key" must be a string!`)
    }

    const now = new Date().getTime()
    const item = {
      key: key,
      value: utils.isPromise(value) ? value.then(getHandler(storeOnResolve, false), getHandler(storeOnReject, true)) : value,
      created: options.created === undefined ? now : options.created,
      accessed: options.accessed === undefined ? now : options.accessed
    }
    if (utils.isNumber(options.maxAge)) {
      item.maxAge = options.maxAge
    }

    if (options.expires === undefined) {
      item.expires = item.created + (item.maxAge || this.$$maxAge)
    } else {
      item.expires = options.expires
    }

    if (this.$$storage) {
      if (utils.isPromise(item.value)) {
        this.$$promises[key] = item.value
        return this.$$promises[key]
      }
      const keysJson = this.$$storage().getItem(`${this.$$prefix}.keys`)
      const keys = keysJson ? utils.fromJson(keysJson) : []
      const itemJson = this.$$storage().getItem(`${this.$$prefix}.data.${key}`)

      // Remove existing
      if (itemJson) {
        this.remove(key)
      }
      // Add to expires heap
      this.$$expiresHeap.push({
        key: key,
        expires: item.expires
      })
      // Add to lru heap
      this.$$lruHeap.push({
        key: key,
        accessed: item.accessed
      })
      // Set item
      this.$$storage().setItem(`${this.$$prefix}.data.${key}`, utils.toJson(item))
      let exists = false
      keys.forEach((_key) => {
        if (_key === key) {
          exists = true
          return false
        }
      })
      if (!exists) {
        keys.push(key)
      }
      this.$$storage().setItem(`${this.$$prefix}.keys`, utils.toJson(keys))
    } else {
      // Remove existing
      if (this.$$data[key]) {
        this.remove(key)
      }
      // Add to expires heap
      this.$$expiresHeap.push(item)
      // Add to lru heap
      this.$$lruHeap.push(item)
      // Set item
      this.$$data[key] = item
      this.$$promises[key] = undefined
    }

    // Handle exceeded capacity
    if (this.$$lruHeap.size() > this.$$capacity) {
      this.remove(this.$$lruHeap.peek().key)
    }

    return value
  }

  /**
   * Remove an item from the cache.
   *
   * @example
   * const removed = cache.remove('foo');
   *
   * @method Cache#remove
   * @param {string} key The key of the item to remove.
   * @returns {*} The value of the removed item, if any.
   */
  remove (key) {
    if (utils.isNumber(key)) {
      key = '' + key
    }
    this.$$promises[key] = undefined
    if (this.$$storage) {
      const itemJson = this.$$storage().getItem(`${this.$$prefix}.data.${key}`)

      if (itemJson) {
        let item = utils.fromJson(itemJson)
        this.$$lruHeap.remove({
          key: key,
          accessed: item.accessed
        })
        this.$$expiresHeap.remove({
          key: key,
          expires: item.expires
        })
        this.$$storage().removeItem(`${this.$$prefix}.data.${key}`)
        let keysJson = this.$$storage().getItem(`${this.$$prefix}.keys`)
        let keys = keysJson ? utils.fromJson(keysJson) : []
        let index = keys.indexOf(key)

        if (index >= 0) {
          keys.splice(index, 1)
        }
        this.$$storage().setItem(`${this.$$prefix}.keys`, utils.toJson(keys))
        return item.value
      }
    } else if (utils.isObject(this.$$data)) {
      let value = this.$$data[key] ? this.$$data[key].value : undefined
      this.$$lruHeap.remove(this.$$data[key])
      this.$$expiresHeap.remove(this.$$data[key])
      this.$$data[key] = undefined
      return value
    }
  }

  /**
   * Remove all items from the cache.
   *
   * @example
   * cache.removeAll();
   *
   * @method Cache#removeAll
   */
  removeAll () {
    const storage = this.$$storage
    const keys = this.keys()
    this.$$lruHeap.removeAll()
    this.$$expiresHeap.removeAll()

    if (storage) {
      storage().setItem(`${this.$$prefix}.keys`, utils.toJson([]))
      keys.forEach((key) => {
        storage().removeItem(`${this.$$prefix}.data.${key}`)
      })
    } else if (utils.isObject(this.$$data)) {
      this.$$data = {}
    }
    this.$$promises = {}
  }

  /**
   * Remove expired items from the cache, if any.
   *
   * @example
   * const expiredItems = cache.removeExpired();
   *
   * @method Cache#removeExpired
   * @returns {object} The expired items, if any.
   */
  removeExpired () {
    const now = new Date().getTime()
    const expired = {}
    let expiredItem

    while ((expiredItem = this.$$expiresHeap.peek()) && expiredItem.expires <= now) {
      expired[expiredItem.key] = expiredItem.value ? expiredItem.value : null
      this.$$expiresHeap.pop()
    }

    Object.keys(expired).forEach((key) => {
      this.remove(key)
    })

    if (this.$$onExpire) {
      Object.keys(expired).forEach((key) => {
        this.$$onExpire(key, expired[key])
      })
    }

    return expired
  }

  /**
   * Update the {@link Cache#cacheFlushInterval} for the cache. Pass in `null`
   * to disable the interval.
   *
   * @example
   * cache.setCacheFlushInterval(60 * 60 * 1000);
   *
   * @method Cache#setCacheFlushInterval
   * @param {number|null} cacheFlushInterval The new {@link Cache#cacheFlushInterval}.
   */
  setCacheFlushInterval (cacheFlushInterval) {
    if (cacheFlushInterval === null) {
      this.$$cacheFlushInterval = null
    } else if (!utils.isNumber(cacheFlushInterval)) {
      throw new TypeError(`"cacheFlushInterval" must be a number!`)
    } else if (cacheFlushInterval <= 0) {
      throw new Error(`"cacheFlushInterval" must be greater than zero!`)
    }
    this.$$cacheFlushInterval = cacheFlushInterval
    clearInterval(this.$$cacheFlushIntervalId)
    this.$$cacheFlushIntervalId = undefined
    if (this.$$cacheFlushInterval) {
      this.$$cacheFlushIntervalId = setInterval(() => this.removeAll(), this.$$cacheFlushInterval)
    }
  }

  /**
   * Update the {@link Cache#capacity} for the cache. Pass in `null` to reset
   * to `Number.MAX_VALUE`.
   *
   * @example
   * cache.setCapacity(1000);
   *
   * @method Cache#setCapacity
   * @param {number|null} capacity The new {@link Cache#capacity}.
   */
  setCapacity (capacity) {
    if (capacity === null) {
      this.$$capacity = Number.MAX_VALUE
    } else if (!utils.isNumber(capacity)) {
      throw new TypeError(`"capacity" must be a number!`)
    } else if (capacity <= 0) {
      throw new Error(`"capacity" must be greater than zero!`)
    } else {
      this.$$capacity = capacity
    }
    const removed = {}
    while (this.$$lruHeap.size() > this.$$capacity) {
      removed[this.$$lruHeap.peek().key] = this.remove(this.$$lruHeap.peek().key)
    }
    return removed
  }

  /**
   * Update the {@link Cache#deleteOnExpire} for the cache. Pass in `null` to
   * reset to `"none"`.
   *
   * @example
   * cache.setDeleteOnExpire('passive');
   *
   * @method Cache#setDeleteOnExpire
   * @param {string|null} deleteOnExpire The new {@link Cache#deleteOnExpire}.
   */
  setDeleteOnExpire (deleteOnExpire, setRecycleFreq) {
    if (deleteOnExpire === null) {
      deleteOnExpire = 'none'
    } else if (!utils.isString(deleteOnExpire)) {
      throw new TypeError(`"deleteOnExpire" must be a string!`)
    } else if (deleteOnExpire !== 'none' && deleteOnExpire !== 'passive' && deleteOnExpire !== 'aggressive') {
      throw new Error(`"deleteOnExpire" must be "none", "passive" or "aggressive"!`)
    }
    this.$$deleteOnExpire = deleteOnExpire
    if (setRecycleFreq !== false) {
      this.setRecycleFreq(this.$$recycleFreq)
    }
  }

  /**
   * Update the {@link Cache#maxAge} for the cache. Pass in `null` to reset to
   * to `Number.MAX_VALUE`.
   *
   * @example
   * cache.setMaxAge(60 * 60 * 1000);
   *
   * @method Cache#setMaxAge
   * @param {number|null} maxAge The new {@link Cache#maxAge}.
   */
  setMaxAge (maxAge) {
    if (maxAge === null) {
      this.$$maxAge = Number.MAX_VALUE
    } else if (!utils.isNumber(maxAge)) {
      throw new TypeError(`"maxAge" must be a number!`)
    } else if (maxAge <= 0) {
      throw new Error(`"maxAge" must be greater than zero!`)
    } else {
      this.$$maxAge = maxAge
    }
    const keys = this.keys()

    this.$$expiresHeap.removeAll()

    if (this.$$storage) {
      keys.forEach((key) => {
        const itemJson = this.$$storage().getItem(`${this.$$prefix}.data.${key}`)
        if (itemJson) {
          const item = utils.fromJson(itemJson)
          if (this.$$maxAge === Number.MAX_VALUE) {
            item.expires = Number.MAX_VALUE
          } else {
            item.expires = item.created + (item.maxAge || this.$$maxAge)
          }
          this.$$expiresHeap.push({
            key: key,
            expires: item.expires
          })
        }
      })
    } else {
      keys.forEach((key) => {
        const item = this.$$data[key]
        if (item) {
          if (this.$$maxAge === Number.MAX_VALUE) {
            item.expires = Number.MAX_VALUE
          } else {
            item.expires = item.created + (item.maxAge || this.$$maxAge)
          }
          this.$$expiresHeap.push(item)
        }
      })
    }

    if (this.$$deleteOnExpire === 'aggressive') {
      return this.removeExpired()
    } else {
      return {}
    }
  }

  /**
   * Update the {@link Cache#onExpire} for the cache. Pass in `null` to unset
   * the global `onExpire` callback of the cache.
   *
   * @example
   * cache.setOnExpire(function (key, value, done) {
   *   // Do something
   * });
   *
   * @method Cache#setOnExpire
   * @param {function|null} onExpire The new {@link Cache#onExpire}.
   */
  setOnExpire (onExpire) {
    if (onExpire === null) {
      this.$$onExpire = null
    } else if (!utils.isFunction(onExpire)) {
      throw new TypeError(`"onExpire" must be a function!`)
    } else {
      this.$$onExpire = onExpire
    }
  }

  /**
   * Update multiple cache options at a time.
   *
   * @example
   * cache.setOptions({
   *   maxAge: 60 * 60 * 1000,
   *   deleteOnExpire: 'aggressive'
   * });
   *
   * @example <caption>Set two options, and reset the rest to the configured defaults</caption>
   * cache.setOptions({
   *   maxAge: 60 * 60 * 1000,
   *   deleteOnExpire: 'aggressive'
   * }, true);
   *
   * @method Cache#setOptions
   * @param {object} options The options to set.
   * @param {boolean} [strict] Reset options not passed to `options` to the
   * configured defaults.
   */
  setOptions (options = {}, strict = false) {
    if (!utils.isObject(options)) {
      throw new TypeError(`"options" must be an object!`)
    }

    if (options.storagePrefix !== undefined) {
      this.$$storagePrefix = options.storagePrefix
    } else if (strict) {
      this.$$storagePrefix = defaults.storagePrefix
    }

    this.$$prefix = this.$$storagePrefix + this.id

    if (options.enabled !== undefined) {
      this.$$enabled = !!options.enabled
    } else if (strict) {
      this.$$enabled = defaults.enabled
    }

    if (options.deleteOnExpire !== undefined) {
      this.setDeleteOnExpire(options.deleteOnExpire, false)
    } else if (strict) {
      this.setDeleteOnExpire(defaults.deleteOnExpire, false)
    }

    if (options.recycleFreq !== undefined) {
      this.setRecycleFreq(options.recycleFreq)
    } else if (strict) {
      this.setRecycleFreq(defaults.recycleFreq)
    }

    if (options.maxAge !== undefined) {
      this.setMaxAge(options.maxAge)
    } else if (strict) {
      this.setMaxAge(defaults.maxAge)
    }

    if (options.storeOnResolve !== undefined) {
      this.$$storeOnResolve = !!options.storeOnResolve
    } else if (strict) {
      this.$$storeOnResolve = defaults.storeOnResolve
    }

    if (options.storeOnReject !== undefined) {
      this.$$storeOnReject = !!options.storeOnReject
    } else if (strict) {
      this.$$storeOnReject = defaults.storeOnReject
    }

    if (options.capacity !== undefined) {
      this.setCapacity(options.capacity)
    } else if (strict) {
      this.setCapacity(defaults.capacity)
    }

    if (options.cacheFlushInterval !== undefined) {
      this.setCacheFlushInterval(options.cacheFlushInterval)
    } else if (strict) {
      this.setCacheFlushInterval(defaults.cacheFlushInterval)
    }

    if (options.onExpire !== undefined) {
      this.setOnExpire(options.onExpire)
    } else if (strict) {
      this.setOnExpire(defaults.onExpire)
    }

    if (options.storageMode !== undefined || options.storageImpl !== undefined) {
      this.setStorageMode(options.storageMode || defaults.storageMode, options.storageImpl || defaults.storageImpl)
    } else if (strict) {
      this.setStorageMode(defaults.storageMode, defaults.storageImpl)
    }
  }

  /**
   * Update the {@link Cache#recycleFreq} for the cache. Pass in `null` to
   * disable the interval.
   *
   * @example
   * cache.setRecycleFreq(10000);
   *
   * @method Cache#setRecycleFreq
   * @param {number|null} recycleFreq The new {@link Cache#recycleFreq}.
   */
  setRecycleFreq (recycleFreq) {
    if (recycleFreq === null) {
      this.$$recycleFreq = null
    } else if (!utils.isNumber(recycleFreq)) {
      throw new TypeError(`"recycleFreq" must be a number!`)
    } else if (recycleFreq <= 0) {
      throw new Error(`"recycleFreq" must be greater than zero!`)
    } else {
      this.$$recycleFreq = recycleFreq
    }
    clearInterval(this.$$recycleFreqId)
    if (this.$$deleteOnExpire === 'aggressive' && this.$$recycleFreq) {
      this.$$recycleFreqId = setInterval(() => this.removeExpired(), this.$$recycleFreq)
    } else {
      this.$$recycleFreqId = undefined
    }
  }

  /**
   * Update the {@link Cache#storageMode} for the cache.
   *
   * @method Cache#setStorageMode
   * @param {string} storageMode The new {@link Cache#storageMode}.
   * @param {object} storageImpl The new {@link Cache~StorageImpl}.
   */
  setStorageMode (storageMode, storageImpl) {
    if (!utils.isString(storageMode)) {
      throw new TypeError(`"storageMode" must be a string!`)
    } else if (storageMode !== 'memory' && storageMode !== 'localStorage' && storageMode !== 'sessionStorage') {
      throw new Error(`"storageMode" must be "memory", "localStorage", or "sessionStorage"!`)
    }

    const prevStorage = this.$$storage
    const prevData = this.$$data
    let shouldReInsert = false
    let items = {}

    const load = (prevStorage, prevData) => {
      const keys = this.keys()
      const prevDataIsObject = utils.isObject(prevData)
      keys.forEach((key) => {
        if (prevStorage) {
          const itemJson = prevStorage().getItem(`${this.$$prefix}.data.${key}`)
          if (itemJson) {
            items[key] = utils.fromJson(itemJson)
          }
        } else if (prevDataIsObject) {
          items[key] = prevData[key]
        }
        this.remove(key)
        shouldReInsert || (shouldReInsert = true)
      })
    }

    if (!this.$$initializing) {
      load(prevStorage, prevData)
    }

    this.$$storageMode = storageMode

    if (storageImpl) {
      if (!utils.isObject(storageImpl)) {
        throw new TypeError(`"storageImpl" must be an object!`)
      } else if (typeof storageImpl.setItem !== 'function') {
        throw new Error(`"storageImpl" must implement "setItem(key, value)"!`)
      } else if (typeof storageImpl.getItem !== 'function') {
        throw new Error(`"storageImpl" must implement "getItem(key)"!`)
      } else if (typeof storageImpl.removeItem !== 'function') {
        throw new Error(`"storageImpl" must implement "removeItem(key)"!`)
      }
      this.$$storage = () => storageImpl
    } else if (this.$$storageMode === 'localStorage') {
      try {
        localStorage.setItem('cachefactory', 'cachefactory')
        localStorage.removeItem('cachefactory')
        this.$$storage = () => localStorage
      } catch (e) {
        this.$$storage = null
        this.$$storageMode = 'memory'
      }
    } else if (this.$$storageMode === 'sessionStorage') {
      try {
        sessionStorage.setItem('cachefactory', 'cachefactory')
        sessionStorage.removeItem('cachefactory')
        this.$$storage = () => sessionStorage
      } catch (e) {
        this.$$storage = null
        this.$$storageMode = 'memory'
      }
    } else {
      this.$$storage = null
      this.$$storageMode = 'memory'
    }

    if (this.$$initializing) {
      load(this.$$storage, this.$$data)
    }

    if (shouldReInsert) {
      Object.keys(items).forEach((key) => {
        const item = items[key]
        this.put(key, item.value, {
          created: item.created,
          accessed: item.accessed,
          expires: item.expires
        })
      })
    }
  }

  /**
   * Reset an item's age in the cache, or if `key` is unspecified, touch all
   * items in the cache.
   *
   * @example
   * cache.touch('foo');
   *
   * @method Cache#touch
   * @param {string} [key] The key of the item to touch.
   * @param {object} [options] Options to pass to {@link Cache#put} if
   * necessary.
   */
  touch (key, options) {
    if (key) {
      const val = this.get(key, {
        onExpire: (k, v) => this.put(k, v)
      })
      if (val) {
        this.put(key, val, options)
      }
    } else {
      const keys = this.keys()
      for (var i = 0; i < keys.length; i++) {
        this.touch(keys[i], options)
      }
    }
  }

  /**
   * Retrieve the values of all items in the cache.
   *
   * @example
   * const values = cache.values();
   *
   * @method Cache#values
   * @returns {array} The values of the items in the cache.
   */
  values () {
    return this.keys().map((key) => this.get(key))
  }
}