modules/ObjectStore.js

import {Enforce} from '@classroomtechtools/enforce_arguments';

const _config_ = Symbol('config');
function configure(config) {
  config = config || {jsons:true};
  config.jsons = config.jsons == undefined ? true : config.jsons;
  config.dates = config.dates == undefined ? false : config.dates;
  config.manual = config.manual || false;
  config.expiry = config.expiry || 600;  // 10 minutes by default
  // can pass in "max" string
  if (config.expiry == 'max') config.expiry = 21600;
  if (config.dates && !config.jsons) throw TypeError("jsons needs to be true for dates: true to be meaningful");
  if (Object.keys(config).length > 4) throw TypeError(`Unknown property: ${Object.keys(config)}`);
  return config;
}

const datePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

class Utils {

  static isSerializedDate(dateValue) {
    // Dates are serialized in TZ format, example: '1981-12-20T04:00:14.000Z'.
    return Utils.isString(dateValue) && datePattern.test(dateValue);
  }

  static isString(value) {
    return typeof value === 'string' || value instanceof String;
  }

  static dateReviver(key, value) {
    if (Utils.isSerializedDate(value)) {
      return new Date(value);
    }
    return value;
  }

  static dateReplacer(key, value) {
    if (value instanceof Date) {
      const timezoneOffsetInHours = -(this.getTimezoneOffset() / 60); //UTC minus local time
      const sign = timezoneOffsetInHours >= 0 ? '+' : '-';
      const leadingZero = (Math.abs(timezoneOffsetInHours) < 10) ? '0' : '';

      //It's a bit unfortunate that we need to construct a new Date instance
      //(we don't want _this_ Date instance to be modified)
      let correctedDate = new Date(this.getFullYear(), this.getMonth(),
          this.getDate(), this.getHours(), this.getMinutes(), this.getSeconds(),
          this.getMilliseconds());
      correctedDate.setHours(this.getHours() + timezoneOffsetInHours);
      const iso = correctedDate.toISOString().replace('Z', '');

      return iso + sign + leadingZero + Math.abs(timezoneOffsetInHours).toString() + ':00';
    }
    return value;
  }

  static serialize(value, dates=true) {
    // return JSON.stringify(value, Utils.dateReplacer);
    if (!dates && Utils.isString(value))
      return value;
    return JSON.stringify(value);
  }

  static deserialize(value, dates=true) {
    if (dates)
      return JSON.parse(value, Utils.dateReviver);
    return JSON.parse(value);
  }

}

/**
 * The class whose instance is returned when using `.create`. You can access the internal properties and interact with the store with its methods.
 * @class
 * @property {Map} map - Internal storage, values are not stored as strings but as their native objects
 * @property {Properties} props - AppsScripts' PropertiesService instance
 * @property {Cache} cache - AppsScripts' CacheService instance
 * @example
 * const store = ObjectStore.create('script');
 * // load it at execution start time
 * store.load();
 * store.set('key1', 'key1');
 * store.set('key2', 'key2');
 * store.persist();
 */
class Store {

  /**
   * Refer to {@link create} to get instance of this class.
   * @see {@link create}
   */
  constructor (guard='script', config) {
    this[_config_] = configure(config);
    guard = guard[0].toUpperCase() + guard.slice(1);
    this.props = PropertiesService[`get${guard}Properties`].call();
    this.cache = CacheService[`get${guard}Cache`].call();
    this.map = new Map();
  }

  static scriptStore (config={}) {
    return new Store('script', config);
  }

  static documentStore (config={}) {
    return new Store('document', config);
  }

  static userStore (config={}) {
    return new Store('user', config);
  }

  static get utils () {
    // return serialiser who knows what to do with dates, if on
    return Utils;
  }

  serializePass (value) {
    if (this[_config_].jsons)
      return Store.utils.serialize(value, this[_config_].dates);
    return value;
  }

  /**
   * Download from the PropertyStore and store locally
   */
  load () {
    const props = this.props.getProperties();
    for (const [key, value] of Object.entries(props)) {
      this.map.set(key, this.serializePass(value));
    }
  }

