Source: Binding.js

var Imm = require('immutable');
var Util = require('./Util');
var ChangesDescriptor = require('./ChangesDescriptor');

/* ---------------- */
/* Private helpers. */
/* ---------------- */

var UNSET_VALUE = {};

var getBackingValue, setBackingValue;

getBackingValue = function (binding) {
  return binding._sharedInternals.backingValue;
};

setBackingValue = function (binding, newBackingValue) {
  binding._sharedInternals.backingValue = newBackingValue;
};

var EMPTY_PATH, PATH_SEPARATOR, getPathElements, getValueAtPath;

EMPTY_PATH = [];
PATH_SEPARATOR = '.';

getPathElements = function (path) {
  return path ? path.split(PATH_SEPARATOR) : [];
};

getValueAtPath = function (backingValue, path) {
  return backingValue && path.length > 0 ? backingValue.getIn(path) : backingValue;
};

var asArrayPath, asStringPath;

asArrayPath = function (path) {
  return typeof path === 'string' ?
    getPathElements(path) :
    (Util.undefinedOrNull(path) ? [] : path);
};

asStringPath = function (path) {
  switch (typeof path) {
    case 'string':
      return path;
    case 'number':
      return path.toString();
    default:
      return Util.undefinedOrNull(path) ? '' : path.join(PATH_SEPARATOR);
  }
};

var setOrUpdate, updateValue, removeValue, merge, clear;

setOrUpdate = function (rootValue, effectivePath, f) {
  return rootValue.updateIn(effectivePath, UNSET_VALUE, function (value) {
    return value === UNSET_VALUE ? f() : f(value);
  });
};

updateValue = function (self, subpath, f) {
  var backingValue = getBackingValue(self);
  var effectivePath = Util.joinPaths(self._path, subpath);
  var newBackingValue = setOrUpdate(backingValue, effectivePath, f);

  setBackingValue(self, newBackingValue);

  if (backingValue.hasIn(effectivePath)) {
    return effectivePath;
  } else {
    return effectivePath.slice(0, effectivePath.length - 1);
  }
};

removeValue = function (self, subpath) {
  var effectivePath = Util.joinPaths(self._path, subpath);
  var backingValue = getBackingValue(self);

  var len = effectivePath.length;
  switch (len) {
    case 0:
      throw new Error('Cannot delete root value');
    default:
      var pathTo = effectivePath.slice(0, len - 1);
      if (backingValue.has(pathTo[0]) || len === 1) {
        var newBackingValue = backingValue.updateIn(pathTo, function (coll) {
          var key = effectivePath[len - 1];
          if (Imm.List.isList(coll)) {
            return coll.splice(key, 1);
          } else {
            return coll && coll.remove(key);
          }
        });

        setBackingValue(self, newBackingValue);
      }

      return pathTo;
  }
};

merge = function (preserve, newValue, value) {
  if (Util.undefinedOrNull(value)) {
    return newValue;
  } else {
    if (Imm.Iterable.isIterable(value) && Imm.Iterable.isIterable(value)) {
      return preserve ? newValue.mergeDeep(value) : value.mergeDeep(newValue);
    } else {
      return preserve ? value : newValue;
    }
  }
};

clear = function (value) {
  return Imm.Iterable.isIterable(value) ? value.clear() : null;
};

var mkStateTransition =
  function (currentBackingValue, previousBackingValue, currentBackingMeta, previousBackingMeta, metaMetaChanged) {
    return {
      currentBackingValue: currentBackingValue,
      currentBackingMeta: currentBackingMeta,
      previousBackingValue: previousBackingValue,
      previousBackingMeta: previousBackingMeta,
      metaMetaChanged: metaMetaChanged || false
    };
  };

var generateListenerId = function () {
  return Math.random().toString(36).substr(2, 9);
};

var notifyListeners, notifyGlobalListeners, startsWith, isPathAffected, notifyNonGlobalListeners, notifyAllListeners;

