baobab
Version:
JavaScript persistent data tree with cursors.
840 lines (722 loc) • 26.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _emmett = _interopRequireDefault(require("emmett"));
var _monkey = require("./monkey");
var _type = _interopRequireDefault(require("./type"));
var _helpers = require("./helpers");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); }
function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }
function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }
function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }
/**
* Traversal helper function for dynamic cursors. Will throw a legible error
* if traversal is not possible.
*
* @param {string} method - The method name, to create a correct error msg.
* @param {array} solvedPath - The cursor's solved path.
*/
function checkPossibilityOfDynamicTraversal(method, solvedPath) {
if (!solvedPath) throw (0, _helpers.makeError)("Baobab.Cursor.".concat(method, ": ") + "cannot use ".concat(method, " on an unresolved dynamic path."), {
path: solvedPath
});
}
/**
* Cursor class
*
* @constructor
* @param {Baobab} tree - The cursor's root.
* @param {array} path - The cursor's path in the tree.
* @param {string} hash - The path's hash computed ahead by the tree.
*/
var Cursor =
/*#__PURE__*/
function (_Emitter) {
_inherits(Cursor, _Emitter);
function Cursor(tree, path, hash) {
var _this;
_classCallCheck(this, Cursor);
_this = _possibleConstructorReturn(this, _getPrototypeOf(Cursor).call(this)); // If no path were to be provided, we fallback to an empty path (root)
path = path || []; // Privates
_this._identity = '[object Cursor]';
_this._archive = null; // Properties
_this.tree = tree;
_this.path = path;
_this.hash = hash; // State
_this.state = {
killed: false,
recording: false,
undoing: false
}; // Checking whether the given path is dynamic or not
_this._dynamicPath = _type["default"].dynamicPath(_this.path); // Checking whether the given path will meet a monkey
_this._monkeyPath = _type["default"].monkeyPath(_this.tree._monkeys, _this.path);
if (!_this._dynamicPath) _this.solvedPath = _this.path;else _this.solvedPath = (0, _helpers.getIn)(_this.tree._data, _this.path).solvedPath;
/**
* Listener bound to the tree's writes so that cursors with dynamic paths
* may update their solved path correctly.
*
* @param {object} event - The event fired by the tree.
*/
_this._writeHandler = function (_ref) {
var data = _ref.data;
if (_this.state.killed || !(0, _helpers.solveUpdate)([data.path], _this._getComparedPaths())) return;
_this.solvedPath = (0, _helpers.getIn)(_this.tree._data, _this.path).solvedPath;
};
/**
* Function in charge of actually trigger the cursor's updates and
* deal with the archived records.
*
* @note: probably should wrap the current solvedPath in closure to avoid
* for tricky cases where it would fail.
*
* @param {mixed} previousData - the tree's previous data.
*/
var fireUpdate = function fireUpdate(previousData) {
var self = _assertThisInitialized(_this);
var eventData = {
get previousData() {
return (0, _helpers.getIn)(previousData, self.solvedPath).data;
},
get currentData() {
return self.get();
}
};
if (_this.state.recording && !_this.state.undoing) _this.archive.add(eventData.previousData);
_this.state.undoing = false;
return _this.emit('update', eventData);
};
/**
* Listener bound to the tree's updates and determining whether the
* cursor is affected and should react accordingly.
*
* Note that this listener is lazily bound to the tree to be sure
* one wouldn't leak listeners when only creating cursors for convenience
* and not to listen to updates specifically.
*
* @param {object} event - The event fired by the tree.
*/
_this._updateHandler = function (event) {
if (_this.state.killed) return;
var _event$data = event.data,
paths = _event$data.paths,
previousData = _event$data.previousData,
update = fireUpdate.bind(_assertThisInitialized(_this), previousData),
comparedPaths = _this._getComparedPaths();
if ((0, _helpers.solveUpdate)(paths, comparedPaths)) return update();
}; // Lazy binding
var bound = false;
_this._lazyBind = function () {
if (bound) return;
bound = true;
if (_this._dynamicPath) _this.tree.on('write', _this._writeHandler);
return _this.tree.on('update', _this._updateHandler);
}; // If the path is dynamic, we actually need to listen to the tree
if (_this._dynamicPath) {
_this._lazyBind();
} else {
// Overriding the emitter `on` and `once` methods
_this.on = (0, _helpers.before)(_this._lazyBind, _this.on.bind(_assertThisInitialized(_this)));
_this.once = (0, _helpers.before)(_this._lazyBind, _this.once.bind(_assertThisInitialized(_this)));
}
return _this;
}
/**
* Internal helpers
* -----------------
*/
/**
* Method returning the paths of the tree watched over by the cursor and that
* should be taken into account when solving a potential update.
*
* @return {array} - Array of paths to compare with a given update.
*/
_createClass(Cursor, [{
key: "_getComparedPaths",
value: function _getComparedPaths() {
// Checking whether we should keep track of some dependencies
var additionalPaths = this._monkeyPath ? (0, _helpers.getIn)(this.tree._monkeys, this._monkeyPath).data.relatedPaths() : [];
return [this.solvedPath].concat(additionalPaths);
}
/**
* Predicates
* -----------
*/
/**
* Method returning whether the cursor is at root level.
*
* @return {boolean} - Is the cursor the root?
*/
}, {
key: "isRoot",
value: function isRoot() {
return !this.path.length;
}
/**
* Method returning whether the cursor is at leaf level.
*
* @return {boolean} - Is the cursor a leaf?
*/
}, {
key: "isLeaf",
value: function isLeaf() {
return _type["default"].primitive(this._get().data);
}
/**
* Method returning whether the cursor is at branch level.
*
* @return {boolean} - Is the cursor a branch?
*/
}, {
key: "isBranch",
value: function isBranch() {
return !this.isRoot() && !this.isLeaf();
}
/**
* Traversal Methods
* ------------------
*/
/**
* Method returning the root cursor.
*
* @return {Baobab} - The root cursor.
*/
}, {
key: "root",
value: function root() {
return this.tree.select();
}
/**
* Method selecting a subpath as a new cursor.
*
* Arity (1):
* @param {path} path - The path to select.
*
* Arity (*):
* @param {...step} path - The path to select.
*
* @return {Cursor} - The created cursor.
*/
}, {
key: "select",
value: function select(path) {
if (arguments.length > 1) path = (0, _helpers.arrayFrom)(arguments);
return this.tree.select(this.path.concat(path));
}
/**
* Method returning the parent node of the cursor or else `null` if the
* cursor is already at root level.
*
* @return {Baobab} - The parent cursor.
*/
}, {
key: "up",
value: function up() {
if (!this.isRoot()) return this.tree.select(this.path.slice(0, -1));
return null;
}
/**
* Method returning the child node of the cursor.
*
* @return {Baobab} - The child cursor.
*/
}, {
key: "down",
value: function down() {
checkPossibilityOfDynamicTraversal('down', this.solvedPath);
if (!(this._get().data instanceof Array)) throw Error('Baobab.Cursor.down: cannot go down on a non-list type.');
return this.tree.select(this.solvedPath.concat(0));
}
/**
* Method returning the left sibling node of the cursor if this one is
* pointing at a list. Returns `null` if this cursor is already leftmost.
*
* @return {Baobab} - The left sibling cursor.
*/
}, {
key: "left",
value: function left() {
checkPossibilityOfDynamicTraversal('left', this.solvedPath);
var last = +this.solvedPath[this.solvedPath.length - 1];
if (isNaN(last)) throw Error('Baobab.Cursor.left: cannot go left on a non-list type.');
return last ? this.tree.select(this.solvedPath.slice(0, -1).concat(last - 1)) : null;
}
/**
* Method returning the right sibling node of the cursor if this one is
* pointing at a list. Returns `null` if this cursor is already rightmost.
*
* @return {Baobab} - The right sibling cursor.
*/
}, {
key: "right",
value: function right() {
checkPossibilityOfDynamicTraversal('right', this.solvedPath);
var last = +this.solvedPath[this.solvedPath.length - 1];
if (isNaN(last)) throw Error('Baobab.Cursor.right: cannot go right on a non-list type.');
if (last + 1 === this.up()._get().data.length) return null;
return this.tree.select(this.solvedPath.slice(0, -1).concat(last + 1));
}
/**
* Method returning the leftmost sibling node of the cursor if this one is
* pointing at a list.
*
* @return {Baobab} - The leftmost sibling cursor.
*/
}, {
key: "leftmost",
value: function leftmost() {
checkPossibilityOfDynamicTraversal('leftmost', this.solvedPath);
var last = +this.solvedPath[this.solvedPath.length - 1];
if (isNaN(last)) throw Error('Baobab.Cursor.leftmost: cannot go left on a non-list type.');
return this.tree.select(this.solvedPath.slice(0, -1).concat(0));
}
/**
* Method returning the rightmost sibling node of the cursor if this one is
* pointing at a list.
*
* @return {Baobab} - The rightmost sibling cursor.
*/
}, {
key: "rightmost",
value: function rightmost() {
checkPossibilityOfDynamicTraversal('rightmost', this.solvedPath);
var last = +this.solvedPath[this.solvedPath.length - 1];
if (isNaN(last)) throw Error('Baobab.Cursor.rightmost: cannot go right on a non-list type.');
var list = this.up()._get().data;
return this.tree.select(this.solvedPath.slice(0, -1).concat(list.length - 1));
}
/**
* Method mapping the children nodes of the cursor.
*
* @param {function} fn - The function to map.
* @param {object} [scope] - An optional scope.
* @return {array} - The resultant array.
*/
}, {
key: "map",
value: function map(fn, scope) {
checkPossibilityOfDynamicTraversal('map', this.solvedPath);
var array = this._get().data,
l = arguments.length;
if (!_type["default"].array(array)) throw Error('baobab.Cursor.map: cannot map a non-list type.');
return array.map(function (item, i) {
return fn.call(l > 1 ? scope : this, this.select(i), i, array);
}, this);
}
/**
* Getter Methods
* ---------------
*/
/**
* Internal get method. Basically contains the main body of the `get` method
* without the event emitting. This is sometimes needed not to fire useless
* events.
*
* @param {path} [path=[]] - Path to get in the tree.
* @return {object} info - The resultant information.
* @return {mixed} info.data - Data at path.
* @return {array} info.solvedPath - The path solved when getting.
*/
}, {
key: "_get",
value: function _get() {
var path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
if (!_type["default"].path(path)) throw (0, _helpers.makeError)('Baobab.Cursor.getters: invalid path.', {
path: path
});
if (!this.solvedPath) return {
data: undefined,
solvedPath: null,
exists: false
};
return (0, _helpers.getIn)(this.tree._data, this.solvedPath.concat(path));
}
/**
* Method used to check whether a certain path exists in the tree starting
* from the current cursor.
*
* Arity (1):
* @param {path} path - Path to check in the tree.
*
* Arity (2):
* @param {..step} path - Path to check in the tree.
*
* @return {boolean} - Does the given path exists?
*/
}, {
key: "exists",
value: function exists(path) {
path = (0, _helpers.coercePath)(path);
if (arguments.length > 1) path = (0, _helpers.arrayFrom)(arguments);
return this._get(path).exists;
}
/**
* Method used to get data from the tree. Will fire a `get` event from the
* tree so that the user may sometimes react upon it to fetch data, for
* instance.
*
* Arity (1):
* @param {path} path - Path to get in the tree.
*
* Arity (2):
* @param {..step} path - Path to get in the tree.
*
* @return {mixed} - Data at path.
*/
}, {
key: "get",
value: function get(path) {
path = (0, _helpers.coercePath)(path);
if (arguments.length > 1) path = (0, _helpers.arrayFrom)(arguments);
var _this$_get = this._get(path),
data = _this$_get.data,
solvedPath = _this$_get.solvedPath; // Emitting the event
this.tree.emit('get', {
data: data,
solvedPath: solvedPath,
path: this.path.concat(path)
});
return data;
}
/**
* Method used to shallow clone data from the tree.
*
* Arity (1):
* @param {path} path - Path to get in the tree.
*
* Arity (2):
* @param {..step} path - Path to get in the tree.
*
* @return {mixed} - Cloned data at path.
*/
}, {
key: "clone",
value: function clone() {
var data = this.get.apply(this, arguments);
return (0, _helpers.shallowClone)(data);
}
/**
* Method used to deep clone data from the tree.
*
* Arity (1):
* @param {path} path - Path to get in the tree.
*
* Arity (2):
* @param {..step} path - Path to get in the tree.
*
* @return {mixed} - Cloned data at path.
*/
}, {
key: "deepClone",
value: function deepClone() {
var data = this.get.apply(this, arguments);
return (0, _helpers.deepClone)(data);
}
/**
* Method used to return raw data from the tree, by carefully avoiding
* computed one.
*
* @todo: should be more performant as the cloning should happen as well as
* when dropping computed data.
*
* Arity (1):
* @param {path} path - Path to serialize in the tree.
*
* Arity (2):
* @param {..step} path - Path to serialize in the tree.
*
* @return {mixed} - The retrieved raw data.
*/
}, {
key: "serialize",
value: function serialize(path) {
path = (0, _helpers.coercePath)(path);
if (arguments.length > 1) path = (0, _helpers.arrayFrom)(arguments);
if (!_type["default"].path(path)) throw (0, _helpers.makeError)('Baobab.Cursor.getters: invalid path.', {
path: path
});
if (!this.solvedPath) return undefined;
var fullPath = this.solvedPath.concat(path);
var data = (0, _helpers.deepClone)((0, _helpers.getIn)(this.tree._data, fullPath).data),
monkeys = (0, _helpers.getIn)(this.tree._monkeys, fullPath).data;
var dropComputedData = function dropComputedData(d, m) {
if (!_type["default"].object(m) || !_type["default"].object(d)) return;
for (var k in m) {
if (m[k] instanceof _monkey.Monkey) delete d[k];else dropComputedData(d[k], m[k]);
}
};
dropComputedData(data, monkeys);
return data;
}
/**
* Method used to project some of the data at cursor onto a map or a list.
*
* @param {object|array} projection - The projection's formal definition.
* @return {object|array} - The resultant map/list.
*/
}, {
key: "project",
value: function project(projection) {
if (_type["default"].object(projection)) {
var data = {};
for (var k in projection) {
data[k] = this.get(projection[k]);
}
return data;
} else if (_type["default"].array(projection)) {
var _data = [];
for (var i = 0, l = projection.length; i < l; i++) {
_data.push(this.get(projection[i]));
}
return _data;
}
throw (0, _helpers.makeError)('Baobab.Cursor.project: wrong projection.', {
projection: projection
});
}
/**
* History Methods
* ----------------
*/
/**
* Methods starting to record the cursor's successive states.
*
* @param {integer} [maxRecords] - Maximum records to keep in memory. Note
* that if no number is provided, the cursor
* will keep everything.
* @return {Cursor} - The cursor instance for chaining purposes.
*/
}, {
key: "startRecording",
value: function startRecording(maxRecords) {
maxRecords = maxRecords || Infinity;
if (maxRecords < 1) throw (0, _helpers.makeError)('Baobab.Cursor.startRecording: invalid max records.', {
value: maxRecords
});
this.state.recording = true;
if (this.archive) return this; // Lazy binding
this._lazyBind();
this.archive = new _helpers.Archive(maxRecords);
return this;
}
/**
* Methods stopping to record the cursor's successive states.
*
* @return {Cursor} - The cursor instance for chaining purposes.
*/
}, {
key: "stopRecording",
value: function stopRecording() {
this.state.recording = false;
return this;
}
/**
* Methods undoing n steps of the cursor's recorded states.
*
* @param {integer} [steps=1] - The number of steps to rollback.
* @return {Cursor} - The cursor instance for chaining purposes.
*/
}, {
key: "undo",
value: function undo() {
var steps = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
if (!this.state.recording) throw new Error('Baobab.Cursor.undo: cursor is not recording.');
var record = this.archive.back(steps);
if (!record) throw Error('Baobab.Cursor.undo: cannot find a relevant record.');
this.state.undoing = true;
this.set(record);
return this;
}
/**
* Methods returning whether the cursor has a recorded history.
*
* @return {boolean} - `true` if the cursor has a recorded history?
*/
}, {
key: "hasHistory",
value: function hasHistory() {
return !!(this.archive && this.archive.get().length);
}
/**
* Methods returning the cursor's history.
*
* @return {array} - The cursor's history.
*/
}, {
key: "getHistory",
value: function getHistory() {
return this.archive ? this.archive.get() : [];
}
/**
* Methods clearing the cursor's history.
*
* @return {Cursor} - The cursor instance for chaining purposes.
*/
}, {
key: "clearHistory",
value: function clearHistory() {
if (this.archive) this.archive.clear();
return this;
}
/**
* Releasing
* ----------
*/
/**
* Methods releasing the cursor from memory.
*/
}, {
key: "release",
value: function release() {
// Removing listeners on parent
if (this._dynamicPath) this.tree.off('write', this._writeHandler);
this.tree.off('update', this._updateHandler); // Unsubscribe from the parent
if (this.hash) delete this.tree._cursors[this.hash]; // Dereferencing
delete this.tree;
delete this.path;
delete this.solvedPath;
delete this.archive; // Killing emitter
this.kill();
this.state.killed = true;
}
/**
* Output
* -------
*/
/**
* Overriding the `toJSON` method for convenient use with JSON.stringify.
*
* @return {mixed} - Data at cursor.
*/
}, {
key: "toJSON",
value: function toJSON() {
return this.serialize();
}
/**
* Overriding the `toString` method for debugging purposes.
*
* @return {string} - The cursor's identity.
*/
}, {
key: "toString",
value: function toString() {
return this._identity;
}
}]);
return Cursor;
}(_emmett["default"]);
/**
* Method used to allow iterating over cursors containing list-type data.
*
* e.g. for(let i of cursor) { ... }
*
* @returns {object} - Each item sequentially.
*/
exports["default"] = Cursor;
if (typeof Symbol === 'function' && typeof Symbol.iterator !== 'undefined') {
Cursor.prototype[Symbol.iterator] = function () {
var array = this._get().data;
if (!_type["default"].array(array)) throw Error('baobab.Cursor.@@iterate: cannot iterate a non-list type.');
var i = 0;
var cursor = this,
length = array.length;
return {
next: function next() {
if (i < length) {
return {
value: cursor.select(i++)
};
}
return {
done: true
};
}
};
};
}
/**
* Setter Methods
* ---------------
*
* Those methods are dynamically assigned to the class for DRY reasons.
*/
// Not using a Set so that ES5 consumers don't pay a bundle size price
var INTRANSITIVE_SETTERS = {
unset: true,
pop: true,
shift: true
};
/**
* Function creating a setter method for the Cursor class.
*
* @param {string} name - the method's name.
* @param {function} [typeChecker] - a function checking that the given value is
* valid for the given operation.
*/
function makeSetter(name, typeChecker) {
/**
* Binding a setter method to the Cursor class and having the following
* definition.
*
* Note: this is not really possible to make those setters variadic because
* it would create an impossible polymorphism with path.
*
* @todo: perform value validation elsewhere so that tree.update can
* beneficiate from it.
*
* Arity (1):
* @param {mixed} value - New value to set at cursor's path.
*
* Arity (2):
* @param {path} path - Subpath to update starting from cursor's.
* @param {mixed} value - New value to set.
*
* @return {mixed} - Data at path.
*/
Cursor.prototype[name] = function (path, value) {
// We should warn the user if he applies to many arguments to the function
if (arguments.length > 2) throw (0, _helpers.makeError)("Baobab.Cursor.".concat(name, ": too many arguments.")); // Handling arities
if (arguments.length === 1 && !INTRANSITIVE_SETTERS[name]) {
value = path;
path = [];
} // Coerce path
path = (0, _helpers.coercePath)(path); // Checking the path's validity
if (!_type["default"].path(path)) throw (0, _helpers.makeError)("Baobab.Cursor.".concat(name, ": invalid path."), {
path: path
}); // Checking the value's validity
if (typeChecker && !typeChecker(value)) throw (0, _helpers.makeError)("Baobab.Cursor.".concat(name, ": invalid value."), {
path: path,
value: value
}); // Checking the solvability of the cursor's dynamic path
if (!this.solvedPath) throw (0, _helpers.makeError)("Baobab.Cursor.".concat(name, ": the dynamic path of the cursor cannot be solved."), {
path: this.path
});
var fullPath = this.solvedPath.concat(path); // Filing the update to the tree
return this.tree.update(fullPath, {
type: name,
value: value
});
};
}
/**
* Making the necessary setters.
*/
makeSetter('set');
makeSetter('unset');
makeSetter('apply', _type["default"]["function"]);
makeSetter('push');
makeSetter('concat', _type["default"].array);
makeSetter('unshift');
makeSetter('pop');
makeSetter('shift');
makeSetter('splice', _type["default"].splicer);
makeSetter('merge', _type["default"].object);
makeSetter('deepMerge', _type["default"].object);