  /**
   * Stores the value at key. If in auto mode, also stores in cache and properties
   * @param {String} key - The identifier, must be a string
   * @param {String} value - The data to be stored at `key`.
   * @param {Boolean} [skipCache=true] - If true (the default), don't interact with the cache service for this call
   * @throws {TypeError} Type error if `key` is not a string
   * @throws {TypeError} Type error if `value` is not a string, and it attempts to write to external stores
   */
  set (key, value, skipCache=true) {
    Enforce.positional(arguments, {key: '!string', value: 'any', 'skipCache': 'boolean'});
    this.map.set(key, value);
    if (!this[_config_].manual) {
      let serializedValue = this.serializePass(value);
      if (!Utils.isString(serializedValue)) throw TypeError("value must be string");
      !skipCache && this.cache.put(key, serializedValue);
      this.props.setProperty(key, serializedValue);
    }
  }

  /**
   * Take what has been stored in `.map` and update the property store
   * @param {Boolean} [skipCache=true] - If true (the default), don't interact with the cache service for this call
   */
  persist (skipCache=true) {
    const build = {};
    for (const [key, value] of this.map) {
      build[key] = this.serializePass(value);
    }
    if (!skipCache) this.cache.putAll(build, this[_config_].expiry);
    this.props.setProperties(build);
  }

  /**
   * Retrieve the value stored at key, return `null` if not present
   * @param {String} key - The key of which to return
   * @param {Boolean} [skipCache=true] If true (the default), don't interact with the cache service for this call
   * @returns {any}
   */
  get (key, skipCache=true) {
    Enforce.positional(arguments, {key: '!string'});
    // avoid any calls at all
    if (this.map.has(key)) return this.map.get(key);

    let value;
    // see if it's in the cache
    if (!skipCache) {
      value = this.cache.get(key);
      if (value !== null) {
        // put in map and return
        this.map.set(key, value);
        return value;
      }
    }

    // let's see if it's in the properties
    value = this.props.getProperty(key);
    if (value === null || value === undefined) return null;  // always return null when not present (or undefined?)
    if (this[_config_].jsons) {
      value = Store.utils.deserialize(value, this[_config_].dates);
    }
    // put it in the cache and the store and return
    this.map.set(key, value);
    this.cache.put(key, value, this[_config_].expiry);
    return value;
  }

  /**
   * Returns the keys that have been stored externally in `Properties`
   * @return {String[]}
   */
  getKeys () {
    return this.props.getKeys();
  }

  /**
   * Iterates over `this.getKeys()` and returns each key in an object
   * @return {Object}
   */
  getAll () {
    const keys = this.getKeys();
    let properties = {};
    for (let key of keys) {
      properties[key] = this.get(key);
    }
    return properties;
  }

  /**
   * Calls `.setProperties` with properties after iterating through, serializing, and storing in local map
   * @param {Object} properties - object representing key/values
   * @param {Boolean} [skipCache=true] - If true (the default), don't interact with the cache service for this call
   */
  setProperties (properties, skipCache=true) {
    Enforce.positional(arguments, {properties: 'object'});
    // make a copy of properties
    const copied = {};
    for (let key of Object.keys(properties)) {
      this.map.set(key, properties[key]);
      !skipCache && this.cache.put(key, properties[key], this[_config_].expiry);
      if (this[_config_].jsons) {
        copied[key] = this.serializePass(properties[key]);
      } else {
        copied[key] = properties[key];
      }
    }
    this.props.setProperties(copied);
  }

  /**
   * @param {String} [key] - The key to remove
   * @throws {TypeError} if key is not a string
   */
  remove (key) {
    Enforce.positional(arguments, {key: '!string'});
    this.props.deleteProperty(key);
    this.cache.remove(key);
    this.map.delete(key);
  }

  /**
   * Removes keys and values from external stores. Since the cache requires a list of keys explicitely given, keys parameter is available to specific which ones to delete from the cache. The props are deleted in its entirety. The `keys` param probably only needs to be set in testing conditions.
   * @param {String[]} [keys=null] - The keys to remove from the cache (note that all keys are removed from properties, irregardless of the value of this parameter)
   */
  removeAll (keys=null) {
    Enforce.positional(arguments, {keys: 'array'});
    // cache service can only remove keys it is sent
    if (!keys) this.cache.removeAll(this.props.getKeys());
    else this.cache.removeAll(keys);
    this.props.deleteAllProperties();
  }
}

export const ObjectStore = {Store};