UNPKG

baobab

Version:

JavaScript persistent data tree with cursors.

592 lines (480 loc) 14.8 kB
/** * 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;