notifyListeners = function (self, samePathListeners, listenerPath, path, stateTransition) {
  var currentBackingValue = stateTransition.currentBackingValue;
  var previousBackingValue = stateTransition.previousBackingValue;
  var currentBackingMeta = stateTransition.currentBackingMeta;
  var previousBackingMeta = stateTransition.previousBackingMeta;

  Util.getPropertyValues(samePathListeners).forEach(function (listenerDescriptor) {
    if (!listenerDescriptor.disabled) {
      var listenerPathAsArray = asArrayPath(listenerPath);

      var valueChanged = currentBackingValue !== previousBackingValue &&
        currentBackingValue.getIn(listenerPathAsArray) !== previousBackingValue.getIn(listenerPathAsArray);
      var metaChanged = stateTransition.metaMetaChanged || (
        previousBackingMeta && currentBackingMeta !== previousBackingMeta &&
          currentBackingMeta.getIn(listenerPathAsArray) !== previousBackingMeta.getIn(listenerPathAsArray));

      if (valueChanged || metaChanged) {
        listenerDescriptor.cb(
          new ChangesDescriptor(
            path, listenerPathAsArray, valueChanged, metaChanged, stateTransition
          )
        );
      }
    }
  });
};

notifyGlobalListeners = function (self, path, stateTransition) {
  var listeners = self._sharedInternals.listeners;
  var globalListeners = listeners[''];
  if (globalListeners) {
    notifyListeners(self, globalListeners, EMPTY_PATH, path, stateTransition);
  }
};

startsWith = function (s1, s2) {
  return s1.indexOf(s2) === 0;
};

isPathAffected = function (listenerPath, changedPath) {
  return changedPath === '' || listenerPath === changedPath ||
    startsWith(changedPath, listenerPath + PATH_SEPARATOR) || startsWith(listenerPath, changedPath + PATH_SEPARATOR);
};

notifyNonGlobalListeners = function (self, path, stateTransition) {
  var listeners = self._sharedInternals.listeners;
  Object.keys(listeners).filter(Util.identity).forEach(function (listenerPath) {
    if (isPathAffected(listenerPath, asStringPath(path))) {
      notifyListeners(self, listeners[listenerPath], listenerPath, path, stateTransition);
    }
  });
};

notifyAllListeners = function (self, path, stateTransition) {
  notifyGlobalListeners(self, path, stateTransition);
  notifyNonGlobalListeners(self, path, stateTransition);
};

var linkMeta, unlinkMeta;

linkMeta = function (self, metaBinding) {
  self._sharedInternals.metaBindingListenerId = metaBinding.addListener(function (changes) {
    var metaNodePath = changes.getPath();
    var changedPath = metaNodePath.slice(0, metaNodePath.length - 1);

    var backingValue = getBackingValue(self);
    var metaMetaChanged = !changes.isValueChanged();
    var previousBackingMeta = metaMetaChanged ? getBackingValue(metaBinding) : changes.getPreviousValue();

    notifyAllListeners(
      self, changedPath,
      mkStateTransition(backingValue, backingValue, getBackingValue(metaBinding), previousBackingMeta, metaMetaChanged)
    );
  });
};

unlinkMeta = function (self, metaBinding) {
  var removed = metaBinding.removeListener(self._sharedInternals.metaBindingListenerId);
  self._sharedInternals.metaBinding = null;
  self._sharedInternals.metaBindingListenerId = null;
  return removed;
};

var findSamePathListeners, setListenerDisabled;

findSamePathListeners = function (self, listenerId) {
  return Util.find(
    Util.getPropertyValues(self._sharedInternals.listeners),
    function (samePathListeners) { return !!samePathListeners[listenerId]; }
  );
};

setListenerDisabled = function (self, listenerId, disabled) {
  var samePathListeners = findSamePathListeners(self, listenerId);
  if (samePathListeners) {
    samePathListeners[listenerId].disabled = disabled;
  }
};

var update, delete_;

update = function (self, subpath, f) {
  var previousBackingValue = getBackingValue(self);
  var affectedPath = updateValue(self, asArrayPath(subpath), f);
  var backingMeta = getBackingValue(self.meta());

  notifyAllListeners(
    self, affectedPath,
    mkStateTransition(getBackingValue(self), previousBackingValue, backingMeta, backingMeta)
  );
};

