baobab
Version:
JavaScript persistent data tree with cursors.
592 lines (480 loc) • 14.8 kB
JavaScript
/**
* Baobab Data Structure
* ======================
*
* A handy data tree with cursors.
*/
import Emitter from 'emmett';
import Cursor from './cursor';
import {MonkeyDefinition, Monkey} from './monkey';
import Watcher from './watcher';
import type from './type';
import update from './update';
import * as helpers from './helpers';
const {
arrayFrom,
coercePath,
deepFreeze,
getIn,
makeError,
deepClone,
deepMerge,
shallowClone,
shallowMerge,
hashPath
} = helpers;
/**
* Baobab defaults
*/
const DEFAULTS = {
// Should the tree handle its transactions on its own?
autoCommit: true,
// Should the transactions be handled asynchronously?
asynchronous: true,
// Should the tree's data be immutable?
immutable: true,
// Should the monkeys be lazy?
lazyMonkeys: true,
// Should we evaluate monkeys?
monkeyBusiness: true,
// Should the tree be persistent?
persistent: true,
// Should the tree's update be pure?
pure: true,
// Validation specifications
validate: null,
// Validation behavior 'rollback' or 'notify'
validationBehavior: 'rollback'
};
/**
* Baobab class
*
* @constructor
* @param {object|array} [initialData={}] - Initial data passed to the tree.
* @param {object} [opts] - Optional options.
* @param {boolean} [opts.autoCommit] - Should the tree auto-commit?
* @param {boolean} [opts.asynchronous] - Should the tree's transactions
* handled asynchronously?
* @param {boolean} [opts.immutable] - Should the tree be immutable?
* @param {boolean} [opts.persistent] - Should the tree be persistent?
* @param {boolean} [opts.pure] - Should the tree be pure?
* @param {function} [opts.validate] - Validation function.
* @param {string} [opts.validationBehaviour] - "rollback" or "notify".
*/
class Baobab extends Emitter {
constructor(initialData, opts) {
super();
// Setting initialData to an empty object if no data is provided by use
if (arguments.length < 1)
initialData = {};
// Checking whether given initial data is valid
if (!type.object(initialData) && !type.array(initialData))
throw makeError('Baobab: invalid data.', {data: initialData});
// Merging given options with defaults
this.options = shallowMerge({}, DEFAULTS, opts);
// Disabling immutability & persistence if persistence if disabled
if (!this.options.persistent) {
this.options.immutable = false;
this.options.pure = false;
}
// Privates
this._identity = '[object Baobab]';
this._cursors = {};
this._future = null;
this._transaction = [];
this._affectedPathsIndex = {};
this._monkeys = {};
this._previousData = null;
this._data = initialData;
// Properties
this.root = new Cursor(this, [], 'λ');
delete this.root.release;
// Does the user want an immutable tree?
if (this.options.immutable)
deepFreeze(this._data);
// Bootstrapping root cursor's getters and setters
const bootstrap = (name) => {
this[name] = function() {
const r = this.root[name].apply(this.root, arguments);
return r instanceof Cursor ? this : r;
};
};
[
'apply',
'clone',
'concat',
'deepClone',
'deepMerge',
'exists',
'get',
'push',
'merge',
'pop',
'project',
'serialize',
'set',
'shift',
'splice',
'unset',
'unshift'
].forEach(bootstrap);
// Registering the initial monkeys
if (this.options.monkeyBusiness) {
this._refreshMonkeys();
}
// Initial validation
const validationError = this.validate();
if (validationError)
throw Error('Baobab: invalid data.', {error: validationError});
}
/**
* Internal method used to refresh the tree's monkey register on every
* update.
* Note 1) For the time being, placing monkeys beneath array nodes is not
* allowed for performance reasons.
*
* @param {mixed} node - The starting node.
* @param {array} path - The starting node's path.
* @param {string} operation - The operation that lead to a refreshment.
* @return {Baobab} - The tree instance for chaining purposes.
*/
_refreshMonkeys(node, path, operation) {
const clean = (data, p = []) => {
if (data instanceof Monkey) {
data.release();
update(this._monkeys, p, {type: 'unset'}, {
immutable: false,
persistent: false,
pure: false
});
return;
}
if (type.object(data)) {
for (const k in data)
clean(data[k], p.concat(k));
}
};
const walk = (data, p = []) => {
// Should we sit a monkey in the tree?
if (data instanceof MonkeyDefinition ||
data instanceof Monkey) {
const monkeyInstance = new Monkey(
this,
p,
data instanceof Monkey ? data.definition : data
);
update(this._monkeys, p, {type: 'set', value: monkeyInstance}, {
immutable: false,
persistent: false,
pure: false
});
return;
}
// Object iteration
if (type.object(data)) {
for (const k in data)
walk(data[k], p.concat(k));
}
};
// Walking the whole tree
if (!arguments.length) {
walk(this._data);
}
else {
const monkeysNode = getIn(this._monkeys, path).data;
// Is this required that we clean some already existing monkeys?
if (monkeysNode)
clean(monkeysNode, path);
// Let's walk the tree only from the updated point
if (operation !== 'unset') {
walk(node, path);
}
}
return this;
}
/**
* Method used to validate the tree's data.
*
* @return {boolean} - Is the tree valid?
*/
validate(affectedPaths) {
const {validate, validationBehavior: behavior} = this.options;
if (typeof validate !== 'function')
return null;
const error = validate.call(
this,
this._previousData,
this._data,
affectedPaths || [[]]
);
if (error instanceof Error) {
if (behavior === 'rollback') {
this._data = this._previousData;
this._affectedPathsIndex = {};
this._transaction = [];
this._previousData = this._data;
}
this.emit('invalid', {error});
return error;
}
return null;
}
/**
* Method used to select data within the tree by creating a cursor. Cursors
* are kept as singletons by the tree for performance and hygiene reasons.
*
* Arity (1):
* @param {path} path - Path to select in the tree.
*
* Arity (*):
* @param {...step} path - Path to select in the tree.
*
* @return {Cursor} - The resultant cursor.
*/
select(path) {
// If no path is given, we simply return the root
path = path || [];
// Variadic
if (arguments.length > 1)
path = arrayFrom(arguments);
// Checking that given path is valid
if (!type.path(path))
throw makeError('Baobab.select: invalid path.', {path});
// Casting to array
path = [].concat(path);
// Computing hash (done here because it would be too late to do it in the
// cursor's constructor since we need to hit the cursors' index first).
const hash = hashPath(path);
// Creating a new cursor or returning the already existing one for the
// requested path.
let cursor = this._cursors[hash];
if (!cursor) {
cursor = new Cursor(this, path, hash);
this._cursors[hash] = cursor;
}
// Emitting an event to notify that a part of the tree was selected
this.emit('select', {path, cursor});
return cursor;
}
/**
* Method used to update the tree. Updates are simply expressed by a path,
* dynamic or not, and an operation.
*
* This is where path solving should happen and not in the cursor.
*
* @param {path} path - The path where we'll apply the operation.
* @param {object} operation - The operation to apply.
* @return {mixed} - Return the result of the update.
*/
update(path, operation) {
// Coercing path
path = coercePath(path);
if (!type.operationType(operation.type))
throw makeError(
`Baobab.update: unknown operation type "${operation.type}".`,
{operation}
);
// Solving the given path
const {solvedPath, exists} = getIn(
this._data,
path
);
// If we couldn't solve the path, we throw
if (!solvedPath)
throw makeError('Baobab.update: could not solve the given path.', {
path: solvedPath
});
// Read-only path?
const monkeyPath = type.monkeyPath(this._monkeys, solvedPath);
if (monkeyPath && solvedPath.length > monkeyPath.length)
throw makeError('Baobab.update: attempting to update a read-only path.', {
path: solvedPath
});
// We don't unset irrelevant paths
if (operation.type === 'unset' && !exists)
return;
// If we merge data, we need to acknowledge monkeys
let realOperation = operation;
if (/merge/i.test(operation.type)) {
const monkeysNode = getIn(this._monkeys, solvedPath).data;
if (type.object(monkeysNode)) {
// Cloning the operation not to create weird behavior for the user
realOperation = shallowClone(realOperation);
// Fetching the existing node in the current data
const currentNode = getIn(this._data, solvedPath).data;
if (/deep/i.test(realOperation.type))
realOperation.value = deepMerge({},
deepMerge({}, currentNode, deepClone(monkeysNode)),
realOperation.value
);
else
realOperation.value = shallowMerge({},
deepMerge({}, currentNode, deepClone(monkeysNode)),
realOperation.value
);
}
}
// Stashing previous data if this is the frame's first update
if (!this._transaction.length)
this._previousData = this._data;
// Applying the operation
const result = update(
this._data,
solvedPath,
realOperation,
this.options
);
const {data, node} = result;
// If because of purity, the update was moot, we stop here
if (!('data' in result))
return node;
// If the operation is push, the affected path is slightly different
const affectedPath = solvedPath.concat(
operation.type === 'push' ? node.length - 1 : []
);
const hash = hashPath(affectedPath);
// Updating data and transaction
this._data = data;
this._affectedPathsIndex[hash] = true;
this._transaction.push(shallowMerge({}, operation, {path: affectedPath}));
// Updating the monkeys
if (this.options.monkeyBusiness) {
this._refreshMonkeys(node, solvedPath, operation.type);
}
// Emitting a `write` event
this.emit('write', {path: affectedPath});
// Should we let the user commit?
if (!this.options.autoCommit)
return node;
// Should we update asynchronously?
if (!this.options.asynchronous) {
this.commit();
return node;
}
// Updating asynchronously
if (!this._future)
this._future = setTimeout(() => this.commit(), 0);
// Finally returning the affected node
return node;
}
/**
* Method committing the updates of the tree and firing the tree's events.
*
* @return {Baobab} - The tree instance for chaining purposes.
*/
commit() {
// Do not fire update if the transaction is empty
if (!this._transaction.length)
return this;
// Clearing timeout if one was defined
if (this._future)
this._future = clearTimeout(this._future);
const affectedPaths = Object.keys(this._affectedPathsIndex).map(h => {
return h !== 'λ' ?
h.split('λ').slice(1) :
[];
});
// Is the tree still valid?
const validationError = this.validate(affectedPaths);
if (validationError)
return this;
// Caching to keep original references before we change them
const transaction = this._transaction,
previousData = this._previousData;
this._affectedPathsIndex = {};
this._transaction = [];
this._previousData = this._data;
// Emitting update event
this.emit('update', {
paths: affectedPaths,
currentData: this._data,
transaction,
previousData
});
return this;
}
/**
* Method returning a monkey at the given path or else `null`.
*
* @param {path} path - Path of the monkey to retrieve.
* @return {Monkey|null} - The Monkey instance of `null`.
*/
getMonkey(path) {
path = coercePath(path);
const monkey = getIn(this._monkeys, [].concat(path)).data;
if (monkey instanceof Monkey)
return monkey;
return null;
}
/**
* Method used to watch a collection of paths within the tree. Very useful
* to bind UI components and such to the tree.
*
* @param {object} mapping - Mapping of paths to listen.
* @return {Cursor} - The created watcher.
*/
watch(mapping) {
return new Watcher(this, mapping);
}
/**
* Method releasing the tree and its attached data from memory.
*/
release() {
let k;
this.emit('release');
delete this.root;
delete this._data;
delete this._previousData;
delete this._transaction;
delete this._affectedPathsIndex;
delete this._monkeys;
// Releasing cursors
for (k in this._cursors)
this._cursors[k].release();
delete this._cursors;
// Killing event emitter
this.kill();
}
/**
* Overriding the `toJSON` method for convenient use with JSON.stringify.
*
* @return {mixed} - Data at cursor.
*/
toJSON() {
return this.serialize();
}
/**
* Overriding the `toString` method for debugging purposes.
*
* @return {string} - The baobab's identity.
*/
toString() {
return this._identity;
}
}
/**
* Monkey helper.
*/
Baobab.monkey = function(...args) {
if (!args.length)
throw new Error('Baobab.monkey: missing definition.');
if (args.length === 1 && typeof args[0] !== 'function')
return new MonkeyDefinition(args[0]);
return new MonkeyDefinition(args);
};
Baobab.dynamicNode = Baobab.monkey;
export const monkey = Baobab.monkey;
export const dynamic = Baobab.dynamic;
/**
* Exposing some internals for convenience
*/
export {Cursor, MonkeyDefinition, Monkey, type, helpers};
/**
* Version.
*/
Baobab.VERSION = '2.6.1';
export const VERSION = Baobab.VERSION;
/**
* Exporting.
*/
export default Baobab;