managers/BaseCacheManager.js

import hashjs from "hash.js";
import { GLUON_VERSION, NAME, TO_JSON_TYPES_ENUM } from "../constants.js";
import Client from "../Client.js";

/**
 * The base cache manager for all cache managers.
 */
class BaseCacheManager {
  #cache;
  #expiryBucket;
  #structureType;
  static rules = {};

  /**
   * Creates a cache manager.
   * @param {Client} client The client instance.
   * @param {Object} options The options for the cache manager.
   * @param {Object} options.structureType The structure type for the cache manager.
   * @throws {TypeError}
   * @public
   * @constructor
   */
  constructor(client, { structureType } = {}) {
    if (!(client instanceof Client))
      throw new TypeError("GLUON: Client must be an instance of Client.");
    if (!structureType)
      throw new TypeError("GLUON: Structure type must be provided.");

    /**
     * The cache for this manager.
     * @type {Map<String, Object>}
     * @private
     */
    this.#cache = new Map();

    /**
     * The expiry bucket for this manager.
     * @type {Map<String, Set<String>>}
     * @private
     */
    this.#expiryBucket = new Map();

    /**
     * The structure type for this manager.
     * @type {Object}
     * @private
     */
    this.#structureType = structureType;
  }

  /**
   * The key prefix for the cache.
   * @type {String}
   * @readonly
   * @private
   */
  get #keyPrefix() {
    return `${NAME.toLowerCase()}.caches.${this.#structureType.identifier}.v${GLUON_VERSION.split(".").slice(0, -1).join("_")}.`;
  }

  /**
   * Creates a hash of the key.
   * @param {String} key The key to hash.
   * @returns {String} The hashed key.
   * @private
   * @method
   */
  #getHash(key) {
    return hashjs.sha256().update(key).digest("hex");
  }

  /**
   * Wraps the key with the key prefix and hashes it.
   * @param {String} key The key.
   * @returns {String}
   * @private
   * @method
   */
  #getKey(key) {
    return `${this.#keyPrefix}${this.#getHash(key)}`;
  }

  /**
   * Gets a value from the cache.
   * @param {String} key The key to get.
   * @param {Object} options The options for the get method.
   * @param {Boolean} options.useRules Whether to use rules or not.
   * @returns {Object?} The value from the cache.
   * @public
   * @method
   * @throws {TypeError}
   */
  get(key, { useRules = false } = { useRules: false }) {
    if (typeof key !== "string")
      throw new TypeError("GLUON: Key must be a string.");
    const value = this.#cache.get(key);
    if (value) return value;
    else if (useRules) return this.#_callFetches(key);
    else return null;
  }

  /**
   * Sets a value in the cache.
   * @param {String} key The key to set.
   * @param {Object} value The value to set.
   * @param {Number} expiry The expiry time in seconds.
   * @returns {Object} The value that was set.
   * @public
   * @method
   * @throws {TypeError}
   */
  set(key, value, expiry = 0) {
    if (typeof key !== "string")
      throw new TypeError("GLUON: Key must be a string.");
    if (typeof expiry !== "number")
      throw new TypeError("GLUON: Expiry must be a number.");
    this.#addToExpiryBucket(key, expiry);
    return this.#cache.set(key, value);
  }

  /**
   * Adds a key to the expiry bucket.
   * @param {String} key The key to add to the expiry bucket.
   * @param {Number} expiry The expiry time in seconds.
   * @returns {void}
   * @public
   * @method
   */
  #addToExpiryBucket(key, expiry) {
    if (expiry === 0) return;
    const expiryDate = new Date(Date.now() + expiry * 1000);
    const bucket = `${expiryDate.getUTCDate()}_${expiryDate.getUTCHours()}_${expiryDate.getUTCMinutes()}`;
    if (!this.#expiryBucket.has(bucket))
      this.#expiryBucket.set(bucket, new Set());
    this.#expiryBucket.get(bucket).add(key);
  }

  /**
   * Expires a bucket.
   * @param {String} bucket The bucket to expire.
   * @returns {void}
   * @public
   * @method
   */
  expireBucket(bucket) {
    if (!this.#expiryBucket.has(bucket)) return;
    for (const key of this.#expiryBucket.get(bucket)) {
      try {
        const value = this.get(key);
        if (value) this.#_callRules(value);
      } catch (e) {
        console.error(e);
      }
      this.delete(key);
    }
    this.#expiryBucket.delete(bucket);
  }

  /**
   * Clears stale buckets.
   * @returns {void}
   * @public
   * @method
   */
  #clearStaleBuckets() {
    const now = new Date();
    const buckets = [...this.#expiryBucket.keys()];
    for (const bucket of buckets) {
      const [date, hour, minute] = bucket.split("_").map(Number);
      if (now.getUTCDate() > date) {
        this.expireBucket(bucket);
        continue;
      } else if (now.getUTCDate() === date && now.getUTCHours() > hour) {
        this.expireBucket(bucket);
        continue;
      } else if (
        now.getUTCDate() === date &&
        now.getUTCHours() === hour &&
        now.getUTCMinutes() > minute
      ) {
        this.expireBucket(bucket);
        continue;
      }
    }
  }

  /**
   * Deletes a key from the cache.
   * @param {String} key The key to delete.
   * @returns {Boolean} Whether the key was deleted or not.
   * @public
   * @method
   */
  delete(key) {
    if (typeof key !== "string")
      throw new TypeError("GLUON: Key must be a string.");
    return this.#cache.delete(key);
  }

  /**
   * Clears the cache.
   * @returns {void}
   * @public
   * @method
   */
  clear() {
    return this.#cache.clear();
  }

  /**
   * The callback for expiring buckets.
   * @returns {void}
   * @public
   * @method
   */
  _intervalCallback() {
    const now = new Date();
    const bucket = `${now.getUTCDate()}_${now.getUTCHours()}_${now.getUTCMinutes()}`;
    this.expireBucket(bucket);
    if (now.getUTCMinutes() === 0) this.#clearStaleBuckets();
    return {
      i: this.#structureType.identifier,
    };
  }

  /**
   * Calls the rules on a value.
   * @param {Object} value The value to call the rules on.
   * @returns {void}
   * @public
   * @method
   */
  #_callRules(value) {
    const rules = Object.values(BaseCacheManager.rules);
    for (const rule of rules)
      if (rule.structure === this.#structureType) rule.store(value);
  }

  /**
   * Calls all the custom fetches.
   * @param {String} id The ID to fetch.
   * @returns {Object}
   * @public
   * @method
   * @async
   */
  async #_callFetches(id) {
    const rules = Object.values(BaseCacheManager.rules);
    let fetchValue;
    for (const rule of rules) {
      if (rule.structure === this.#structureType)
        fetchValue = await rule.retrieve(id, this);
      if (fetchValue) return fetchValue;
    }
    return null;
  }

  /**
   * Returns the size of the cache.
   * @type {Number}
   * @readonly
   * @public
   */
  get size() {
    return this.#cache.size;
  }

  /**
   * Calls a function on each item in the cache.
   * @param {Function} callback Callback function to run on each item in the cache.
   * @returns {void}
   * @public
   * @method
   */
  forEach(callback) {
    return this.#cache.forEach(callback);
  }

  /**
   * Returns the JSON representation of this structure.
   * @param {Number} format The format to return the data in.
   * @returns {Object}
   * @public
   * @method
   */
  toJSON(format) {
    switch (format) {
      case TO_JSON_TYPES_ENUM.STORAGE_FORMAT:
      case TO_JSON_TYPES_ENUM.CACHE_FORMAT:
      case TO_JSON_TYPES_ENUM.DISCORD_FORMAT:
      default: {
        return [...this.#cache.values()];
      }
    }
  }
}

export default BaseCacheManager;