delete_ = function (self, subpath) {
  var previousBackingValue = getBackingValue(self);
  var affectedPath = removeValue(self, asArrayPath(subpath));
  var backingMeta = getBackingValue(self.meta());

  notifyAllListeners(
    self, affectedPath,
    mkStateTransition(getBackingValue(self), previousBackingValue, backingMeta, backingMeta)
  );
};

/** Binding constructor.
 * @param {String[]} [path] binding path, empty array if omitted
 * @param {Object} [sharedInternals] shared relative bindings internals:
 * <ul>
 *   <li>backingValue - backing value;</li>
 *   <li>metaBinding - meta binding;</li>
 *   <li>metaBindingListenerId - meta binding listener id;</li>
 *   <li>listeners - change listeners;</li>
 *   <li>cache - bindings cache.</li>
 * </ul>
 * @public
 * @class Binding
 * @classdesc Wraps immutable collection. Provides convenient read-write access to nested values.
 * Allows to create sub-bindings (or views) narrowed to a subpath and sharing the same backing value.
 * Changes to these bindings are mutually visible.
 * <p>Terminology:
 * <ul>
 *   <li>
 *     (sub)path - path to a value within nested associative data structure, example: 'path.t.0.some.value';
 *   </li>
 *   <li>
 *     backing value - value shared by all bindings created using [sub]{@link Binding#sub} method.
 *   </li>
 * </ul>
 * <p>Features:
 * <ul>
 *   <li>can create sub-bindings sharing same backing value. Sub-binding can only modify values down its subpath;</li>
 *   <li>allows to conveniently modify nested values: assign, update with a function, remove, and so on;</li>
 *   <li>can attach change listeners to a specific subpath;</li>
 *   <li>can perform multiple changes atomically in respect of listener notification.</li>
 * </ul>
 * @see Binding.init */
var Binding = function (path, sharedInternals) {
  /** @private */
  this._path = path || EMPTY_PATH;

  /** @protected
   * @ignore */
  this._sharedInternals = sharedInternals || {};

  if (!this._sharedInternals.listeners) {
    this._sharedInternals.listeners = {};
  }

  if (!this._sharedInternals.cache) {
    this._sharedInternals.cache = {};
  }
};

/* --------------- */
/* Static helpers. */
/* --------------- */

/** Create new binding with empty listeners set.
 * @param {Immutable.Map} [backingValue] backing value, empty map if omitted
 * @param {Binding} [metaBinding] meta binding
 * @return {Binding} fresh binding instance */
Binding.init = function (backingValue, metaBinding) {
  var binding = new Binding(EMPTY_PATH, {
    backingValue: backingValue || Imm.Map(),
    metaBinding: metaBinding
  });

  if (metaBinding) {
    linkMeta(binding, metaBinding);
  }

  return binding;
};

/** Convert string path to array path.
 * @param {String} pathAsString path as string
 * @return {Array} path as an array */
Binding.asArrayPath = function (pathAsString) {
  return asArrayPath(pathAsString);
};

/** Convert array path to string path.
 * @param {String[]} pathAsAnArray path as an array
 * @return {String} path as a string */
Binding.asStringPath = function (pathAsAnArray) {
  return asStringPath(pathAsAnArray);
};

/** Meta node name.
 * @deprecated Use Util.META_NODE instead.
 * @type {String} */
Binding.META_NODE = Util.META_NODE;

