watcher.js

const isFunction = require("lodash.isfunction");

const errors = require("./errors.js");
const callbackNotAFunction = errors.callbackNotAFunction;
const objectAlreadyWatched = errors.objectAlreadyWatched;
const objectNotWatched = errors.objectNotWatched;

/**
 * @callback module:watcher~onChangeCallback
 * @param {Object}  oldObj  The old object being watched.
 * @param {Object}  newObj  The new object that is now being watched.
 */

/**
 *  @public
 *  @author Pedro Miguel Pereira Serrano Martins
 *  @version 2.0.6
 *  @module watcher
 *  @desc   Watches over changes in objects and executes callbacks when those
 *          changes happen.
 */


/**
 *  @typedef {Object}    Id The unique id of the object we want to  watch over.
 *                          Can be anything, althout I strongly recommend it to
 *                          be a primitive type, such as a <code>string</code>,
 *                          or a <code>number</code>.
 */

const watchMap = new Map();
const isObjWatched = objName => watchMap.has(objName);

/**
 *  @public
 *  @func   watch
 *  @param  {module:watcher~Id} objId   The unique id of the object we want
 *                                      to watch over.
 *  @param  {Object} obj            The object that will be watched.
 *  @throws {ObjectAlreadyWatched}  If the watch list is already watching an
 *                                  object with the given <code>objId</code>.
 *
 *  @description    Adds the following object to the watchlist with the
 *                  given id.
 */
const watch = (objId, obj) => {
    if (isObjWatched(objId))
        throw objectAlreadyWatched(objId);

    watchMap.set(objId, {
        obj: obj,

        /**
         *  @private
         *  @function   onChange
         *
         *  @description    Default function that runs for every new watched
         *                  object. Does nothing and is a place hodler.
         */
        onChange: () => {}
    });
};

/**
 *  @public
 *  @func   unwatch
 *  @param  {module:watcher~Id}    objId    The object that will be
 *                                          unwatched.
 *  @throws {ObjectNotWatched}      If the watch list does not contain any
 *                                  object with the given <code>objId</code>.
 *
 *  @description    Removes the object with the given <code>objId</code> from the watch
 *                  list. It also deletes all callbacks associated with it.
 */
const unwatch = objId => {
    if (!isObjWatched(objId))
        throw objectNotWatched(objId);
    watchMap.delete(objId);
};

/**
 *  @public
 *  @func       get
 *  @param      {module:watcher~Id} objId   The unique id of the object we
 *                                          want to get.
 *  @returns    {Object}
 *  @throws {ObjectNotWatched}      If the watch list does not contain any
 *                                  object with the given <code>objId</code>.
 *
 *  @description    This function returns a <b>shallow clone</b> of the object with 
 *                  the given object id. This happens so as to minize side
 *                  effects and prevents direct object manipulation to the
 *                  object being watched.
 */
const get = objId => {
    if (!isObjWatched(objId))
        throw objectNotWatched(objId);
    return Object.assign({}, watchMap.get(objId).obj);
};

/**
 *  @public
 *  @function   set
 *  @param      {module:watcher~Id} objId   The id of the object we want to
 *                                          replace.
 *  @param      {Object}    newObj  The new object that will replace the
 *                                  current object with the id.
 *  @throws {ObjectNotWatched}      If the watch list does not contain any
 *                                  object with the given <code>objId</code>.
 *
 *  @description    Replaces the object currently being watched with the
 *                  given one. If your code has references affecting the old
 *                  object (which is very unlikely given that you always
 *                  work with clones) they will be lost. Calls the object's
 *                  callback passing the new object as an argument.
 */
const set = (objId, newObj) => {
    if (!isObjWatched(objId))
        throw objectNotWatched(objId);

    const oldEntry = watchMap.get(objId);
    const newEntry = Object.assign({}, oldEntry, { obj: newObj });

    watchMap.set(objId, newEntry);
    newEntry.onChange(oldEntry.obj, newEntry.obj); //call onChange with both objects
};

/**
 *  @public
 *  @func   onChange
 *  @param  {module:watcher~Id}    objId    The id of the object to which we
 *                                          want to attach a callback.
 *  @param  {module:watcher~onChangeCallback}  callback    The callback to be executed when
 *                                          the object changes via the
 *                                          <code>set</code> method.
 *  @throws {ObjectNotWatched}              If the watch list does not
 *                                          contain any object with the
 *                                          given <code>objId</code>.
 *  @throws {CallbackNotAFunction}          If the provided callback
 *                                          parameter is not a function.
 *
 *  @description    Sets the callback for the given object. This callback
 *                  will be executed everytime the object changes via
 *                  the <code>set</code> method.
 */
const onChange = (objId, callback) => {
    if (!isObjWatched(objId))
        throw objectNotWatched(objId);

    if (!isFunction(callback))
        throw callbackNotAFunction(objId);

    const entry = watchMap.get(objId);
    entry.onChange = callback;
};

/**
 *  @public
 *  @func   reset
 *
 *  @description    Removes all the watched objects and all their associated
 *                  callbacks. Empties the watch list.
 */
const reset = () => {
    watchMap.clear();
};


module.exports = Object.freeze( {
    watch,
    unwatch,
    onChange,
    get,
    set,
    reset
} );