Home Manual Reference Source

src/ResourceCache.js

/*
 * BSD 3-Clause License
 *
 * Copyright (c) 2018, MapCreator
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 *
 *  Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 *  Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import mitt from 'mitt';
import Unobservable from './utils/Unobservable';
import Uuid from './utils/uuid';

/**
 * Used for caching resources. Requires the resource to have an unique id field
 * @see {@link PaginatedResourceWrapper}
 * @deprecated
 * @todo Add periodic data refreshing while idle, most likely implemented in cache (maybe v1/resource?timestamp=123 where it will give modified records since)
 */
export default class ResourceCache extends Unobservable {
  constructor (cacheTime, dereference) {
    super();

    this.cacheTime = cacheTime;
    this.dereference = dereference;
    this.emitter = mitt();

    this._storage = {};
  }

  /**
   * Push a page into the cache
   * @param {PaginatedResourceListing} page - Data to be cached
   */
  push (page) {
    if (page.rows === 0) {
      return; // Don't insert empty pages
    }

    delete page.__ob__; // Remove VueJs observer

    // Test if this is data we can actually work with by testing if there are any non-numeric ids (undefined etc)
    const invalidData = page.data.map(row => row.id).filter(x => typeof x !== 'number').length > 0;

    if (invalidData) {
      throw new TypeError('Missing or invalid row.id for page.data. Data rows must to contain a numeric "id" field.');
    }

    const validThrough = this._timestamp + this.cacheTime;

    const cacheId = Uuid.uuid4();

    const data = {
      page, validThrough,
      id: cacheId,
      timeout: setTimeout(
        () => this._deleteCacheIds(cacheId),
        this.cacheTime * 1000,
      ),
    };

    const storage = this._storage[page.route] || (this._storage[page.route] = {});

    (storage[page.cacheToken] || (storage[page.cacheToken] = [])).push(data);

    this.emitter.emit('push', { page, validThrough, resourceUrl: page.route });
    this.emitter.emit('invalidate', { resourceUrl: page.route });
  }

  /**
   * Delete from cache using cacheId
   * @param {String|Array<String>} ids - cache ids
   */
  _deleteCacheIds (ids) {
    if (!(ids instanceof Array)) {
      this._deleteCacheIds([ids]);

      return;
    }

    let found = 0;

    for (const resourceUrl of Object.keys(this._storage)) {
      for (const token of Object.keys(this._storage[resourceUrl])) {
        const entries = this._storage[resourceUrl][token];

        for (let i = 0; i < entries.length; i++) {
          if (ids.includes(entries[i].id)) {
            entries.splice(i, 1);
            i--;
            found++;

            if (found === ids.length) {
              return;
            }
          }
        }
      }
    }
  }

  /**
   * Revalidate all data and delete stale data
   * @param {String} resourceUrl - Resource url
   */
  revalidate (resourceUrl = null) {
    if (!resourceUrl) {
      Object.keys(this._storage).map(x => this.revalidate(x));
    } else if (this._storage[resourceUrl]) {
      const storage = this._storage[resourceUrl];

      // Remove old data from the cache and stop old timeouts
      Object.keys(storage).forEach(key => {
        storage[key]
          .filter(row => row.validThrough < this._timestamp)
          .forEach(row => clearTimeout(row.timeout));

        storage[key] = storage[key].filter(row => row.validThrough >= this._timestamp);
      });

      const junk = Object.keys(storage).filter(key => storage[key].length === 0);

      // Delete empty
      junk.forEach(key => delete storage[key]);

      if (Object.keys(storage).length === 0) {
        delete this._storage[resourceUrl];
      }

      if (junk.length > 0) {
        this.emitter.emit('invalidate', { resourceUrl });
      }
    }
  }

  /**
   * Collect relevant cached pages
   * @param {String} resourceUrl - resource url
   * @param {String} cacheToken - Cache token
   * @see {@link PaginatedResourceListing#cacheToken}
   * @returns {Array<PaginatedResourceListing>} - Relevant cached pages
   */
  collectPages (resourceUrl, cacheToken = '') {
    cacheToken = cacheToken.toLowerCase();

    // Storage array or []
    const storage = (this._storage[resourceUrl] || {})[cacheToken] || [];

    // Sort by validThrough and extract pages
    // SORT BY page, validThrough ASCENDING
    return storage.sort((a, b) => {
      if (a.page === b.page) {
        return a.validThrough - b.validThrough;
      }

      return a.page - b.page;
    });
  }

