Cursor.js

import lodash from 'lodash';
import EventEmitter from 'events';

/**
 * One query capsule.
 * @class
 * @memberof module:ancient-cursor
 */
class Cursor {
  
  /**
   * @constructs Cursor
   * @param query - Query resolves current cursor .We keep it just in case. Suddenly, to work with data, you need to compare them with the query?
   * @param data - Any data by resolved query.
   * @param {Cursor~adapterDestroyed} adapterDestroyed
   * @param {string|number} [id]
   */
  constructor(query, data, adapterDestroyed, id) {
    this.query = query;
    this.data = data;
    this.adapterDestroyed = adapterDestroyed;
    this.id = id;
    this.emitter = new EventEmitter();
  }
  
  /**
   * Way to change data changes in specified path.
   * @param {string|string[]} path
   * @param current
   */
  set(path, current) {
    var _path = lodash.toPath(path); 
    
    var oldByPath = lodash.get(this.data, _path);
    
    if (!_path.length) this.data = current;
    else this.data = lodash.set(this.data || {}, _path, current);
    
    this.emitter.emit('changed', { old: oldByPath, path: _path, action: 'set', 'arguments': lodash.toArray(arguments), });
  }
  
  /**
   * Way to change array data in specified path. Unlike the standard splice, all arguments are required, except items.
   * @param {string|string[]} path
   * @param {number} start
   * @param {number} deleteCount
   * @param {...*} [items]
   */
  splice(path, start, deleteCount, ...items) {
    var _path = lodash.toPath(path); 
    
    var oldByPath = lodash.clone(lodash.get(this.data, _path));
    
    var data = this.get(_path);
    
    if (!lodash.isArray(data)) throw new Error(`Data by path is not an array.`);
    
    data.splice(start, deleteCount, ...items);
    
    this.emitter.emit('changed', { old: oldByPath, path: _path, action: 'splice', 'arguments': lodash.toArray(arguments), });
  }
  
  /**
   * Getter from data. Handler can observe changes by current path in data.
   * @param {string|string[]} path
   * @param {Cursor~handler} [handler] - Notify you about changes in data by path.
   * @return data - Returns someting from data by spcefied path.
   */
  get(path = null, handler) {
    this.on(path, handler);
    return lodash.isNull(path)?this.data:lodash.get(this.data, path);
  }
  
  _generateListener(path, handler) {
    var eventPath = lodash.toPath(path);
    var stop, listener;
    if (typeof(handler) == 'function') {
      listener = (changes) => {
        var isClone, oldValue, currentValue;
        var eventPathLocal = eventPath.slice(changes.path.length);
        
        if (changes.path.length <= eventPath.length) {
          if (lodash.isEqual(changes.path, eventPath.slice(0, changes.path.length))) {
            isClone = true;
            oldValue = eventPathLocal.length?lodash.get(changes.old, eventPathLocal):changes.old;
            currentValue = eventPath.length?lodash.get(this.data, eventPath):this.data;
          } else return;
        } else {
          if (lodash.isEqual(eventPath, changes.path.slice(0, eventPath.length))) {
            isClone = false;
            oldValue = currentValue = eventPath.length?lodash.get(this.data, eventPath):this.data;
          } else return;
        }
        
        handler(oldValue, currentValue, stop, changes, isClone, this);
      };
      stop = () => this.emitter.removeListener('changed', listener);
    }
    return { listener, stop };
  }
  
  /**
   * Handle event changed, as get handler argument, but returns stop method.
   * @param {string|string[]} path
   * @param {Cursor~handler} [handler] - Notify you about changes in data by path.
   * @return {Function} stop
   */
  on(path = null, handler) {
    var { listener, stop } = this._generateListener(path, handler);
    if (listener) this.emitter.on('changed', listener);
    return stop;
  }
  
  /**
   * Adds a one time listener function for the data by path changes. The next time changes is triggered, this listener is removed after invoked.
   * @param {string|string[]} path
   * @param {Cursor~handler} [handler] - Notify you about changes in data by path.
   */
  once(path = null, handler) {
    var { listener, stop } = this._generateListener(path, handler);
    if (listener) this.emitter.once('changed', listener);
    return stop;
  }

  /**
   * Destroy current cursor.
   * If cursor constructed with adapterDestroyed method, then call `this.adapterDestroyed` method. If cursor constructed from `CursorManager`, it remove cursor from `this.manager.cursors` and unset `this.id`.
   * Has no other effects.
   */
  destroy() {
    if (typeof(this.adapterDestroyed) == 'function') {
      this.adapterDestroyed(this);
    }
  }
}

/**
 * @callback Cursor~adapterDestroyed
 * @memberof module:ancient-cursor
 * @param {Cursor} cursor
 */

/**
 * @callback Cursor~handler
 * @memberof module:ancient-cursor
 * @param old - Link to this data old the change.
 * @param current - Link to this data current the change.
 * @param {Function} stop
 * @param {Object} changes
 * @param changes.old
 * @param {string[]} changes.path
 * @param {string} changes.action
 * @param {Array} changes.arguments
 * @param {boolean} isClone - True if changed path deeper then event path. For details, read attention.
 * @param {Cursor} cursor
 * @description
 * **Attention!** If the path leads to a higher level of data from the changed, the link to the data `old` the change will lead to the same location as link `current` the change. If the path leads to a changed level or deeper, then `old` and `current` will differ.
 */

 export default Cursor;