/** @lends Binding.prototype */
var bindingPrototype = {

  /** Get binding path.
   * @returns {Array} binding path */
  getPath: function () {
    return this._path;
  },

  /** Update backing value.
   * @param {Immutable.Map} newBackingValue new backing value
   * @return {Binding} new binding instance, original is unaffected */
  withBackingValue: function (newBackingValue) {
    var newSharedInternals = {};
    Util.assign(newSharedInternals, this._sharedInternals);
    newSharedInternals.backingValue = newBackingValue;
    return new Binding(this._path, newSharedInternals);
  },

  /** Check if binding value is changed in alternative backing value.
   * @param {Immutable.Map} alternativeBackingValue alternative backing value
   * @param {Function} [compare] alternative compare function, does reference equality check if omitted */
  isChanged: function (alternativeBackingValue, compare) {
    var value = this.get();
    var alternativeValue = alternativeBackingValue ? alternativeBackingValue.getIn(this._path) : undefined;
    return compare ?
        !compare(value, alternativeValue) :
        !(value === alternativeValue || (Util.undefinedOrNull(value) && Util.undefinedOrNull(alternativeValue)));
  },

  /** Check if this and supplied binding are relatives (i.e. share same backing value).
   * @param {Binding} otherBinding potential relative
   * @return {Boolean} */
  isRelative: function (otherBinding) {
    return this._sharedInternals === otherBinding._sharedInternals &&
      this._sharedInternals.backingValue === otherBinding._sharedInternals.backingValue;
  },

  /** Get binding's meta binding.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers;
   *                                 b.meta('path') is equivalent to b.meta().sub('path')
   * @returns {Binding} meta binding or undefined */
  meta: function (subpath) {
    if (!this._sharedInternals.metaBinding) {
      var metaBinding = Binding.init(Imm.Map());
      linkMeta(this, metaBinding);
      this._sharedInternals.metaBinding = metaBinding;
    }

    var effectiveSubpath = subpath ? Util.joinPaths([Util.META_NODE], asArrayPath(subpath)) : [Util.META_NODE];
    var thisPath = this.getPath();
    var absolutePath = thisPath.length > 0 ? Util.joinPaths(thisPath, effectiveSubpath) : effectiveSubpath;
    return this._sharedInternals.metaBinding.sub(absolutePath);
  },

  /** Unlink this binding's meta binding, removing change listener and making them totally independent.
   * May be used to prevent memory leaks when appropriate.
   * @return {Boolean} true if binding's meta binding was unlinked */
  unlinkMeta: function () {
    var metaBinding = this._sharedInternals.metaBinding;
    return metaBinding ? unlinkMeta(this, metaBinding) : false;
  },

  /** Get binding value.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @return {*} value at path or null */
  get: function (subpath) {
    return getValueAtPath(getBackingValue(this), Util.joinPaths(this._path, asArrayPath(subpath)));
  },

  /** Convert to JS representation.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @return {*} JS representation of data at subpath */
  toJS: function (subpath) {
    var value = this.sub(subpath).get();
    return Imm.Iterable.isIterable(value) ? value.toJS() : value;
  },

  /** Bind to subpath. Both bindings share the same backing value. Changes are mutually visible.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @return {Binding} new binding instance, original is unaffected */
  sub: function (subpath) {
    var pathAsArray = asArrayPath(subpath);
    var absolutePath = Util.joinPaths(this._path, pathAsArray);
    if (absolutePath.length > 0) {
      var absolutePathAsString = asStringPath(absolutePath);
      var cached = this._sharedInternals.cache[absolutePathAsString];

      if (cached) {
        return cached;
      } else {
        var subBinding = new Binding(absolutePath, this._sharedInternals);
        this._sharedInternals.cache[absolutePathAsString] = subBinding;
        return subBinding;
      }
    } else {
      return this;
    }
  },

  /** Update binding value.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @param {Function} f update function
   * @return {Binding} this binding */
  update: function (subpath, f) {
    var args = Util.resolveArgs(arguments, '?subpath', 'f');
    update(this, args.subpath, args.f);
    return this;
  },

  /** Set binding value.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @param {*} newValue new value
   * @return {Binding} this binding */
  set: function (subpath, newValue) {
    var args = Util.resolveArgs(arguments, '?subpath', 'newValue');
    update(this, args.subpath, Util.constantly(args.newValue));
    return this;
  },

  /** Delete value.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @return {Binding} this binding */
  remove: function (subpath) {
    delete_(this, subpath);
    return this;
  },

  /** Deep merge values.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @param {Boolean} [preserve=false] preserve existing values when merging
   * @param {*} newValue new value
   * @return {Binding} this binding */
  merge: function (subpath, preserve, newValue) {
    var args = Util.resolveArgs(
      arguments,
      function (x) { return Util.canRepresentSubpath(x) ? 'subpath' : null; },
      '?preserve',
      'newValue'
    );
    update(this, args.subpath, merge.bind(null, args.preserve, args.newValue));
    return this;
  },

  /** Clear nested collection. Does '.clear()' on Immutable values, nullifies otherwise.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @return {Binding} this binding */
  clear: function (subpath) {
    var subpathAsArray = asArrayPath(subpath);
    if (!Util.undefinedOrNull(this.get(subpathAsArray))) {
      update(this, subpathAsArray, clear);
    }
    return this;
  },

  /** Add change listener.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @param {Function} cb function receiving changes descriptor
   * @return {String} unique id which should be used to un-register the listener
   * @see ChangesDescriptor */
  addListener: function (subpath, cb) {
    var args = Util.resolveArgs(
      arguments, function (x) { return Util.canRepresentSubpath(x) ? 'subpath' : null; }, 'cb'
    );

    var listenerId = generateListenerId();
    var pathAsString = asStringPath(Util.joinPaths(this._path, asArrayPath(args.subpath || '')));
    var samePathListeners = this._sharedInternals.listeners[pathAsString];
    var listenerDescriptor = { cb: args.cb, disabled: false };
    if (samePathListeners) {
      samePathListeners[listenerId] = listenerDescriptor;
    } else {
      var listeners = {};
      listeners[listenerId] = listenerDescriptor;
      this._sharedInternals.listeners[pathAsString] = listeners;
    }
    return listenerId;
  },

  /** Add change listener triggered only once.
   * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
   * @param {Function} cb function receiving changes descriptor
   * @return {String} unique id which should be used to un-register the listener
   * @see ChangesDescriptor */
  addOnceListener: function (subpath, cb) {
    var args = Util.resolveArgs(
      arguments, function (x) { return Util.canRepresentSubpath(x) ? 'subpath' : null; }, 'cb'
    );

    var self = this;
    var listenerId = self.addListener(args.subpath, function () {
      self.removeListener(listenerId);
      args.cb();
    });
    return listenerId;
  },

  /** Enable listener.
   * @param {String} listenerId listener id
   * @return {Binding} this binding */
  enableListener: function (listenerId) {
    setListenerDisabled(this, listenerId, false);
    return this;
  },

  /** Disable listener.
   * @param {String} listenerId listener id
   * @return {Binding} this binding */
  disableListener: function (listenerId) {
    setListenerDisabled(this, listenerId, true);
    return this;
  },

  /** Execute function with listener temporarily disabled. Correctly handles functions returning promises.
   * @param {String} listenerId listener id
   * @param {Function} f function to execute
   * @return {Binding} this binding */
  withDisabledListener: function (listenerId, f) {
    var samePathListeners = findSamePathListeners(this, listenerId);
    if (samePathListeners) {
      var descriptor = samePathListeners[listenerId];
      descriptor.disabled = true;
      Util.afterComplete(f, function () { descriptor.disabled = false; });
    } else {
      f();
    }
    return this;
  },

  /** Un-register the listener.
   * @param {String} listenerId listener id
   * @return {Boolean} true if listener removed successfully, false otherwise */
  removeListener: function (listenerId) {
    var samePathListeners = findSamePathListeners(this, listenerId);
    return samePathListeners ? delete samePathListeners[listenerId] : false;
  },

  /** Create transaction context.
   * If promise is supplied, transaction will be automatically
   * cancelled and reverted (if already committed) on promise failure.
   * @param {Promise} [promise] ES6 promise
   * @return {TransactionContext} transaction context */
  atomically: function (promise) {
    return new TransactionContext(this, promise);
  }

};