  /**
   * Clears the cache
   * @param {String} resourceUrl - Resource url
   */
  clear (resourceUrl = '') {
    if (resourceUrl) {
      delete this._storage[resourceUrl];
      this.emitter.emit('invalidate', { resourceUrl });
    } else {
      Object.keys(this._storage).forEach(url => {
        this.emitter.emit('invalidate', { resourceUrl: url });
      });

      this._storage = {};
    }
  }

  /**
   * Resolve cache and return indexed data
   * @param {String} resourceUrl - Resource url
   * @param {String} cacheToken - Cache token
   * @see {@link PaginatedResourceListing#cacheToken}
   * @returns {Array<ResourceBase>} - Indexed relevant data
   * @todo add page numbers or range as optional parameter
   */
  resolve (resourceUrl, cacheToken = '') {
    cacheToken = cacheToken.toLowerCase();

    // List ordered from old to new
    const data = this.collectPages(resourceUrl, cacheToken);
    const out = [];

    let lastPage;
    let startIndex = 0;

    for (const row of data) {
      const page = row.page;

      // Skip empty pages
      if (page.data.length === 0) {
        continue;
      }

      // Have we parsed the same page already?
      if (typeof lastPage !== 'undefined' && lastPage === page.page) {
        let ii;

        for (let i = 0; i < page.data.length; i++) {
          ii = i + startIndex; // Get relative index for `out`

          if (typeof out[ii] === 'undefined') {
            out.push(page.data[i]); // Push if there is no data
          } else if (page.data[i].id !== out[ii].id) {
            out[ii] = page.data[i];

            // lookbehind
            for (let j = 0; j < startIndex; j++) {
              if (out[j].id === out[ii].id) {
                out.splice(j, 1);

                startIndex--;
                i--;
                ii--;
                j--;
              }
            }
          }
        }

        // Remove trailing data
        if (typeof ii !== 'undefined') {
          out.splice(ii + 1, out.length);
        }
      } else {
        // First time page number is parsed, just append it.
        startIndex = out.length;

        page.data.map(x => out.push(x));
      }

      lastPage = row.page.page;
    }

    if (this.dereference) {
      return out.map(x => x.clone());
    }

    return out;
  }

  /**
   * Update records in the cache manually lazily. Any matching instance found will be updated.
   * @param {ResourceBase|Array<ResourceBase>} rows - Data to be updated
   */
  update (rows) {
    if (!(rows instanceof Array)) {
      this.update([rows]);

      return;
    }

    // Split up data into types
    const data = {};

    const ids = {};

    for (const row of rows) {
      const key = row.constructor.name;

      (data[key] || (data[key] = [])).push(row);
      (ids[key] || (ids[key] = [])).push(row.id);
    }

    const models = Object.keys(data);

    for (const resourceUrl of Object.keys(this._storage)) {
      let invalidate = false;

      for (const token of Object.keys(this._storage[resourceUrl])) {
        const entries = this._storage[resourceUrl][token];

        for (const entry of entries) {
          const page = entry.page;

          if (page.data.length === 0) {
            continue;
          }

          const key = page.data[0].constructor.name;

          if (!models.includes(key)) {
            break;
          }

          for (const row of page.data) {
            if (!ids[key].includes(row.id)) {
              continue;
            }

            const index = ids[key].findIndex(x => x === row.id);

            const value = data[key][index];

            value.sanitize();

            value.fieldNames.forEach(x => {
              row[x] = value[x];
            });

            invalidate = true;
          }
        }
      }

      if (invalidate) {
        this.emitter.emit('invalidate', { resourceUrl });
      }
    }
  }

  /**
   * Get a usable timestamp
   * @returns {number} - timestamp
   * @private
   */
  get _timestamp () {
    return Math.floor(Date.now() / 1000);
  }
}