bindingPrototype['delete'] = bindingPrototype.remove;

Binding.prototype = bindingPrototype;

/** Transaction context constructor.
 * @param {Binding} binding binding
 * @param {Promise} [promise] ES6 promise
 * @public
 * @class TransactionContext
 * @classdesc Transaction context. */
var TransactionContext = function (binding, promise) {
  /** @private */
  this._binding = binding;

  /** @private */
  this._queuedUpdates = [];
  /** @private */
  this._finishedUpdates = [];

  /** @private */
  this._committed = false;
  /** @private */
  this._cancelled = false;

  /** @private */
  this._hasChanges = false;
  /** @private */
  this._hasMetaChanges = false;

  if (promise) {
    var self = this;
    promise.then(Util.identity, function () {
      if (!self.isCancelled()) {
        self.cancel();
      }
    });
  }
};

TransactionContext.prototype = (function () {

  var UPDATE_TYPE = Object.freeze({
    UPDATE: 'update',
    DELETE: 'delete'
  });

  var registerUpdate, hasChanges;

  registerUpdate = function (self, binding) {
    if (!self._hasChanges) {
      self._hasChanges = binding.isRelative(self._binding);
    }

    if (!self._hasMetaChanges) {
      self._hasMetaChanges = !binding.isRelative(self._binding);
    }
  };

  hasChanges = function (self) {
    return self._hasChanges || self._hasMetaChanges;
  };

  var addUpdate, addDeletion, areSiblings, filterRedundantPaths, commitSilently;

  addUpdate = function (self, binding, update, subpath) {
    registerUpdate(self, binding);
    self._queuedUpdates.push({ binding: binding, update: update, subpath: subpath, type: UPDATE_TYPE.UPDATE });
  };

  addDeletion = function (self, binding, subpath) {
    registerUpdate(self, binding);
    self._queuedUpdates.push({ binding: binding, subpath: subpath, type: UPDATE_TYPE.DELETE });
  };

  areSiblings = function (path1, path2) {
    var path1Length = path1.length, path2Length = path2.length;
    return path1Length === path2Length &&
      (path1Length === 1 || path1[path1Length - 2] === path2[path1Length - 2]);
  };

  filterRedundantPaths = function (affectedPaths) {
    if (affectedPaths.length < 2) {
      return affectedPaths;
    } else {
      var sortedPaths = affectedPaths.sort();
      var previousPath = sortedPaths[0], previousPathAsString = asStringPath(previousPath);
      var result = [previousPath];
      for (var i = 1; i < sortedPaths.length; i++) {
        var currentPath = sortedPaths[i], currentPathAsString = asStringPath(currentPath);
        if (!startsWith(currentPathAsString, previousPathAsString)) {
          if (areSiblings(currentPath, previousPath)) {
            var commonParentPath = currentPath.slice(0, currentPath.length - 1);
            result.pop();
            result.push(commonParentPath);
            previousPath = commonParentPath;
            previousPathAsString = asStringPath(commonParentPath);
          } else {
            result.push(currentPath);
            previousPath = currentPath;
            previousPathAsString = currentPathAsString;
          }
        }
      }
      return result;
    }
  };

  commitSilently = function (self) {
    var finishedUpdates = self._queuedUpdates.map(function (update) {
      var previousBackingValue = getBackingValue(update.binding);
      var affectedPath = update.type === UPDATE_TYPE.UPDATE ?
        updateValue(update.binding, update.subpath, update.update) :
        removeValue(update.binding, update.subpath);

      return {
        affectedPath: affectedPath,
        binding: update.binding,
        previousBackingValue: previousBackingValue
      };
    });

    self._committed = true;
    self._queuedUpdates = null;

    return finishedUpdates;
  };

  var revert = function (self) {
    var finishedUpdates = self._finishedUpdates;
    if (finishedUpdates.length > 0) {
      var tx = self._binding.atomically();

      for (var i = finishedUpdates.length; i-- > 0;) {
        var update = finishedUpdates[i];
        var binding = update.binding, affectedPath = update.affectedPath;
        var relativeAffectedPath =
          binding.getPath().length === affectedPath.length ?
            affectedPath :
            affectedPath.slice(binding.getPath().length);

        tx.set(binding, relativeAffectedPath, update.previousBackingValue.getIn(affectedPath));
      }

      tx.commit();
    }

    self._finishedUpdates = null;
  };

  var cancel = function (self) {
    if (self.isCommitted()) {
      revert(self);
    }

    self._cancelled = true;
  };

  /** @lends TransactionContext.prototype */
  var transactionContextPrototype = {

    /** Update binding value.
     * @param {Binding} [binding] binding to apply update to
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @param {Function} f update function
     * @return {TransactionContext} updated transaction */
    update: function (binding, subpath, f) {
      var args = Util.resolveArgs(
        arguments,
        function (x) { return x instanceof Binding ? 'binding' : null; }, '?subpath', 'f'
      );
      addUpdate(this, args.binding || this._binding, args.f, asArrayPath(args.subpath));
      return this;
    },

    /** Set binding value.
     * @param {Binding} [binding] binding to apply update to
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @param {*} newValue new value
     * @return {TransactionContext} updated transaction context */
    set: function (binding, subpath, newValue) {
      var args = Util.resolveArgs(
        arguments,
        function (x) { return x instanceof Binding ? 'binding' : null; }, '?subpath', 'newValue'
      );
      return this.update(args.binding, args.subpath, Util.constantly(args.newValue));
    },

    /** Remove value.
     * @param {Binding} [binding] binding to apply update to
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @return {TransactionContext} updated transaction context */
    remove: function (binding, subpath) {
      var args = Util.resolveArgs(
        arguments,
        function (x) { return x instanceof Binding ? 'binding' : null; }, '?subpath'
      );
      addDeletion(this, args.binding || this._binding, asArrayPath(args.subpath));
      return this;
    },

    /** Deep merge values.
     * @param {Binding} [binding] binding to apply update to
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @param {Boolean} [preserve=false] preserve existing values when merging
     * @param {*} newValue new value
     * @return {TransactionContext} updated transaction context */
    merge: function (binding, subpath, preserve, newValue) {
      var args = Util.resolveArgs(
        arguments,
        function (x) { return x instanceof Binding ? 'binding' : null; },
        function (x) { return Util.canRepresentSubpath(x) ? 'subpath' : null; },
        function (x) { return typeof x === 'boolean' ? 'preserve' : null; },
        'newValue'
      );
      return this.update(args.binding, args.subpath, merge.bind(null, args.preserve, args.newValue));
    },

    /** Clear collection or nullify nested value.
     * @param {Binding} [binding] binding to apply update to
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @return {TransactionContext} updated transaction context */
    clear: function (binding, subpath) {
      var args = Util.resolveArgs(
        arguments,
        function (x) { return x instanceof Binding ? 'binding' : null; }, '?subpath'
      );
      addUpdate(this, args.binding || this._binding, clear, asArrayPath(args.subpath));
      return this;
    },

    /** Commit transaction (write changes and notify listeners).
     * @param {Object} [options] options object
     * @param {Boolean} [options.notify=true] should listeners be notified
     * @return {TransactionContext} updated transaction context */
    commit: function (options) {
      if (!this.isCommitted()) {
        if (!this.isCancelled() && hasChanges(this)) {
          var effectiveOptions = options || {};
          var binding = this._binding;
          var metaBinding = binding.meta();

          var previousBackingValue = null, previousBackingMeta = null;
          if (effectiveOptions.notify !== false) {
            previousBackingValue = getBackingValue(binding);
            previousBackingMeta = getBackingValue(metaBinding);
          }

          this._finishedUpdates = commitSilently(this);
          var affectedPaths = this._finishedUpdates.map(function (update) { return update.affectedPath; });

          if (effectiveOptions.notify !== false) {
            var filteredPaths = filterRedundantPaths(affectedPaths);

            var stateTransition = mkStateTransition(
              getBackingValue(binding), previousBackingValue, getBackingValue(metaBinding), previousBackingMeta
            );

            notifyGlobalListeners(binding, filteredPaths[0], stateTransition);
            filteredPaths.forEach(function (path) {
              notifyNonGlobalListeners(binding, path, stateTransition);
            });
          }
        }

        return this;
      } else {
        throw new Error('Morearty: transaction already committed');
      }
    },

    /** Cancel this transaction.
     * Committing cancelled transaction won't have any effect.
     * For committed transactions affected paths will be reverted to original values,
     * overwriting any changes made after transaction has been committed. */
    cancel: function () {
      if (!this.isCancelled()) {
        cancel(this);
      } else {
        throw new Error('Morearty: transaction already cancelled');
      }
    },

    /** Check if transaction was committed.
     * @return {Boolean} committed flag */
    isCommitted: function () {
      return this._committed;
    },

    /** Check if transaction was cancelled, either manually or due to promise failure.
     * @return {Boolean} cancelled flag */
    isCancelled: function () {
      return this._cancelled;
    }

  };

  transactionContextPrototype['delete'] = transactionContextPrototype.remove;

  return transactionContextPrototype;
})();

module.exports = Binding;