├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── benchmark.js ├── bower.json ├── build ├── baobab.js └── baobab.min.js ├── eslint.config.js ├── package-lock.json ├── package.json ├── scripts ├── banner.tmpl ├── build.js ├── commonjs-addendum.js ├── test-commonjs.js └── test-es6-import.js ├── src ├── baobab.d.ts ├── baobab.js ├── cursor.js ├── helpers.js ├── monkey.js ├── type.js ├── update.js └── watcher.js ├── test ├── register.js ├── state.ts ├── suites │ ├── baobab.ts │ ├── cursor.ts │ ├── helpers.ts │ ├── monkey.ts │ └── watcher.ts └── utils.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Yomguithereal 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | TODO.md 4 | benchmark.js 5 | *.log 6 | dist 7 | .idea/ 8 | *.iml 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .editorconfig 4 | .gitignore 5 | .npmignore 6 | .travis.yml 7 | test/ 8 | build/ 9 | gulpfile.js 10 | benchmark.js 11 | *.log 12 | .eslintrc 13 | .babelrc 14 | TODO.md 15 | bower.json 16 | .idea/ 17 | *.iml 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "9" 5 | - "10" 6 | - "11" 7 | - "12" 8 | - "13" 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.6.1 4 | 5 | * Fixing prototype pollution issue ([@arjunshibu](https://github.com/arjunshibu)). 6 | 7 | ## v2.6.0 8 | 9 | * Adding TypeScript declaration files. 10 | 11 | ## v2.5.3 12 | 13 | * Refreshing the library's build to fix `babel` issues when consuming the library. 14 | 15 | ## v2.5.2 16 | 17 | * Fixing the library's export. 18 | 19 | ## v2.5.1 20 | 21 | * Fixing issue related to monkey not firing the correct events ([@roark](https://github.com/roark)). 22 | 23 | ## v2.5.0 24 | 25 | * Adding the `monkeyBusiness` option ([@Tuhis](https://github.com/Tuhis)). 26 | 27 | ## v2.4.3 28 | 29 | * Better `tree/cursor.splice` ([@jrust](https://github.com/jrust)). 30 | 31 | ## v2.4.2 32 | 33 | * Fixing monkey-related memory leak ([@jrust](https://github.com/jrust)). 34 | 35 | ## v2.4.1 36 | 37 | * Fixing `tree/cursor.splice` descriptor ([@Nimelrian](https://github.com/Nimelrian)). 38 | 39 | ## v2.4.0 40 | 41 | * Handling non-enumerable properties ([@BrendanAnnable](https://github.com/BrendanAnnable)). 42 | 43 | ## v2.3.4 44 | 45 | * Fixing an issue concerning objects created through `Object.create(null)` ([@fmal](https://github.com/fmal)). 46 | 47 | ## v2.3.3 48 | 49 | * Fixing again an issue concerning merging an monkeys (thanks to [@abalmos](https://github.com/abalmos) and [@Zache](https://github.com/Zache)). 50 | 51 | ## v2.3.2 52 | 53 | * Fixing issue concerning merging and monkeys. 54 | * Fixing path coercion in monkeys' dependencies. 55 | 56 | ## v2.3.1 57 | 58 | * Adding internal `tree.getMonkey`. 59 | * Fixing issue concerning monkey's recursivity. 60 | * Fixing a bug concerning setters & dynamic paths. 61 | 62 | ## v2.3.0 63 | 64 | * Adding the `tree/cursor.clone` and the `tree/cursor.deepClone` methods. 65 | * Adding the `tree/cursor.pop` and the `tree/cursor.shift` methods. 66 | * Adding a way to disable a single monkey's immutability. 67 | * Fixing an issue where the `tree.commit` method would fire a useless update. 68 | * Fixing an issue related to updates and dynamic paths. 69 | * Fixing the `tree/cursor.splice` to correctly handle negative indexes. 70 | * Fixing a bug related to eager monkeys and immutability. 71 | 72 | ## v2.2.1 73 | 74 | * Fixing a bug with watcher not able to handle path polymorphisms. 75 | 76 | ## v2.2.0 77 | 78 | * Cursors are now ES6 iterables ([@kirjs](https://github.com/kirjs)). 79 | * Dropping the `.babelrc` file from the npm build. 80 | 81 | ## v2.1.2 82 | 83 | * Storing hashed paths using `λ` as delimiter instead of `/` to enable some edge cases ([@nivekmai](https://github.com/nivekmai)). 84 | * Fixing an issue with cursors where a stopped history wouldn't restart correctly ([@nikvm](https://github.com/nikvm)). 85 | * Fixing monkeys' laziness. 86 | * Fixing an edge case when one watches over paths beneath monkeys. 87 | 88 | ## v2.1.1 89 | 90 | * Fixing existence checking of `undefined` values. 91 | * Fixing the `lazyMonkeys` option. 92 | * Fixing the tree's behavior regarding ES6 collections ([@askmatey](https://github.com/askmatey)). 93 | * Fixing the `splice` method ([@SaphuA](https://github.com/SaphuA)). 94 | 95 | ## v2.1.0 96 | 97 | * Adding the `lazyMonkeys` option. 98 | * Adding relative paths for monkeys' dependencies. 99 | 100 | ## v2.0.1 101 | 102 | * Fixing monkeys' laziness ([@Zache](https://github.com/Zache)). 103 | * Fixing issues related to the root cursor. 104 | * Fixing `get` event edge cases. 105 | 106 | ## v2.0.0 107 | 108 | * The tree is now immutable by default. 109 | * Cursor's setters method won't return themselves but rather the affected node now. 110 | * Adding `cursor.concat`. 111 | * Adding `cursor.deepMerge`. 112 | * Adding `cursor.serialize`. 113 | * Adding `cursor.project`. 114 | * Adding `cursor.exists`. 115 | * Adding `tree.watch`. 116 | * Adding the `pure` option. 117 | * Changing the way you can define computed data in the tree, aka "facets". Facets are now to be defined within the tree itself, are called "monkeys", and can be accessed using the exact same API as normal data. 118 | * Adding an alternative dynamic node definition syntax for convenience. 119 | * Dropped the `syncwrite` option. The tree is now writing synchronously but still emits its updates asynchronously by default. 120 | * Max number of records is now set to `Infinity` by default, meaning there is no limit. 121 | * Update events are now exposing the detail of each transaction so you can replay them elsewhere. 122 | * Fixing `cursor.push/unshift` behavior. 123 | * Dropped the `$cursor` helper. 124 | * Dropped the `update` specs for a simpler transaction syntax. 125 | * Updated `emmett` to `3.1.1`. 126 | * ES6 codebase rewrite. 127 | * Full code self documentation. 128 | 129 | ## v1.1.1 130 | 131 | * Updating `emmett` to `v3.0.1`. 132 | * Adding missing setters methods to the tree. 133 | * Fixing `cursor.root` method. 134 | 135 | ## v1.1.0 136 | 137 | * Adding an `immutable` option to the tree. 138 | * Adding a `syncwrite` option to the tree. 139 | * Adding a `get` and `select` event to the tree. 140 | * Facets getters are now applied within the tree's scope. 141 | * `update` events are now exposing the related data for convenience. 142 | * Fixing a `$cursor` related bug. 143 | * Fixing `type.Primitive`. 144 | * Fixing `facet.release` issues. 145 | 146 | ## v1.0.3 147 | 148 | * Exposing `Cursor` and `Facet` classes for type checking ([@charlieschwabacher](https://github.com/charlieschwabacher)). 149 | * Fixing `type.Object`. 150 | * Fixing root updates. 151 | 152 | ## v1.0.2 153 | 154 | * Fixing facets related issues (internal). 155 | * Fixing cases where falsy paths in cursors setters would fail the update. 156 | * Fixing `$splice` behavior. 157 | * Fixing `$merge` behavior. 158 | * Persistent history rather than deep cloned. 159 | * Improving performances on single update cases. 160 | 161 | ## v1.0.1 162 | 163 | * Fixing scope argument of `tree.createFacet`. 164 | * Fixing facet mappings edge cases. 165 | * Facets can now use facets. 166 | * Fixing merge edge cases. 167 | * Fixing update edge cases. 168 | * Fixing bug where setting falsy values would fail. 169 | 170 | ## v1.0.0 171 | 172 | * Dropping `cursor.edit` and `cursor.remove` in favor of `cursor.set` and `cursor.unset` polymorphisms. 173 | * Dropping `typology` dependency. 174 | * Dropping options: `clone`, `cloningFunction`, `singletonCursors`, `shiftReferences`, `maxHistory`, `mixins` and `typology`. 175 | * Updated `emmett` to `v3.0.0`. 176 | * Moving react integration to [baobab-react](https://github.com/Yomguithereal/baobab-react). 177 | * Shifting references is now default. 178 | * Adding facets. 179 | * Adding `$splice` keyword and `cursor.splice`. 180 | * Adding `validationBehavior` option. 181 | * Adding `$cursor` paths. 182 | * Adding path polymorphisms to every cursor's setters. 183 | * Reworking history to work at cursor level. 184 | * Reworking validation process. 185 | * Fixing some bugs. 186 | 187 | ## v0.4.4 188 | 189 | * Fixing `cursor.root`. 190 | * Fixing `cursor.release`. 191 | * Fixing build procedure for latest `node` and `browserify` versions. 192 | * I9 support. 193 | 194 | ## v0.4.3 195 | 196 | * Adding React mixins function polymorphisms thanks to [@denisw](https://github.com/denisw). 197 | * Fixing `cursor.chain` thanks to [@jonypawks](https://github.com/jonypawks). 198 | * Fixing transaction flow issues thanks to [@jmisterka](https://github.com/jmisterka). 199 | 200 | ## v0.4.2 201 | 202 | * Fixing deep object comparison and dynamic paths matching thanks to [@angus-c](https://github.com/angus-c). 203 | 204 | ## v0.4.1 205 | 206 | * Safer cursor update methods. 207 | * Fixing `cursor.chain`. 208 | * Fixing unset behavior when acting on lists. 209 | * Fixing release methods. 210 | * Path polymorphism for `tree/cursor.set`. 211 | * Adding `tree/cursor.root` method. 212 | * Reducing leak risks by making cursors and combinations lazier. 213 | 214 | ## v0.4.0 215 | 216 | * Several webpack-friendly changes. 217 | * Fixing complex paths solving. 218 | * Better `release` methods. 219 | * Tree instantiation minimal polymorphism. 220 | * Shooting gremlins in the head. 221 | * Better internals. 222 | * Implementing the `unset` and `remove` methods. 223 | 224 | ## v0.3.2 225 | 226 | * Bug fixes thanks to [@jacomyal](https://github.com/jacomyal), [@jondot](https://github.com/jondot). 227 | * Better perfs thanks to [@christianalfoni](https://github.com/christianalfoni). 228 | * `release` method for the tree. 229 | 230 | ## v0.3.1 231 | 232 | * Fixing reference shifting behaviours. 233 | * `release` method for cursors. 234 | 235 | ## v0.3.0 236 | 237 | * Exposing `getIn` helper. 238 | * Merged mixins are now executed after baobab's ones. 239 | * Cursor combinations. 240 | * Cursor data now available through component's state. 241 | * Retrieval and selection sugar with functions and descriptive objects. 242 | * Adding `referenceShifting` option. 243 | * Cursor predicates. 244 | * `$merge` command. 245 | * Various optimizations and bug fixes. 246 | 247 | ## v0.2.2 248 | 249 | * Updating dependencies. 250 | * Fixing several bugs. 251 | * Better unit testing for mixins. 252 | * `mixins` settings. 253 | * Bower support. 254 | 255 | ## v0.2.1 256 | 257 | * Several bug fixes. 258 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Baobab 2 | 3 | Contributions are obviously welcome. 4 | 5 | Be sure to add relevant unit tests, lint & build the code before submitting your pull request. 6 | 7 | ```bash 8 | # Installing the dev environment 9 | git clone git@github.com:Yomguithereal/baobab.git 10 | cd baobab 11 | npm install 12 | 13 | # Running the tests 14 | npm test 15 | 16 | # Linting & building 17 | npm run lint 18 | npm run build 19 | ``` 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2016 Guillaume Plique (Yomguithereal) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | var Benchmark = require('benchmark'), 2 | Baobab = require('./src/baobab.js'); 3 | 4 | var suite = new Benchmark.Suite(); 5 | 6 | var tree = new Baobab(); 7 | tree.set(['a', 'b', 'c', 'd', 'e', 'f', 'g'], []); 8 | 9 | var cursor = tree.select(['a', 'b', 'c']); 10 | 11 | suite 12 | .add('Baobab#get', function() { 13 | tree.get(['a', 'b', 'c', 'd']); 14 | }) 15 | .add('Baobab#set', function() { 16 | tree.set(['a', 'b', 'c', 'foo'], 'bar'); 17 | }) 18 | .add('Baobab#unset', function() { 19 | tree.unset(['a', 'b', 'c', 'foo']); 20 | }) 21 | .add('Baobab.Cursor#set', function() { 22 | cursor.set({ d: { e: { f: { g: [] } } } }); 23 | cursor.set(['d', 'e', 'f', 'foo'], 'bar'); 24 | }) 25 | .add('Baobab.Cursor#unset', function() { 26 | cursor.unset(['d', 'e', 'f', 'foo']); 27 | }) 28 | .add('Baobab.Cursor#push', function() { 29 | cursor.push(['d', 'e', 'f', 'g'], 'baobab'); 30 | }) 31 | .add('Baobab.Cursor#unshift', function() { 32 | cursor.unshift(['d', 'e', 'f', 'g'], 'baobab'); 33 | }) 34 | .add('Baobab.Cursor#splice', function() { 35 | cursor.splice(['d', 'e', 'f', 'g'], [1, 1, 'baobab']); 36 | }) 37 | .on('cycle', function(event) { 38 | console.log(String(event.target)); 39 | }) 40 | .run(); 41 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baobab", 3 | "main": "build/baobab.min.js", 4 | "version": "2.5.2", 5 | "homepage": "https://github.com/Yomguithereal/baobab", 6 | "author": { 7 | "name": "Guillaume Plique", 8 | "url": "http://github.com/Yomguithereal" 9 | }, 10 | "description": "JavaScript persistent data tree with cursors.", 11 | "keywords": [ 12 | "cursors", 13 | "atom", 14 | "tree", 15 | "react" 16 | ], 17 | "license": "MIT", 18 | "ignore": [ 19 | "**/.*", 20 | "node_modules", 21 | "bower_components", 22 | "test", 23 | "tests" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /build/baobab.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Baobab 3 | * 4 | * Homepage: https://github.com/Yomguithereal/baobab 5 | * Version: 2.6.1 6 | * Author: Yomguithereal (Guillaume Plique) 7 | * License: MIT 8 | */ 9 | !function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).Baobab=t()}}((function(){return function t(e,r,n){function i(o,s){if(!r[o]){if(!e[o]){var h="function"==typeof require&&require;if(!s&&h)return h(o,!0);if(a)return a(o,!0);var l=new Error("Cannot find module '"+o+"'");throw l.code="MODULE_NOT_FOUND",l}var u=r[o]={exports:{}};e[o][0].call(u.exports,(function(t){return i(e[o][1][t]||t)}),u,u.exports,t,e,r,n)}return r[o].exports}for(var a="function"==typeof require&&require,o=0;o1?t[e]=a(t[e],{once:!0}):t.push({once:!0}),this.on.apply(this,t)},h.prototype.off=function(t,e){var r,n,i,a;if(1===arguments.length&&"function"==typeof t){e=arguments[0];var h=Object.keys(this._handlers).concat(Object.getOwnPropertySymbols(this._handlers));for(r=0;r1&&(r.data=e),a.fn.call("scope"in a?a.scope:this,r),a.once&&d.push(a);for(l=d.length-1;l>=0;l--){var p=(n=d[l].type?this._handlers[d[l].type]:d[l].pattern?this._handlersComplex:this._handlersAll).indexOf(d[l]);-1!==p&&n.splice(p,1)}}return this},h.prototype.kill=function(){this.unbindAll(),this._handlers=null,this._handlersAll=null,this._handlersComplex=null,this._enabled=!1,this.unbindAll=this.on=this.once=this.off=this.emit=this.listeners=Function.prototype},h.prototype.disable=function(){return this._enabled=!1,this},h.prototype.enable=function(){return this._enabled=!0,this},h.version="3.2.0",e.exports=h},{}],2:[function(t,e,r){"use strict";r.__esModule=!0,r.helpers=r.default=r.VERSION=r.dynamic=r.monkey=void 0;var n=c(t("emmett")),i=c(t("./cursor"));r.Cursor=i.default;var a=t("./monkey");r.MonkeyDefinition=a.MonkeyDefinition,r.Monkey=a.Monkey;var o=c(t("./watcher")),s=c(t("./type"));r.type=s.default;var h=c(t("./update")),l=function(t){if(t&&t.__esModule)return t;if(null===t||"object"!=typeof t&&"function"!=typeof t)return{default:t};var e=u();if(e&&e.has(t))return e.get(t);var r={},n=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var i in t)if(Object.prototype.hasOwnProperty.call(t,i)){var a=n?Object.getOwnPropertyDescriptor(t,i):null;a&&(a.get||a.set)?Object.defineProperty(r,i,a):r[i]=t[i]}r.default=t,e&&e.set(t,r);return r}(t("./helpers"));function u(){if("function"!=typeof WeakMap)return null;var t=new WeakMap;return u=function(){return t},t}function c(t){return t&&t.__esModule?t:{default:t}}function f(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}r.helpers=l;var d=l.arrayFrom,p=l.coercePath,y=l.deepFreeze,v=l.getIn,g=l.makeError,m=l.deepClone,b=l.deepMerge,_=l.shallowClone,k=l.shallowMerge,w=l.hashPath,P={autoCommit:!0,asynchronous:!0,immutable:!0,lazyMonkeys:!0,monkeyBusiness:!0,persistent:!0,pure:!0,validate:null,validationBehavior:"rollback"},j=function(t){var e,r;function n(e,r){var n;if(n=t.call(this)||this,arguments.length<1&&(e={}),!s.default.object(e)&&!s.default.array(e))throw g("Baobab: invalid data.",{data:e});n.options=k({},P,r),n.options.persistent||(n.options.immutable=!1,n.options.pure=!1),n._identity="[object Baobab]",n._cursors={},n._future=null,n._transaction=[],n._affectedPathsIndex={},n._monkeys={},n._previousData=null,n._data=e,n.root=new i.default(f(n),[],"λ"),delete n.root.release,n.options.immutable&&y(n._data);var a=function(t){n[t]=function(){var e=this.root[t].apply(this.root,arguments);return e instanceof i.default?this:e}};["apply","clone","concat","deepClone","deepMerge","exists","get","push","merge","pop","project","serialize","set","shift","splice","unset","unshift"].forEach(a),n.options.monkeyBusiness&&n._refreshMonkeys();var o=n.validate();if(o)throw Error("Baobab: invalid data.",{error:o});return n}r=t,(e=n).prototype=Object.create(r.prototype),e.prototype.constructor=e,e.__proto__=r;var l=n.prototype;return l._refreshMonkeys=function(t,e,r){var n=this,i=function t(e,r){if(void 0===r&&(r=[]),e instanceof a.Monkey)return e.release(),void(0,h.default)(n._monkeys,r,{type:"unset"},{immutable:!1,persistent:!1,pure:!1});if(s.default.object(e))for(var i in e)t(e[i],r.concat(i))},o=function t(e,r){if(void 0===r&&(r=[]),e instanceof a.MonkeyDefinition||e instanceof a.Monkey){var i=new a.Monkey(n,r,e instanceof a.Monkey?e.definition:e);(0,h.default)(n._monkeys,r,{type:"set",value:i},{immutable:!1,persistent:!1,pure:!1})}else if(s.default.object(e))for(var o in e)t(e[o],r.concat(o))};if(arguments.length){var l=v(this._monkeys,e).data;l&&i(l,e),"unset"!==r&&o(t,e)}else o(this._data);return this},l.validate=function(t){var e=this.options,r=e.validate,n=e.validationBehavior;if("function"!=typeof r)return null;var i=r.call(this,this._previousData,this._data,t||[[]]);return i instanceof Error?("rollback"===n&&(this._data=this._previousData,this._affectedPathsIndex={},this._transaction=[],this._previousData=this._data),this.emit("invalid",{error:i}),i):null},l.select=function(t){if(t=t||[],arguments.length>1&&(t=d(arguments)),!s.default.path(t))throw g("Baobab.select: invalid path.",{path:t});t=[].concat(t);var e=w(t),r=this._cursors[e];return r||(r=new i.default(this,t,e),this._cursors[e]=r),this.emit("select",{path:t,cursor:r}),r},l.update=function(t,e){var r=this;if(t=p(t),!s.default.operationType(e.type))throw g('Baobab.update: unknown operation type "'+e.type+'".',{operation:e});var n=v(this._data,t),i=n.solvedPath,a=n.exists;if(!i)throw g("Baobab.update: could not solve the given path.",{path:i});var o=s.default.monkeyPath(this._monkeys,i);if(o&&i.length>o.length)throw g("Baobab.update: attempting to update a read-only path.",{path:i});if("unset"!==e.type||a){var l=e;if(/merge/i.test(e.type)){var u=v(this._monkeys,i).data;if(s.default.object(u)){l=_(l);var c=v(this._data,i).data;/deep/i.test(l.type)?l.value=b({},b({},c,m(u)),l.value):l.value=k({},b({},c,m(u)),l.value)}}this._transaction.length||(this._previousData=this._data);var f=(0,h.default)(this._data,i,l,this.options),d=f.data,y=f.node;if(!("data"in f))return y;var P=i.concat("push"===e.type?y.length-1:[]),j=w(P);return this._data=d,this._affectedPathsIndex[j]=!0,this._transaction.push(k({},e,{path:P})),this.options.monkeyBusiness&&this._refreshMonkeys(y,i,e.type),this.emit("write",{path:P}),this.options.autoCommit?this.options.asynchronous?(this._future||(this._future=setTimeout((function(){return r.commit()}),0)),y):(this.commit(),y):y}},l.commit=function(){if(!this._transaction.length)return this;this._future&&(this._future=clearTimeout(this._future));var t=Object.keys(this._affectedPathsIndex).map((function(t){return"λ"!==t?t.split("λ").slice(1):[]}));if(this.validate(t))return this;var e=this._transaction,r=this._previousData;return this._affectedPathsIndex={},this._transaction=[],this._previousData=this._data,this.emit("update",{paths:t,currentData:this._data,transaction:e,previousData:r}),this},l.getMonkey=function(t){t=p(t);var e=v(this._monkeys,[].concat(t)).data;return e instanceof a.Monkey?e:null},l.watch=function(t){return new o.default(this,t)},l.release=function(){var t;for(t in this.emit("release"),delete this.root,delete this._data,delete this._previousData,delete this._transaction,delete this._affectedPathsIndex,delete this._monkeys,this._cursors)this._cursors[t].release();delete this._cursors,this.kill()},l.toJSON=function(){return this.serialize()},l.toString=function(){return this._identity},n}(n.default);j.monkey=function(){for(var t=arguments.length,e=new Array(t),r=0;r1&&(t=(0,o.arrayFrom)(arguments)),this.tree.select(this.path.concat(t))},s.up=function(){return this.isRoot()?null:this.tree.select(this.path.slice(0,-1))},s.down=function(){if(l("down",this.solvedPath),!(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))},s.left=function(){l("left",this.solvedPath);var t=+this.solvedPath[this.solvedPath.length-1];if(isNaN(t))throw Error("Baobab.Cursor.left: cannot go left on a non-list type.");return t?this.tree.select(this.solvedPath.slice(0,-1).concat(t-1)):null},s.right=function(){l("right",this.solvedPath);var t=+this.solvedPath[this.solvedPath.length-1];if(isNaN(t))throw Error("Baobab.Cursor.right: cannot go right on a non-list type.");return t+1===this.up()._get().data.length?null:this.tree.select(this.solvedPath.slice(0,-1).concat(t+1))},s.leftmost=function(){l("leftmost",this.solvedPath);var t=+this.solvedPath[this.solvedPath.length-1];if(isNaN(t))throw Error("Baobab.Cursor.leftmost: cannot go left on a non-list type.");return this.tree.select(this.solvedPath.slice(0,-1).concat(0))},s.rightmost=function(){l("rightmost",this.solvedPath);var t=+this.solvedPath[this.solvedPath.length-1];if(isNaN(t))throw Error("Baobab.Cursor.rightmost: cannot go right on a non-list type.");var e=this.up()._get().data;return this.tree.select(this.solvedPath.slice(0,-1).concat(e.length-1))},s.map=function(t,e){l("map",this.solvedPath);var r=this._get().data,n=arguments.length;if(!a.default.array(r))throw Error("baobab.Cursor.map: cannot map a non-list type.");return r.map((function(i,a){return t.call(n>1?e:this,this.select(a),a,r)}),this)},s._get=function(t){if(void 0===t&&(t=[]),!a.default.path(t))throw(0,o.makeError)("Baobab.Cursor.getters: invalid path.",{path:t});return this.solvedPath?(0,o.getIn)(this.tree._data,this.solvedPath.concat(t)):{data:void 0,solvedPath:null,exists:!1}},s.exists=function(t){return t=(0,o.coercePath)(t),arguments.length>1&&(t=(0,o.arrayFrom)(arguments)),this._get(t).exists},s.get=function(t){t=(0,o.coercePath)(t),arguments.length>1&&(t=(0,o.arrayFrom)(arguments));var e=this._get(t),r=e.data,n=e.solvedPath;return this.tree.emit("get",{data:r,solvedPath:n,path:this.path.concat(t)}),r},s.clone=function(){var t=this.get.apply(this,arguments);return(0,o.shallowClone)(t)},s.deepClone=function(){var t=this.get.apply(this,arguments);return(0,o.deepClone)(t)},s.serialize=function(t){if(t=(0,o.coercePath)(t),arguments.length>1&&(t=(0,o.arrayFrom)(arguments)),!a.default.path(t))throw(0,o.makeError)("Baobab.Cursor.getters: invalid path.",{path:t});if(this.solvedPath){var e=this.solvedPath.concat(t),r=(0,o.deepClone)((0,o.getIn)(this.tree._data,e).data),n=(0,o.getIn)(this.tree._monkeys,e).data,s=function t(e,r){if(a.default.object(r)&&a.default.object(e))for(var n in r)r[n]instanceof i.Monkey?delete e[n]:t(e[n],r[n])};return s(r,n),r}},s.project=function(t){if(a.default.object(t)){var e={};for(var r in t)e[r]=this.get(t[r]);return e}if(a.default.array(t)){for(var n=[],i=0,s=t.length;i2)throw(0,o.makeError)("Baobab.Cursor."+t+": too many arguments.");if(1!==arguments.length||c[t]||(n=r,r=[]),r=(0,o.coercePath)(r),!a.default.path(r))throw(0,o.makeError)("Baobab.Cursor."+t+": invalid path.",{path:r});if(e&&!e(n))throw(0,o.makeError)("Baobab.Cursor."+t+": invalid value.",{path:r,value:n});if(!this.solvedPath)throw(0,o.makeError)("Baobab.Cursor."+t+": the dynamic path of the cursor cannot be solved.",{path:this.path});var i=this.solvedPath.concat(r);return this.tree.update(i,{type:t,value:n})}}f("set"),f("unset"),f("apply",a.default.function),f("push"),f("concat",a.default.array),f("unshift"),f("pop"),f("shift"),f("splice",a.default.splicer),f("merge",a.default.object),f("deepMerge",a.default.object)},{"./helpers":4,"./monkey":5,"./type":6,emmett:1}],4:[function(t,e,r){(function(e){"use strict";r.__esModule=!0,r.arrayFrom=function(t){return h(t)},r.before=function(t,e){return function(){t.apply(null,arguments),e.apply(null,arguments)}},r.coercePath=function(t){return t||0===t||""===t?t:[]},r.getIn=function(t,e){if(!e)return g;var r,n,i,o=[],h=!0,l=t;for(n=0,i=e.length;n3?n-3:0),o=3;o=0?t.slice(0,e).concat(i).concat(t.slice(e+r)):t.slice(0,t.length+e).concat(i).concat(t.slice(t.length+e+r))},r.uniqid=r.deepMerge=r.shallowMerge=r.deepFreeze=r.freeze=r.deepClone=r.shallowClone=r.Archive=void 0;var n,i=t("./monkey"),a=(n=t("./type"))&&n.__esModule?n:{default:n};var o={}.hasOwnProperty;function s(t,e){var r,n;for(r=0,n=t.length;rthis.size&&(this.records.length=this.size),this},e.clear=function(){return this.records=[],this},e.back=function(t){var e=this.records[t-1];return e&&(this.records=this.records.slice(t)),e},t}();function u(t,r){if(!r||"object"!=typeof r||r instanceof Error||r instanceof i.MonkeyDefinition||r instanceof i.Monkey||"ArrayBuffer"in e&&r instanceof ArrayBuffer)return r;if(a.default.array(r)){if(t){for(var n=new Array(r.length),o=0,s=r.length;o1?e-1:0),n=1;n1&&isNaN(+t[1]))&&a(t[0],["number","function","object"]))};var o=["string","number","function","object"];i.path=function(t){return!(!t&&0!==t&&""!==t)&&[].concat(t).every((function(t){return a(t,o)}))},i.dynamicPath=function(t){return t.some((function(t){return i.function(t)||i.object(t)}))},i.monkeyPath=function(t,e){var r,i,a=[],o=t;for(r=0,i=e.length;r0&&v.push(l),s===h-1){if("set"===u){if(n.pure&&g[l]===c)return{node:g[l]};i.default.lazyGetter(g,l)?Object.defineProperty(g,l,{value:c,enumerable:!0,configurable:!0}):n.persistent&&!d.mutableLeaf?g[l]=(0,a.shallowClone)(c):g[l]=c}else if("monkey"===u)Object.defineProperty(g,l,{get:c,enumerable:!0,configurable:!0});else if("apply"===u){var m=c(g[l]);if(n.pure&&g[l]===m)return{node:g[l]};i.default.lazyGetter(g,l)?Object.defineProperty(g,l,{value:m,enumerable:!0,configurable:!0}):n.persistent?g[l]=(0,a.shallowClone)(m):g[l]=m}else if("push"===u){if(!i.default.array(g[l]))throw o("push","array",v);n.persistent?g[l]=g[l].concat([c]):g[l].push(c)}else if("unshift"===u){if(!i.default.array(g[l]))throw o("unshift","array",v);n.persistent?g[l]=[c].concat(g[l]):g[l].unshift(c)}else if("concat"===u){if(!i.default.array(g[l]))throw o("concat","array",v);n.persistent?g[l]=g[l].concat(c):g[l].push.apply(g[l],c)}else if("splice"===u){if(!i.default.array(g[l]))throw o("splice","array",v);n.persistent?g[l]=a.splice.apply(null,[g[l]].concat(c)):g[l].splice.apply(g[l],c)}else if("pop"===u){if(!i.default.array(g[l]))throw o("pop","array",v);n.persistent?g[l]=(0,a.splice)(g[l],-1,1):g[l].pop()}else if("shift"===u){if(!i.default.array(g[l]))throw o("shift","array",v);n.persistent?g[l]=(0,a.splice)(g[l],0,1):g[l].shift()}else if("unset"===u)i.default.object(g)?delete g[l]:i.default.array(g)&&g.splice(l,1);else if("merge"===u){if(!i.default.object(g[l]))throw o("merge","object",v);n.persistent?g[l]=(0,a.shallowMerge)({},g[l],c):g[l]=(0,a.shallowMerge)(g[l],c)}else if("deepMerge"===u){if(!i.default.object(g[l]))throw o("deepMerge","object",v);n.persistent?g[l]=(0,a.deepMerge)({},g[l],c):g[l]=(0,a.deepMerge)(g[l],c)}n.immutable&&!d.mutableLeaf&&(0,a.deepFreeze)(g);break}i.default.primitive(g[l])?g[l]={}:n.persistent&&(g[l]=(0,a.shallowClone)(g[l])),n.immutable&&h>0&&(0,a.freeze)(g),g=g[l]}return i.default.lazyGetter(g,l)?{data:p.root}:{data:p.root,node:g[l]}};var n,i=(n=t("./type"))&&n.__esModule?n:{default:n},a=t("./helpers");function o(t,e,r){return(0,a.makeError)('Baobab.update: cannot apply the "'+t+'" on a non '+e+" (path: /"+r.join("/")+").",{path:r})}},{"./helpers":4,"./type":6}],8:[function(t,e,r){"use strict";r.__esModule=!0,r.default=void 0;var n=s(t("emmett")),i=s(t("./cursor")),a=s(t("./type")),o=t("./helpers");function s(t){return t&&t.__esModule?t:{default:t}}var h=function(t){var e,r;function n(e,r){var n;return(n=t.call(this)||this).tree=e,n.mapping=null,n.state={killed:!1},n.refresh(r),n.handler=function(t){if(!n.state.killed){var e=n.getWatchedPaths();return(0,o.solveUpdate)(t.data.paths,e)?n.emit("update"):void 0}},n.tree.on("update",n.handler),n}r=t,(e=n).prototype=Object.create(r.prototype),e.prototype.constructor=e,e.__proto__=r;var s=n.prototype;return s.getWatchedPaths=function(){var t=this;return Object.keys(this.mapping).map((function(e){var r=t.mapping[e];return r instanceof i.default?r.solvedPath:t.mapping[e]})).reduce((function(e,r){if(r=[].concat(r),a.default.dynamicPath(r)&&(r=(0,o.getIn)(t.tree._data,r).solvedPath),!r)return e;var n=a.default.monkeyPath(t.tree._monkeys,r);return n?e.concat((0,o.getIn)(t.tree._monkeys,n).data.relatedPaths()):e.concat([r])}),[])},s.getCursors=function(){var t=this,e={};return Object.keys(this.mapping).forEach((function(r){var n=t.mapping[r];n instanceof i.default?e[r]=n:e[r]=t.tree.select(n)})),e},s.refresh=function(t){if(!a.default.watcherMapping(t))throw(0,o.makeError)("Baobab.watch: invalid mapping.",{mapping:t});this.mapping=t;var e={};for(var r in t)e[r]=t[r]instanceof i.default?t[r].path:t[r];this.get=this.tree.project.bind(this.tree,e)},s.release=function(){this.tree.off("update",this.handler),this.state.killed=!0,this.kill()},n}(n.default);r.default=h},{"./cursor":3,"./helpers":4,"./type":6,emmett:1}]},{},[2])(2)})); 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@yomguithereal/eslint-config/es6' 4 | ].map(require.resolve), 5 | rules: { 6 | 'no-loop-func': 0 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baobab", 3 | "version": "2.6.1", 4 | "description": "JavaScript persistent data tree with cursors.", 5 | "main": "./dist/baobab.js", 6 | "dependencies": { 7 | "emmett": "^3.2.0" 8 | }, 9 | "devDependencies": { 10 | "@babel/cli": "^7.7.4", 11 | "@babel/core": "^7.7.4", 12 | "@babel/node": "^7.7.4", 13 | "@babel/preset-env": "7.7.4", 14 | "@babel/register": "^7.7.4", 15 | "@types/async": "^3.0.7", 16 | "@types/expect": "^24.3.0", 17 | "@types/lodash": "^4.14.149", 18 | "@types/mocha": "^7.0.1", 19 | "@types/node": "^13.7.0", 20 | "@yomguithereal/eslint-config": "^4.0.0", 21 | "async": "^3.1.0", 22 | "babelify": "^10.0.0", 23 | "benchmark": "^2.1.4", 24 | "browserify": "^16.5.0", 25 | "eslint": "^6.7.2", 26 | "fs-extra": "^8.1.0", 27 | "lodash": "^4.17.4", 28 | "mkdirp": "^0.5.1", 29 | "mocha": "^7.0.1", 30 | "terser": "^4.4.2", 31 | "ts-mocha": "^6.0.0", 32 | "tslint": "^6.0.0", 33 | "typescript": "^3.7.5" 34 | }, 35 | "scripts": { 36 | "benchmark": "babel-node --presets @babel/preset-env benchmark.js", 37 | "build": "node ./scripts/build.js", 38 | "check": "npm test && npm run lint && npm run build", 39 | "dist:addendum": "cat scripts/commonjs-addendum.js >> dist/baobab.js", 40 | "dist": "babel ./src --out-dir dist --presets @babel/preset-env && cp src/baobab.d.ts dist/. && npm run dist:addendum", 41 | "lint": "eslint -c eslint.config.js ./src ./test && tslint src/baobab.d.ts test/suites/*.ts", 42 | "prepublish": "npm run check && npm run dist", 43 | "test:commonjs": "node scripts/test-commonjs.js", 44 | "test:es6-import": "babel --presets @babel/preset-env scripts/test-es6-import.js | node", 45 | "test": "ts-mocha --reporter spec --require test/register.js test/suites/*.ts" 46 | }, 47 | "repository": { 48 | "type": "git", 49 | "url": "https://github.com/Yomguithereal/baobab.git" 50 | }, 51 | "keywords": [ 52 | "cursors", 53 | "atom", 54 | "tree", 55 | "react" 56 | ], 57 | "author": { 58 | "name": "Guillaume Plique", 59 | "url": "http://github.com/Yomguithereal" 60 | }, 61 | "license": "MIT", 62 | "bugs": { 63 | "url": "https://github.com/Yomguithereal/baobab/issues" 64 | }, 65 | "homepage": "https://github.com/Yomguithereal/baobab" 66 | } 67 | -------------------------------------------------------------------------------- /scripts/banner.tmpl: -------------------------------------------------------------------------------- 1 | /* 2 | * <%= name %> 3 | * 4 | * Homepage: <%= homepage %> 5 | * Version: <%= version %> 6 | * Author: Yomguithereal (Guillaume Plique) 7 | * License: MIT 8 | */ 9 | <%= code %> 10 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs-extra'), 2 | path = require('path'), 3 | browserify = require('browserify'), 4 | compileTemplate = require('lodash/template'), 5 | Terser = require('terser'); 6 | 7 | var pkg = require('../package.json'); 8 | 9 | var TEMPLATE_PATH = path.join(__dirname, 'banner.tmpl'); 10 | var ENPOINT_PATH = path.join(__dirname, '..', 'src', 'baobab.js'); 11 | 12 | var BUILD_PATH = path.join(__dirname, '..', 'build'); 13 | var CONCAT_PATH = path.join(BUILD_PATH, 'baobab.js'); 14 | var MINIFIED_PATH = path.join(BUILD_PATH, 'baobab.min.js'); 15 | 16 | var BANNER_TEMPLATE = compileTemplate(fs.readFileSync(TEMPLATE_PATH, 'utf-8')); 17 | 18 | browserify(ENPOINT_PATH, {standalone: 'Baobab'}) 19 | .transform('babelify', {presets: [['@babel/preset-env', {loose: true}]]}) 20 | .bundle(function(err, buffer) { 21 | if (err) { 22 | console.error(err); 23 | process.exit(1); 24 | } 25 | 26 | fs.ensureDirSync(BUILD_PATH); 27 | 28 | var commonTemplateData = { 29 | name: 'Baobab', 30 | homepage: pkg.homepage, 31 | version: pkg.version 32 | }; 33 | 34 | var baobabCode = buffer.toString(); 35 | 36 | var minifiedCode = Terser.minify(baobabCode).code; 37 | 38 | fs.writeFileSync(CONCAT_PATH, BANNER_TEMPLATE(Object.assign({code: baobabCode}, commonTemplateData))); 39 | fs.writeFileSync(MINIFIED_PATH, BANNER_TEMPLATE(Object.assign({code: minifiedCode}, commonTemplateData))); 40 | }); 41 | -------------------------------------------------------------------------------- /scripts/commonjs-addendum.js: -------------------------------------------------------------------------------- 1 | 2 | for (var exportedName in exports) 3 | Baobab[exportedName] = exports[exportedName]; 4 | 5 | module.exports = Baobab; 6 | -------------------------------------------------------------------------------- /scripts/test-commonjs.js: -------------------------------------------------------------------------------- 1 | var Baobab = require('../dist/baobab'); 2 | 3 | console.log(Baobab, new Baobab(), Baobab.helpers); 4 | -------------------------------------------------------------------------------- /scripts/test-es6-import.js: -------------------------------------------------------------------------------- 1 | import Baobab, {helpers} from './dist/baobab'; 2 | 3 | console.log(Baobab, new Baobab(), helpers); 4 | -------------------------------------------------------------------------------- /src/baobab.d.ts: -------------------------------------------------------------------------------- 1 | import Emitter from 'emmett'; 2 | 3 | interface PlainObject { 4 | [key: string]: T; 5 | } 6 | 7 | type Predicate = (data: any) => boolean; 8 | type Constraints = PlainObject; 9 | type PathKey = string | number; 10 | type PathElement = PathKey | Predicate | Constraints; 11 | export type Path = PathElement[] | PathKey; 12 | 13 | type Splicer = [number | PlainObject | ((...args: any[]) => any), ...any[]]; 14 | 15 | /** 16 | * This class is empty purposely. Baobab must be able to identify in an initial 17 | * state when it has to deal with Monkeys instanciation, and uses this dummy 18 | * class in that purpose. 19 | */ 20 | export class MonkeyDefinition { 21 | // Empty class intended 22 | } 23 | 24 | export class Monkey { 25 | // TODO 26 | } 27 | 28 | export interface BaobabOptions { 29 | autoCommit: boolean; 30 | asynchronous: boolean; 31 | immutable: boolean; 32 | lazyMonkeys: boolean; 33 | monkeyBusiness: boolean; 34 | persistent: boolean; 35 | pure: boolean; 36 | validate: null | ((previousData: any, data: any, affectedPaths?: Path[]) => (Error | undefined)); 37 | validationBehavior: string; 38 | } 39 | 40 | export interface MonkeyOptions { 41 | immutable: boolean; 42 | } 43 | 44 | /** 45 | * This class only exists to group methods that are common to the Baobab and 46 | * Cursor classes. Since `Baobab.root` is a property while `Cursor#root` is a 47 | * method, Baobab cannot extend Cursor. 48 | */ 49 | export abstract class CommonBaobabMethods extends Emitter { 50 | apply(path: Path, value: (state: any) => any): any; 51 | apply(value: (state: any) => any): any; 52 | 53 | clone(...args: PathElement[]): any; 54 | clone(path?: Path): any; 55 | 56 | concat(path: Path, value: any[]): any; 57 | concat(value: any[]): any; 58 | 59 | deepClone(...args: PathElement[]): any; 60 | deepClone(path?: Path): any; 61 | 62 | deepMerge(path: Path, value: PlainObject): any; 63 | deepMerge(value: PlainObject): any; 64 | 65 | exists(...args: PathElement[]): boolean; 66 | exists(path?: Path): boolean; 67 | 68 | get(...args: PathElement[]): any; 69 | get(path: Path): any; 70 | 71 | merge(path: Path, value: PlainObject): any; 72 | merge(value: PlainObject): any; 73 | 74 | pop(path?: Path): any; 75 | 76 | project(projection: (Path)[]): any[]; 77 | project(projection: PlainObject): PlainObject; 78 | 79 | push(path: Path, value: any): any; 80 | push(value: any): any; 81 | 82 | release(): void; 83 | 84 | select(...args: PathElement[]): Cursor; 85 | select(path: Path): Cursor; 86 | 87 | serialize(...args: PathElement[]): any; 88 | serialize(path: Path): any; 89 | 90 | set(path: Path, value: any): any; 91 | set(value: any): any; 92 | 93 | shift(path?: Path): any; 94 | 95 | splice(path: Path, value: Splicer): any; 96 | splice(value: Splicer): any; 97 | 98 | unset(path?: Path): any; 99 | 100 | unshift(path: Path, value: any): any; 101 | unshift(value: any): any; 102 | } 103 | 104 | export class Watcher extends Emitter { 105 | constructor(tree: Baobab, mapping: PlainObject); 106 | 107 | get(): PlainObject; 108 | getWatchedPaths(): Path[]; 109 | getCursors(): PlainObject; 110 | refresh(mappings: PlainObject): void; 111 | release(): void; 112 | } 113 | 114 | export class Cursor extends CommonBaobabMethods implements Iterable { 115 | path?: Path; 116 | solvedPath?: PathKey[]; 117 | state: { 118 | killed: boolean; 119 | recording: boolean; 120 | undoing: boolean; 121 | }; 122 | 123 | [Symbol.iterator](): IterableIterator; 124 | 125 | // Navigation: 126 | up(): Cursor | null; 127 | down(): Cursor; 128 | left(): Cursor | null; 129 | right(): Cursor | null; 130 | leftmost(): Cursor | null; 131 | rightmost(): Cursor | null; 132 | root(): Cursor; 133 | 134 | // Predicates: 135 | isLeaf(): boolean; 136 | isRoot(): boolean; 137 | isBranch(): boolean; 138 | 139 | // History: 140 | hasHistory(): boolean; 141 | getHistory(): any[]; 142 | clearHistory(): this; 143 | startRecording(maxRecords?: number): this; 144 | stopRecording(): this; 145 | undo(steps?: number): this; 146 | 147 | // Others: 148 | toJSON(): string; 149 | toString(): string; 150 | 151 | map(fn: (v: any, index?: number) => any, scope?: any): any[]; 152 | } 153 | 154 | export class Baobab extends CommonBaobabMethods { 155 | constructor(initialState?: PlainObject, options?: Partial); 156 | 157 | root: Cursor; 158 | options: BaobabOptions; 159 | 160 | update( 161 | path: Path, 162 | operation: { 163 | type: string, 164 | value: any, 165 | options?: { 166 | mutableLeaf?: boolean 167 | } 168 | } 169 | ): this; 170 | 171 | commit(): this; 172 | 173 | getMonkey(path: Path): Monkey; 174 | 175 | watch(mappings: PlainObject): Watcher; 176 | 177 | static monkey(definition: { cursors?: PlainObject; get(data: PlainObject): any; options?: MonkeyOptions }): MonkeyDefinition; 178 | 179 | /* tslint:disable:unified-signatures */ 180 | // Polymorphisms for: 181 | // `.monkey(...paths: Path[], get: (v1: any) => any)` 182 | static monkey(path1: Path, get: (value: any) => any, options?: MonkeyOptions): MonkeyDefinition; 183 | static monkey(path1: Path, path2: Path, get: (...values: [any, any]) => any, options?: MonkeyOptions): MonkeyDefinition; 184 | static monkey(path1: Path, path2: Path, path3: Path, get: (...values: [any, any, any]) => any, options?: MonkeyOptions): MonkeyDefinition; 185 | static monkey(path1: Path, path2: Path, path3: Path, path4: Path, get: (...values: [any, any, any, any]) => any, options?: MonkeyOptions): MonkeyDefinition; 186 | static monkey(path1: Path, path2: Path, path3: Path, path4: Path, path5: Path, get: (...values: [any, any, any, any, any]) => any, options?: MonkeyOptions): MonkeyDefinition; 187 | // Fallback: 188 | static monkey(...pathsEndingWithGetAndMaybeOptions: (Path | ((...values: any[]) => any) | MonkeyOptions)[]): MonkeyDefinition; 189 | 190 | // Polymorphisms for: 191 | // `.monkey(definition: [...paths: Path[], get: (v1: any) => any])` 192 | static monkey(args: [Path, (value: any) => any], options?: MonkeyOptions): MonkeyDefinition; 193 | static monkey(args: [Path, Path, (...values: [any, any]) => any], options?: MonkeyOptions): MonkeyDefinition; 194 | static monkey(args: [Path, Path, Path, (...values: [any, any, any]) => any], options?: MonkeyOptions): MonkeyDefinition; 195 | static monkey(args: [Path, Path, Path, Path, (...values: [any, any, any, any]) => any], options?: MonkeyOptions): MonkeyDefinition; 196 | static monkey(args: [Path, Path, Path, Path, Path, (...values: [any, any, any, any, any]) => any], options?: MonkeyOptions): MonkeyDefinition; 197 | // Fallback: 198 | static monkey(pathsEndingWithGet: (Path | ((...values: any[]) => any) | MonkeyOptions)[]): MonkeyDefinition; 199 | /* tslint:enable:unified-signatures */ 200 | 201 | static dynamicNode: typeof Baobab.monkey; 202 | } 203 | 204 | export default Baobab; 205 | -------------------------------------------------------------------------------- /src/baobab.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Data Structure 3 | * ====================== 4 | * 5 | * A handy data tree with cursors. 6 | */ 7 | import Emitter from 'emmett'; 8 | import Cursor from './cursor'; 9 | import {MonkeyDefinition, Monkey} from './monkey'; 10 | import Watcher from './watcher'; 11 | import type from './type'; 12 | import update from './update'; 13 | import * as helpers from './helpers'; 14 | 15 | const { 16 | arrayFrom, 17 | coercePath, 18 | deepFreeze, 19 | getIn, 20 | makeError, 21 | deepClone, 22 | deepMerge, 23 | shallowClone, 24 | shallowMerge, 25 | hashPath 26 | } = helpers; 27 | 28 | /** 29 | * Baobab defaults 30 | */ 31 | const DEFAULTS = { 32 | 33 | // Should the tree handle its transactions on its own? 34 | autoCommit: true, 35 | 36 | // Should the transactions be handled asynchronously? 37 | asynchronous: true, 38 | 39 | // Should the tree's data be immutable? 40 | immutable: true, 41 | 42 | // Should the monkeys be lazy? 43 | lazyMonkeys: true, 44 | 45 | // Should we evaluate monkeys? 46 | monkeyBusiness: true, 47 | 48 | // Should the tree be persistent? 49 | persistent: true, 50 | 51 | // Should the tree's update be pure? 52 | pure: true, 53 | 54 | // Validation specifications 55 | validate: null, 56 | 57 | // Validation behavior 'rollback' or 'notify' 58 | validationBehavior: 'rollback' 59 | }; 60 | 61 | 62 | /** 63 | * Baobab class 64 | * 65 | * @constructor 66 | * @param {object|array} [initialData={}] - Initial data passed to the tree. 67 | * @param {object} [opts] - Optional options. 68 | * @param {boolean} [opts.autoCommit] - Should the tree auto-commit? 69 | * @param {boolean} [opts.asynchronous] - Should the tree's transactions 70 | * handled asynchronously? 71 | * @param {boolean} [opts.immutable] - Should the tree be immutable? 72 | * @param {boolean} [opts.persistent] - Should the tree be persistent? 73 | * @param {boolean} [opts.pure] - Should the tree be pure? 74 | * @param {function} [opts.validate] - Validation function. 75 | * @param {string} [opts.validationBehaviour] - "rollback" or "notify". 76 | */ 77 | class Baobab extends Emitter { 78 | constructor(initialData, opts) { 79 | super(); 80 | 81 | // Setting initialData to an empty object if no data is provided by use 82 | if (arguments.length < 1) 83 | initialData = {}; 84 | 85 | // Checking whether given initial data is valid 86 | if (!type.object(initialData) && !type.array(initialData)) 87 | throw makeError('Baobab: invalid data.', {data: initialData}); 88 | 89 | // Merging given options with defaults 90 | this.options = shallowMerge({}, DEFAULTS, opts); 91 | 92 | // Disabling immutability & persistence if persistence if disabled 93 | if (!this.options.persistent) { 94 | this.options.immutable = false; 95 | this.options.pure = false; 96 | } 97 | 98 | // Privates 99 | this._identity = '[object Baobab]'; 100 | this._cursors = {}; 101 | this._future = null; 102 | this._transaction = []; 103 | this._affectedPathsIndex = {}; 104 | this._monkeys = {}; 105 | this._previousData = null; 106 | this._data = initialData; 107 | 108 | // Properties 109 | this.root = new Cursor(this, [], 'λ'); 110 | delete this.root.release; 111 | 112 | // Does the user want an immutable tree? 113 | if (this.options.immutable) 114 | deepFreeze(this._data); 115 | 116 | // Bootstrapping root cursor's getters and setters 117 | const bootstrap = (name) => { 118 | this[name] = function() { 119 | const r = this.root[name].apply(this.root, arguments); 120 | return r instanceof Cursor ? this : r; 121 | }; 122 | }; 123 | 124 | [ 125 | 'apply', 126 | 'clone', 127 | 'concat', 128 | 'deepClone', 129 | 'deepMerge', 130 | 'exists', 131 | 'get', 132 | 'push', 133 | 'merge', 134 | 'pop', 135 | 'project', 136 | 'serialize', 137 | 'set', 138 | 'shift', 139 | 'splice', 140 | 'unset', 141 | 'unshift' 142 | ].forEach(bootstrap); 143 | 144 | // Registering the initial monkeys 145 | if (this.options.monkeyBusiness) { 146 | this._refreshMonkeys(); 147 | } 148 | 149 | // Initial validation 150 | const validationError = this.validate(); 151 | 152 | if (validationError) 153 | throw Error('Baobab: invalid data.', {error: validationError}); 154 | } 155 | 156 | /** 157 | * Internal method used to refresh the tree's monkey register on every 158 | * update. 159 | * Note 1) For the time being, placing monkeys beneath array nodes is not 160 | * allowed for performance reasons. 161 | * 162 | * @param {mixed} node - The starting node. 163 | * @param {array} path - The starting node's path. 164 | * @param {string} operation - The operation that lead to a refreshment. 165 | * @return {Baobab} - The tree instance for chaining purposes. 166 | */ 167 | _refreshMonkeys(node, path, operation) { 168 | 169 | const clean = (data, p = []) => { 170 | if (data instanceof Monkey) { 171 | data.release(); 172 | update(this._monkeys, p, {type: 'unset'}, { 173 | immutable: false, 174 | persistent: false, 175 | pure: false 176 | }); 177 | 178 | return; 179 | } 180 | 181 | if (type.object(data)) { 182 | for (const k in data) 183 | clean(data[k], p.concat(k)); 184 | } 185 | }; 186 | 187 | const walk = (data, p = []) => { 188 | 189 | // Should we sit a monkey in the tree? 190 | if (data instanceof MonkeyDefinition || 191 | data instanceof Monkey) { 192 | const monkeyInstance = new Monkey( 193 | this, 194 | p, 195 | data instanceof Monkey ? data.definition : data 196 | ); 197 | 198 | update(this._monkeys, p, {type: 'set', value: monkeyInstance}, { 199 | immutable: false, 200 | persistent: false, 201 | pure: false 202 | }); 203 | 204 | return; 205 | } 206 | 207 | // Object iteration 208 | if (type.object(data)) { 209 | for (const k in data) 210 | walk(data[k], p.concat(k)); 211 | } 212 | }; 213 | 214 | // Walking the whole tree 215 | if (!arguments.length) { 216 | walk(this._data); 217 | } 218 | else { 219 | const monkeysNode = getIn(this._monkeys, path).data; 220 | 221 | // Is this required that we clean some already existing monkeys? 222 | if (monkeysNode) 223 | clean(monkeysNode, path); 224 | 225 | // Let's walk the tree only from the updated point 226 | if (operation !== 'unset') { 227 | walk(node, path); 228 | } 229 | } 230 | 231 | return this; 232 | } 233 | 234 | /** 235 | * Method used to validate the tree's data. 236 | * 237 | * @return {boolean} - Is the tree valid? 238 | */ 239 | validate(affectedPaths) { 240 | const {validate, validationBehavior: behavior} = this.options; 241 | 242 | if (typeof validate !== 'function') 243 | return null; 244 | 245 | const error = validate.call( 246 | this, 247 | this._previousData, 248 | this._data, 249 | affectedPaths || [[]] 250 | ); 251 | 252 | if (error instanceof Error) { 253 | 254 | if (behavior === 'rollback') { 255 | this._data = this._previousData; 256 | this._affectedPathsIndex = {}; 257 | this._transaction = []; 258 | this._previousData = this._data; 259 | } 260 | 261 | this.emit('invalid', {error}); 262 | 263 | return error; 264 | } 265 | 266 | return null; 267 | } 268 | 269 | /** 270 | * Method used to select data within the tree by creating a cursor. Cursors 271 | * are kept as singletons by the tree for performance and hygiene reasons. 272 | * 273 | * Arity (1): 274 | * @param {path} path - Path to select in the tree. 275 | * 276 | * Arity (*): 277 | * @param {...step} path - Path to select in the tree. 278 | * 279 | * @return {Cursor} - The resultant cursor. 280 | */ 281 | select(path) { 282 | 283 | // If no path is given, we simply return the root 284 | path = path || []; 285 | 286 | // Variadic 287 | if (arguments.length > 1) 288 | path = arrayFrom(arguments); 289 | 290 | // Checking that given path is valid 291 | if (!type.path(path)) 292 | throw makeError('Baobab.select: invalid path.', {path}); 293 | 294 | // Casting to array 295 | path = [].concat(path); 296 | 297 | // Computing hash (done here because it would be too late to do it in the 298 | // cursor's constructor since we need to hit the cursors' index first). 299 | const hash = hashPath(path); 300 | 301 | // Creating a new cursor or returning the already existing one for the 302 | // requested path. 303 | let cursor = this._cursors[hash]; 304 | 305 | if (!cursor) { 306 | cursor = new Cursor(this, path, hash); 307 | this._cursors[hash] = cursor; 308 | } 309 | 310 | // Emitting an event to notify that a part of the tree was selected 311 | this.emit('select', {path, cursor}); 312 | return cursor; 313 | } 314 | 315 | /** 316 | * Method used to update the tree. Updates are simply expressed by a path, 317 | * dynamic or not, and an operation. 318 | * 319 | * This is where path solving should happen and not in the cursor. 320 | * 321 | * @param {path} path - The path where we'll apply the operation. 322 | * @param {object} operation - The operation to apply. 323 | * @return {mixed} - Return the result of the update. 324 | */ 325 | update(path, operation) { 326 | 327 | // Coercing path 328 | path = coercePath(path); 329 | 330 | if (!type.operationType(operation.type)) 331 | throw makeError( 332 | `Baobab.update: unknown operation type "${operation.type}".`, 333 | {operation} 334 | ); 335 | 336 | // Solving the given path 337 | const {solvedPath, exists} = getIn( 338 | this._data, 339 | path 340 | ); 341 | 342 | // If we couldn't solve the path, we throw 343 | if (!solvedPath) 344 | throw makeError('Baobab.update: could not solve the given path.', { 345 | path: solvedPath 346 | }); 347 | 348 | // Read-only path? 349 | const monkeyPath = type.monkeyPath(this._monkeys, solvedPath); 350 | if (monkeyPath && solvedPath.length > monkeyPath.length) 351 | throw makeError('Baobab.update: attempting to update a read-only path.', { 352 | path: solvedPath 353 | }); 354 | 355 | // We don't unset irrelevant paths 356 | if (operation.type === 'unset' && !exists) 357 | return; 358 | 359 | // If we merge data, we need to acknowledge monkeys 360 | let realOperation = operation; 361 | if (/merge/i.test(operation.type)) { 362 | const monkeysNode = getIn(this._monkeys, solvedPath).data; 363 | 364 | if (type.object(monkeysNode)) { 365 | 366 | // Cloning the operation not to create weird behavior for the user 367 | realOperation = shallowClone(realOperation); 368 | 369 | // Fetching the existing node in the current data 370 | const currentNode = getIn(this._data, solvedPath).data; 371 | 372 | if (/deep/i.test(realOperation.type)) 373 | realOperation.value = deepMerge({}, 374 | deepMerge({}, currentNode, deepClone(monkeysNode)), 375 | realOperation.value 376 | ); 377 | else 378 | realOperation.value = shallowMerge({}, 379 | deepMerge({}, currentNode, deepClone(monkeysNode)), 380 | realOperation.value 381 | ); 382 | } 383 | } 384 | 385 | // Stashing previous data if this is the frame's first update 386 | if (!this._transaction.length) 387 | this._previousData = this._data; 388 | 389 | // Applying the operation 390 | const result = update( 391 | this._data, 392 | solvedPath, 393 | realOperation, 394 | this.options 395 | ); 396 | 397 | const {data, node} = result; 398 | 399 | // If because of purity, the update was moot, we stop here 400 | if (!('data' in result)) 401 | return node; 402 | 403 | // If the operation is push, the affected path is slightly different 404 | const affectedPath = solvedPath.concat( 405 | operation.type === 'push' ? node.length - 1 : [] 406 | ); 407 | 408 | const hash = hashPath(affectedPath); 409 | 410 | // Updating data and transaction 411 | this._data = data; 412 | this._affectedPathsIndex[hash] = true; 413 | this._transaction.push(shallowMerge({}, operation, {path: affectedPath})); 414 | 415 | // Updating the monkeys 416 | if (this.options.monkeyBusiness) { 417 | this._refreshMonkeys(node, solvedPath, operation.type); 418 | } 419 | 420 | // Emitting a `write` event 421 | this.emit('write', {path: affectedPath}); 422 | 423 | // Should we let the user commit? 424 | if (!this.options.autoCommit) 425 | return node; 426 | 427 | // Should we update asynchronously? 428 | if (!this.options.asynchronous) { 429 | this.commit(); 430 | return node; 431 | } 432 | 433 | // Updating asynchronously 434 | if (!this._future) 435 | this._future = setTimeout(() => this.commit(), 0); 436 | 437 | // Finally returning the affected node 438 | return node; 439 | } 440 | 441 | /** 442 | * Method committing the updates of the tree and firing the tree's events. 443 | * 444 | * @return {Baobab} - The tree instance for chaining purposes. 445 | */ 446 | commit() { 447 | 448 | // Do not fire update if the transaction is empty 449 | if (!this._transaction.length) 450 | return this; 451 | 452 | // Clearing timeout if one was defined 453 | if (this._future) 454 | this._future = clearTimeout(this._future); 455 | 456 | const affectedPaths = Object.keys(this._affectedPathsIndex).map(h => { 457 | return h !== 'λ' ? 458 | h.split('λ').slice(1) : 459 | []; 460 | }); 461 | 462 | // Is the tree still valid? 463 | const validationError = this.validate(affectedPaths); 464 | 465 | if (validationError) 466 | return this; 467 | 468 | // Caching to keep original references before we change them 469 | const transaction = this._transaction, 470 | previousData = this._previousData; 471 | 472 | this._affectedPathsIndex = {}; 473 | this._transaction = []; 474 | this._previousData = this._data; 475 | 476 | // Emitting update event 477 | this.emit('update', { 478 | paths: affectedPaths, 479 | currentData: this._data, 480 | transaction, 481 | previousData 482 | }); 483 | 484 | return this; 485 | } 486 | 487 | /** 488 | * Method returning a monkey at the given path or else `null`. 489 | * 490 | * @param {path} path - Path of the monkey to retrieve. 491 | * @return {Monkey|null} - The Monkey instance of `null`. 492 | */ 493 | getMonkey(path) { 494 | path = coercePath(path); 495 | 496 | const monkey = getIn(this._monkeys, [].concat(path)).data; 497 | 498 | if (monkey instanceof Monkey) 499 | return monkey; 500 | 501 | return null; 502 | } 503 | 504 | /** 505 | * Method used to watch a collection of paths within the tree. Very useful 506 | * to bind UI components and such to the tree. 507 | * 508 | * @param {object} mapping - Mapping of paths to listen. 509 | * @return {Cursor} - The created watcher. 510 | */ 511 | watch(mapping) { 512 | return new Watcher(this, mapping); 513 | } 514 | 515 | /** 516 | * Method releasing the tree and its attached data from memory. 517 | */ 518 | release() { 519 | let k; 520 | 521 | this.emit('release'); 522 | 523 | delete this.root; 524 | 525 | delete this._data; 526 | delete this._previousData; 527 | delete this._transaction; 528 | delete this._affectedPathsIndex; 529 | delete this._monkeys; 530 | 531 | // Releasing cursors 532 | for (k in this._cursors) 533 | this._cursors[k].release(); 534 | delete this._cursors; 535 | 536 | // Killing event emitter 537 | this.kill(); 538 | } 539 | 540 | /** 541 | * Overriding the `toJSON` method for convenient use with JSON.stringify. 542 | * 543 | * @return {mixed} - Data at cursor. 544 | */ 545 | toJSON() { 546 | return this.serialize(); 547 | } 548 | 549 | /** 550 | * Overriding the `toString` method for debugging purposes. 551 | * 552 | * @return {string} - The baobab's identity. 553 | */ 554 | toString() { 555 | return this._identity; 556 | } 557 | } 558 | 559 | /** 560 | * Monkey helper. 561 | */ 562 | Baobab.monkey = function(...args) { 563 | 564 | if (!args.length) 565 | throw new Error('Baobab.monkey: missing definition.'); 566 | 567 | if (args.length === 1 && typeof args[0] !== 'function') 568 | return new MonkeyDefinition(args[0]); 569 | 570 | return new MonkeyDefinition(args); 571 | }; 572 | Baobab.dynamicNode = Baobab.monkey; 573 | 574 | export const monkey = Baobab.monkey; 575 | export const dynamic = Baobab.dynamic; 576 | 577 | /** 578 | * Exposing some internals for convenience 579 | */ 580 | export {Cursor, MonkeyDefinition, Monkey, type, helpers}; 581 | 582 | /** 583 | * Version. 584 | */ 585 | Baobab.VERSION = '2.6.1'; 586 | export const VERSION = Baobab.VERSION; 587 | 588 | /** 589 | * Exporting. 590 | */ 591 | export default Baobab; 592 | -------------------------------------------------------------------------------- /src/cursor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Cursors 3 | * =============== 4 | * 5 | * Cursors created by selecting some data within a Baobab tree. 6 | */ 7 | import Emitter from 'emmett'; 8 | import {Monkey} from './monkey'; 9 | import type from './type'; 10 | import { 11 | Archive, 12 | arrayFrom, 13 | before, 14 | coercePath, 15 | deepClone, 16 | getIn, 17 | makeError, 18 | shallowClone, 19 | solveUpdate 20 | } from './helpers'; 21 | 22 | 23 | /** 24 | * Traversal helper function for dynamic cursors. Will throw a legible error 25 | * if traversal is not possible. 26 | * 27 | * @param {string} method - The method name, to create a correct error msg. 28 | * @param {array} solvedPath - The cursor's solved path. 29 | */ 30 | function checkPossibilityOfDynamicTraversal(method, solvedPath) { 31 | if (!solvedPath) 32 | throw makeError( 33 | `Baobab.Cursor.${method}: ` + 34 | `cannot use ${method} on an unresolved dynamic path.`, 35 | {path: solvedPath} 36 | ); 37 | } 38 | 39 | /** 40 | * Cursor class 41 | * 42 | * @constructor 43 | * @param {Baobab} tree - The cursor's root. 44 | * @param {array} path - The cursor's path in the tree. 45 | * @param {string} hash - The path's hash computed ahead by the tree. 46 | */ 47 | export default class Cursor extends Emitter { 48 | constructor(tree, path, hash) { 49 | super(); 50 | 51 | // If no path were to be provided, we fallback to an empty path (root) 52 | path = path || []; 53 | 54 | // Privates 55 | this._identity = '[object Cursor]'; 56 | this._archive = null; 57 | 58 | // Properties 59 | this.tree = tree; 60 | this.path = path; 61 | this.hash = hash; 62 | 63 | // State 64 | this.state = { 65 | killed: false, 66 | recording: false, 67 | undoing: false 68 | }; 69 | 70 | // Checking whether the given path is dynamic or not 71 | this._dynamicPath = type.dynamicPath(this.path); 72 | 73 | // Checking whether the given path will meet a monkey 74 | this._monkeyPath = type.monkeyPath(this.tree._monkeys, this.path); 75 | 76 | if (!this._dynamicPath) 77 | this.solvedPath = this.path; 78 | else 79 | this.solvedPath = getIn(this.tree._data, this.path).solvedPath; 80 | 81 | /** 82 | * Listener bound to the tree's writes so that cursors with dynamic paths 83 | * may update their solved path correctly. 84 | * 85 | * @param {object} event - The event fired by the tree. 86 | */ 87 | this._writeHandler = ({data}) => { 88 | if (this.state.killed || 89 | !solveUpdate([data.path], this._getComparedPaths())) 90 | return; 91 | 92 | this.solvedPath = getIn(this.tree._data, this.path).solvedPath; 93 | }; 94 | 95 | /** 96 | * Function in charge of actually trigger the cursor's updates and 97 | * deal with the archived records. 98 | * 99 | * @note: probably should wrap the current solvedPath in closure to avoid 100 | * for tricky cases where it would fail. 101 | * 102 | * @param {mixed} previousData - the tree's previous data. 103 | */ 104 | const fireUpdate = (previousData) => { 105 | const self = this; 106 | 107 | const eventData = { 108 | get previousData() { 109 | return getIn(previousData, self.solvedPath).data; 110 | }, 111 | get currentData() { 112 | return self.get(); 113 | } 114 | }; 115 | 116 | if (this.state.recording && !this.state.undoing) 117 | this.archive.add(eventData.previousData); 118 | 119 | this.state.undoing = false; 120 | 121 | return this.emit('update', eventData); 122 | }; 123 | 124 | /** 125 | * Listener bound to the tree's updates and determining whether the 126 | * cursor is affected and should react accordingly. 127 | * 128 | * Note that this listener is lazily bound to the tree to be sure 129 | * one wouldn't leak listeners when only creating cursors for convenience 130 | * and not to listen to updates specifically. 131 | * 132 | * @param {object} event - The event fired by the tree. 133 | */ 134 | this._updateHandler = (event) => { 135 | if (this.state.killed) 136 | return; 137 | 138 | const {paths, previousData} = event.data, 139 | update = fireUpdate.bind(this, previousData), 140 | comparedPaths = this._getComparedPaths(); 141 | 142 | if (solveUpdate(paths, comparedPaths)) 143 | return update(); 144 | }; 145 | 146 | // Lazy binding 147 | let bound = false; 148 | this._lazyBind = () => { 149 | if (bound) 150 | return; 151 | 152 | bound = true; 153 | 154 | if (this._dynamicPath) 155 | this.tree.on('write', this._writeHandler); 156 | 157 | return this.tree.on('update', this._updateHandler); 158 | }; 159 | 160 | // If the path is dynamic, we actually need to listen to the tree 161 | if (this._dynamicPath) { 162 | this._lazyBind(); 163 | } 164 | else { 165 | 166 | // Overriding the emitter `on` and `once` methods 167 | this.on = before(this._lazyBind, this.on.bind(this)); 168 | this.once = before(this._lazyBind, this.once.bind(this)); 169 | } 170 | } 171 | 172 | /** 173 | * Internal helpers 174 | * ----------------- 175 | */ 176 | 177 | /** 178 | * Method returning the paths of the tree watched over by the cursor and that 179 | * should be taken into account when solving a potential update. 180 | * 181 | * @return {array} - Array of paths to compare with a given update. 182 | */ 183 | _getComparedPaths() { 184 | 185 | // Checking whether we should keep track of some dependencies 186 | const additionalPaths = this._monkeyPath ? 187 | getIn(this.tree._monkeys, this._monkeyPath) 188 | .data 189 | .relatedPaths() : 190 | []; 191 | 192 | return [this.solvedPath].concat(additionalPaths); 193 | } 194 | 195 | /** 196 | * Predicates 197 | * ----------- 198 | */ 199 | 200 | /** 201 | * Method returning whether the cursor is at root level. 202 | * 203 | * @return {boolean} - Is the cursor the root? 204 | */ 205 | isRoot() { 206 | return !this.path.length; 207 | } 208 | 209 | /** 210 | * Method returning whether the cursor is at leaf level. 211 | * 212 | * @return {boolean} - Is the cursor a leaf? 213 | */ 214 | isLeaf() { 215 | return type.primitive(this._get().data); 216 | } 217 | 218 | /** 219 | * Method returning whether the cursor is at branch level. 220 | * 221 | * @return {boolean} - Is the cursor a branch? 222 | */ 223 | isBranch() { 224 | return !this.isRoot() && !this.isLeaf(); 225 | } 226 | 227 | /** 228 | * Traversal Methods 229 | * ------------------ 230 | */ 231 | 232 | /** 233 | * Method returning the root cursor. 234 | * 235 | * @return {Baobab} - The root cursor. 236 | */ 237 | root() { 238 | return this.tree.select(); 239 | } 240 | 241 | /** 242 | * Method selecting a subpath as a new cursor. 243 | * 244 | * Arity (1): 245 | * @param {path} path - The path to select. 246 | * 247 | * Arity (*): 248 | * @param {...step} path - The path to select. 249 | * 250 | * @return {Cursor} - The created cursor. 251 | */ 252 | select(path) { 253 | if (arguments.length > 1) 254 | path = arrayFrom(arguments); 255 | 256 | return this.tree.select(this.path.concat(path)); 257 | } 258 | 259 | /** 260 | * Method returning the parent node of the cursor or else `null` if the 261 | * cursor is already at root level. 262 | * 263 | * @return {Baobab} - The parent cursor. 264 | */ 265 | up() { 266 | if (!this.isRoot()) 267 | return this.tree.select(this.path.slice(0, -1)); 268 | 269 | return null; 270 | } 271 | 272 | /** 273 | * Method returning the child node of the cursor. 274 | * 275 | * @return {Baobab} - The child cursor. 276 | */ 277 | down() { 278 | checkPossibilityOfDynamicTraversal('down', this.solvedPath); 279 | 280 | if (!(this._get().data instanceof Array)) 281 | throw Error('Baobab.Cursor.down: cannot go down on a non-list type.'); 282 | 283 | return this.tree.select(this.solvedPath.concat(0)); 284 | } 285 | 286 | /** 287 | * Method returning the left sibling node of the cursor if this one is 288 | * pointing at a list. Returns `null` if this cursor is already leftmost. 289 | * 290 | * @return {Baobab} - The left sibling cursor. 291 | */ 292 | left() { 293 | checkPossibilityOfDynamicTraversal('left', this.solvedPath); 294 | 295 | const last = +this.solvedPath[this.solvedPath.length - 1]; 296 | 297 | if (isNaN(last)) 298 | throw Error('Baobab.Cursor.left: cannot go left on a non-list type.'); 299 | 300 | return last ? 301 | this.tree.select(this.solvedPath.slice(0, -1).concat(last - 1)) : 302 | null; 303 | } 304 | 305 | /** 306 | * Method returning the right sibling node of the cursor if this one is 307 | * pointing at a list. Returns `null` if this cursor is already rightmost. 308 | * 309 | * @return {Baobab} - The right sibling cursor. 310 | */ 311 | right() { 312 | checkPossibilityOfDynamicTraversal('right', this.solvedPath); 313 | 314 | const last = +this.solvedPath[this.solvedPath.length - 1]; 315 | 316 | if (isNaN(last)) 317 | throw Error('Baobab.Cursor.right: cannot go right on a non-list type.'); 318 | 319 | if (last + 1 === this.up()._get().data.length) 320 | return null; 321 | 322 | return this.tree.select(this.solvedPath.slice(0, -1).concat(last + 1)); 323 | } 324 | 325 | /** 326 | * Method returning the leftmost sibling node of the cursor if this one is 327 | * pointing at a list. 328 | * 329 | * @return {Baobab} - The leftmost sibling cursor. 330 | */ 331 | leftmost() { 332 | checkPossibilityOfDynamicTraversal('leftmost', this.solvedPath); 333 | 334 | const last = +this.solvedPath[this.solvedPath.length - 1]; 335 | 336 | if (isNaN(last)) 337 | throw Error('Baobab.Cursor.leftmost: cannot go left on a non-list type.'); 338 | 339 | return this.tree.select(this.solvedPath.slice(0, -1).concat(0)); 340 | } 341 | 342 | /** 343 | * Method returning the rightmost sibling node of the cursor if this one is 344 | * pointing at a list. 345 | * 346 | * @return {Baobab} - The rightmost sibling cursor. 347 | */ 348 | rightmost() { 349 | checkPossibilityOfDynamicTraversal('rightmost', this.solvedPath); 350 | 351 | const last = +this.solvedPath[this.solvedPath.length - 1]; 352 | 353 | if (isNaN(last)) 354 | throw Error( 355 | 'Baobab.Cursor.rightmost: cannot go right on a non-list type.'); 356 | 357 | const list = this.up()._get().data; 358 | 359 | return this.tree 360 | .select(this.solvedPath.slice(0, -1).concat(list.length - 1)); 361 | } 362 | 363 | /** 364 | * Method mapping the children nodes of the cursor. 365 | * 366 | * @param {function} fn - The function to map. 367 | * @param {object} [scope] - An optional scope. 368 | * @return {array} - The resultant array. 369 | */ 370 | map(fn, scope) { 371 | checkPossibilityOfDynamicTraversal('map', this.solvedPath); 372 | 373 | const array = this._get().data, 374 | l = arguments.length; 375 | 376 | if (!type.array(array)) 377 | throw Error('baobab.Cursor.map: cannot map a non-list type.'); 378 | 379 | return array.map(function(item, i) { 380 | return fn.call( 381 | l > 1 ? scope : this, 382 | this.select(i), 383 | i, 384 | array 385 | ); 386 | }, this); 387 | } 388 | 389 | /** 390 | * Getter Methods 391 | * --------------- 392 | */ 393 | 394 | /** 395 | * Internal get method. Basically contains the main body of the `get` method 396 | * without the event emitting. This is sometimes needed not to fire useless 397 | * events. 398 | * 399 | * @param {path} [path=[]] - Path to get in the tree. 400 | * @return {object} info - The resultant information. 401 | * @return {mixed} info.data - Data at path. 402 | * @return {array} info.solvedPath - The path solved when getting. 403 | */ 404 | _get(path = []) { 405 | 406 | if (!type.path(path)) 407 | throw makeError('Baobab.Cursor.getters: invalid path.', {path}); 408 | 409 | if (!this.solvedPath) 410 | return {data: undefined, solvedPath: null, exists: false}; 411 | 412 | return getIn(this.tree._data, this.solvedPath.concat(path)); 413 | } 414 | 415 | /** 416 | * Method used to check whether a certain path exists in the tree starting 417 | * from the current cursor. 418 | * 419 | * Arity (1): 420 | * @param {path} path - Path to check in the tree. 421 | * 422 | * Arity (2): 423 | * @param {..step} path - Path to check in the tree. 424 | * 425 | * @return {boolean} - Does the given path exists? 426 | */ 427 | exists(path) { 428 | path = coercePath(path); 429 | 430 | if (arguments.length > 1) 431 | path = arrayFrom(arguments); 432 | 433 | return this._get(path).exists; 434 | } 435 | 436 | /** 437 | * Method used to get data from the tree. Will fire a `get` event from the 438 | * tree so that the user may sometimes react upon it to fetch data, for 439 | * instance. 440 | * 441 | * Arity (1): 442 | * @param {path} path - Path to get in the tree. 443 | * 444 | * Arity (2): 445 | * @param {..step} path - Path to get in the tree. 446 | * 447 | * @return {mixed} - Data at path. 448 | */ 449 | get(path) { 450 | path = coercePath(path); 451 | 452 | if (arguments.length > 1) 453 | path = arrayFrom(arguments); 454 | 455 | const {data, solvedPath} = this._get(path); 456 | 457 | // Emitting the event 458 | this.tree.emit('get', {data, solvedPath, path: this.path.concat(path)}); 459 | 460 | return data; 461 | } 462 | 463 | /** 464 | * Method used to shallow clone data from the tree. 465 | * 466 | * Arity (1): 467 | * @param {path} path - Path to get in the tree. 468 | * 469 | * Arity (2): 470 | * @param {..step} path - Path to get in the tree. 471 | * 472 | * @return {mixed} - Cloned data at path. 473 | */ 474 | clone(...args) { 475 | const data = this.get(...args); 476 | 477 | return shallowClone(data); 478 | } 479 | 480 | /** 481 | * Method used to deep clone data from the tree. 482 | * 483 | * Arity (1): 484 | * @param {path} path - Path to get in the tree. 485 | * 486 | * Arity (2): 487 | * @param {..step} path - Path to get in the tree. 488 | * 489 | * @return {mixed} - Cloned data at path. 490 | */ 491 | deepClone(...args) { 492 | const data = this.get(...args); 493 | 494 | return deepClone(data); 495 | } 496 | 497 | /** 498 | * Method used to return raw data from the tree, by carefully avoiding 499 | * computed one. 500 | * 501 | * @todo: should be more performant as the cloning should happen as well as 502 | * when dropping computed data. 503 | * 504 | * Arity (1): 505 | * @param {path} path - Path to serialize in the tree. 506 | * 507 | * Arity (2): 508 | * @param {..step} path - Path to serialize in the tree. 509 | * 510 | * @return {mixed} - The retrieved raw data. 511 | */ 512 | serialize(path) { 513 | path = coercePath(path); 514 | 515 | if (arguments.length > 1) 516 | path = arrayFrom(arguments); 517 | 518 | if (!type.path(path)) 519 | throw makeError('Baobab.Cursor.getters: invalid path.', {path}); 520 | 521 | if (!this.solvedPath) 522 | return undefined; 523 | 524 | const fullPath = this.solvedPath.concat(path); 525 | 526 | const data = deepClone(getIn(this.tree._data, fullPath).data), 527 | monkeys = getIn(this.tree._monkeys, fullPath).data; 528 | 529 | const dropComputedData = (d, m) => { 530 | if (!type.object(m) || !type.object(d)) 531 | return; 532 | 533 | for (const k in m) { 534 | if (m[k] instanceof Monkey) 535 | delete d[k]; 536 | else 537 | dropComputedData(d[k], m[k]); 538 | } 539 | }; 540 | 541 | dropComputedData(data, monkeys); 542 | return data; 543 | } 544 | 545 | /** 546 | * Method used to project some of the data at cursor onto a map or a list. 547 | * 548 | * @param {object|array} projection - The projection's formal definition. 549 | * @return {object|array} - The resultant map/list. 550 | */ 551 | project(projection) { 552 | if (type.object(projection)) { 553 | const data = {}; 554 | 555 | for (const k in projection) 556 | data[k] = this.get(projection[k]); 557 | 558 | return data; 559 | } 560 | 561 | else if (type.array(projection)) { 562 | const data = []; 563 | 564 | for (let i = 0, l = projection.length; i < l; i++) 565 | data.push(this.get(projection[i])); 566 | 567 | return data; 568 | } 569 | 570 | throw makeError('Baobab.Cursor.project: wrong projection.', {projection}); 571 | } 572 | 573 | /** 574 | * History Methods 575 | * ---------------- 576 | */ 577 | 578 | /** 579 | * Methods starting to record the cursor's successive states. 580 | * 581 | * @param {integer} [maxRecords] - Maximum records to keep in memory. Note 582 | * that if no number is provided, the cursor 583 | * will keep everything. 584 | * @return {Cursor} - The cursor instance for chaining purposes. 585 | */ 586 | startRecording(maxRecords) { 587 | maxRecords = maxRecords || Infinity; 588 | 589 | if (maxRecords < 1) 590 | throw makeError('Baobab.Cursor.startRecording: invalid max records.', { 591 | value: maxRecords 592 | }); 593 | 594 | this.state.recording = true; 595 | 596 | if (this.archive) 597 | return this; 598 | 599 | // Lazy binding 600 | this._lazyBind(); 601 | 602 | this.archive = new Archive(maxRecords); 603 | return this; 604 | } 605 | 606 | /** 607 | * Methods stopping to record the cursor's successive states. 608 | * 609 | * @return {Cursor} - The cursor instance for chaining purposes. 610 | */ 611 | stopRecording() { 612 | this.state.recording = false; 613 | return this; 614 | } 615 | 616 | /** 617 | * Methods undoing n steps of the cursor's recorded states. 618 | * 619 | * @param {integer} [steps=1] - The number of steps to rollback. 620 | * @return {Cursor} - The cursor instance for chaining purposes. 621 | */ 622 | undo(steps = 1) { 623 | if (!this.state.recording) 624 | throw new Error('Baobab.Cursor.undo: cursor is not recording.'); 625 | 626 | const record = this.archive.back(steps); 627 | 628 | if (!record) 629 | throw Error('Baobab.Cursor.undo: cannot find a relevant record.'); 630 | 631 | this.state.undoing = true; 632 | this.set(record); 633 | 634 | return this; 635 | } 636 | 637 | /** 638 | * Methods returning whether the cursor has a recorded history. 639 | * 640 | * @return {boolean} - `true` if the cursor has a recorded history? 641 | */ 642 | hasHistory() { 643 | return !!(this.archive && this.archive.get().length); 644 | } 645 | 646 | /** 647 | * Methods returning the cursor's history. 648 | * 649 | * @return {array} - The cursor's history. 650 | */ 651 | getHistory() { 652 | return this.archive ? this.archive.get() : []; 653 | } 654 | 655 | /** 656 | * Methods clearing the cursor's history. 657 | * 658 | * @return {Cursor} - The cursor instance for chaining purposes. 659 | */ 660 | clearHistory() { 661 | if (this.archive) 662 | this.archive.clear(); 663 | return this; 664 | } 665 | 666 | /** 667 | * Releasing 668 | * ---------- 669 | */ 670 | 671 | /** 672 | * Methods releasing the cursor from memory. 673 | */ 674 | release() { 675 | 676 | // Removing listeners on parent 677 | if (this._dynamicPath) 678 | this.tree.off('write', this._writeHandler); 679 | 680 | this.tree.off('update', this._updateHandler); 681 | 682 | // Unsubscribe from the parent 683 | if (this.hash) 684 | delete this.tree._cursors[this.hash]; 685 | 686 | // Dereferencing 687 | delete this.tree; 688 | delete this.path; 689 | delete this.solvedPath; 690 | delete this.archive; 691 | 692 | // Killing emitter 693 | this.kill(); 694 | this.state.killed = true; 695 | } 696 | 697 | /** 698 | * Output 699 | * ------- 700 | */ 701 | 702 | /** 703 | * Overriding the `toJSON` method for convenient use with JSON.stringify. 704 | * 705 | * @return {mixed} - Data at cursor. 706 | */ 707 | toJSON() { 708 | return this.serialize(); 709 | } 710 | 711 | /** 712 | * Overriding the `toString` method for debugging purposes. 713 | * 714 | * @return {string} - The cursor's identity. 715 | */ 716 | toString() { 717 | return this._identity; 718 | } 719 | } 720 | 721 | /** 722 | * Method used to allow iterating over cursors containing list-type data. 723 | * 724 | * e.g. for(let i of cursor) { ... } 725 | * 726 | * @returns {object} - Each item sequentially. 727 | */ 728 | if (typeof Symbol === 'function' && typeof Symbol.iterator !== 'undefined') { 729 | Cursor.prototype[Symbol.iterator] = function() { 730 | const array = this._get().data; 731 | 732 | if (!type.array(array)) 733 | throw Error('baobab.Cursor.@@iterate: cannot iterate a non-list type.'); 734 | 735 | let i = 0; 736 | 737 | const cursor = this, 738 | length = array.length; 739 | 740 | return { 741 | next() { 742 | if (i < length) { 743 | return { 744 | value: cursor.select(i++) 745 | }; 746 | } 747 | 748 | return { 749 | done: true 750 | }; 751 | } 752 | }; 753 | }; 754 | } 755 | 756 | /** 757 | * Setter Methods 758 | * --------------- 759 | * 760 | * Those methods are dynamically assigned to the class for DRY reasons. 761 | */ 762 | 763 | // Not using a Set so that ES5 consumers don't pay a bundle size price 764 | const INTRANSITIVE_SETTERS = { 765 | unset: true, 766 | pop: true, 767 | shift: true 768 | }; 769 | 770 | /** 771 | * Function creating a setter method for the Cursor class. 772 | * 773 | * @param {string} name - the method's name. 774 | * @param {function} [typeChecker] - a function checking that the given value is 775 | * valid for the given operation. 776 | */ 777 | function makeSetter(name, typeChecker) { 778 | 779 | /** 780 | * Binding a setter method to the Cursor class and having the following 781 | * definition. 782 | * 783 | * Note: this is not really possible to make those setters variadic because 784 | * it would create an impossible polymorphism with path. 785 | * 786 | * @todo: perform value validation elsewhere so that tree.update can 787 | * beneficiate from it. 788 | * 789 | * Arity (1): 790 | * @param {mixed} value - New value to set at cursor's path. 791 | * 792 | * Arity (2): 793 | * @param {path} path - Subpath to update starting from cursor's. 794 | * @param {mixed} value - New value to set. 795 | * 796 | * @return {mixed} - Data at path. 797 | */ 798 | Cursor.prototype[name] = function(path, value) { 799 | 800 | // We should warn the user if he applies to many arguments to the function 801 | if (arguments.length > 2) 802 | throw makeError(`Baobab.Cursor.${name}: too many arguments.`); 803 | 804 | // Handling arities 805 | if (arguments.length === 1 && !INTRANSITIVE_SETTERS[name]) { 806 | value = path; 807 | path = []; 808 | } 809 | 810 | // Coerce path 811 | path = coercePath(path); 812 | 813 | // Checking the path's validity 814 | if (!type.path(path)) 815 | throw makeError(`Baobab.Cursor.${name}: invalid path.`, {path}); 816 | 817 | // Checking the value's validity 818 | if (typeChecker && !typeChecker(value)) 819 | throw makeError(`Baobab.Cursor.${name}: invalid value.`, {path, value}); 820 | 821 | // Checking the solvability of the cursor's dynamic path 822 | if (!this.solvedPath) 823 | throw makeError( 824 | `Baobab.Cursor.${name}: the dynamic path of the cursor cannot be solved.`, 825 | {path: this.path} 826 | ); 827 | 828 | const fullPath = this.solvedPath.concat(path); 829 | 830 | // Filing the update to the tree 831 | return this.tree.update( 832 | fullPath, 833 | { 834 | type: name, 835 | value 836 | } 837 | ); 838 | }; 839 | } 840 | 841 | /** 842 | * Making the necessary setters. 843 | */ 844 | makeSetter('set'); 845 | makeSetter('unset'); 846 | makeSetter('apply', type.function); 847 | makeSetter('push'); 848 | makeSetter('concat', type.array); 849 | makeSetter('unshift'); 850 | makeSetter('pop'); 851 | makeSetter('shift'); 852 | makeSetter('splice', type.splicer); 853 | makeSetter('merge', type.object); 854 | makeSetter('deepMerge', type.object); 855 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint eqeqeq: 0 */ 2 | /* eslint no-use-before-define: 0 */ 3 | /** 4 | * Baobab Helpers 5 | * =============== 6 | * 7 | * Miscellaneous helper functions. 8 | */ 9 | import {Monkey, MonkeyDefinition} from './monkey'; 10 | import type from './type'; 11 | 12 | const hasOwnProp = {}.hasOwnProperty; 13 | 14 | /** 15 | * Function returning the index of the first element of a list matching the 16 | * given predicate. 17 | * 18 | * @param {array} a - The target array. 19 | * @param {function} fn - The predicate function. 20 | * @return {mixed} - The index of the first matching item or -1. 21 | */ 22 | function index(a, fn) { 23 | let i, l; 24 | for (i = 0, l = a.length; i < l; i++) { 25 | if (fn(a[i])) 26 | return i; 27 | } 28 | return -1; 29 | } 30 | 31 | /** 32 | * Efficient slice function used to clone arrays or parts of them. 33 | * 34 | * @param {array} array - The array to slice. 35 | * @return {array} - The sliced array. 36 | */ 37 | function slice(array) { 38 | const newArray = new Array(array.length); 39 | 40 | let i, 41 | l; 42 | 43 | for (i = 0, l = array.length; i < l; i++) 44 | newArray[i] = array[i]; 45 | 46 | return newArray; 47 | } 48 | 49 | /** 50 | * Archive abstraction 51 | * 52 | * @constructor 53 | * @param {integer} size - Maximum number of records to store. 54 | */ 55 | export class Archive { 56 | constructor(size) { 57 | this.size = size; 58 | this.records = []; 59 | } 60 | 61 | /** 62 | * Method retrieving the records. 63 | * 64 | * @return {array} - The records. 65 | */ 66 | get() { 67 | return this.records; 68 | } 69 | 70 | /** 71 | * Method adding a record to the archive 72 | * 73 | * @param {object} record - The record to store. 74 | * @return {Archive} - The archive itself for chaining purposes. 75 | */ 76 | add(record) { 77 | this.records.unshift(record); 78 | 79 | // If the number of records is exceeded, we truncate the records 80 | if (this.records.length > this.size) 81 | this.records.length = this.size; 82 | 83 | return this; 84 | } 85 | 86 | /** 87 | * Method clearing the records. 88 | * 89 | * @return {Archive} - The archive itself for chaining purposes. 90 | */ 91 | clear() { 92 | this.records = []; 93 | return this; 94 | } 95 | 96 | /** 97 | * Method to go back in time. 98 | * 99 | * @param {integer} steps - Number of steps we should go back by. 100 | * @return {number} - The last record. 101 | */ 102 | back(steps) { 103 | const record = this.records[steps - 1]; 104 | 105 | if (record) 106 | this.records = this.records.slice(steps); 107 | return record; 108 | } 109 | } 110 | 111 | /** 112 | * Function creating a real array from what should be an array but is not. 113 | * I'm looking at you nasty `arguments`... 114 | * 115 | * @param {mixed} culprit - The culprit to convert. 116 | * @return {array} - The real array. 117 | */ 118 | export function arrayFrom(culprit) { 119 | return slice(culprit); 120 | } 121 | 122 | /** 123 | * Function decorating one function with another that will be called before the 124 | * decorated one. 125 | * 126 | * @param {function} decorator - The decorating function. 127 | * @param {function} fn - The function to decorate. 128 | * @return {function} - The decorated function. 129 | */ 130 | export function before(decorator, fn) { 131 | return function() { 132 | decorator.apply(null, arguments); 133 | fn.apply(null, arguments); 134 | }; 135 | } 136 | 137 | /** 138 | * Function cloning the given regular expression. Supports `y` and `u` flags 139 | * already. 140 | * 141 | * @param {RegExp} re - The target regular expression. 142 | * @return {RegExp} - The cloned regular expression. 143 | */ 144 | function cloneRegexp(re) { 145 | const pattern = re.source; 146 | 147 | let flags = ''; 148 | 149 | if (re.global) flags += 'g'; 150 | if (re.multiline) flags += 'm'; 151 | if (re.ignoreCase) flags += 'i'; 152 | if (re.sticky) flags += 'y'; 153 | if (re.unicode) flags += 'u'; 154 | 155 | return new RegExp(pattern, flags); 156 | } 157 | 158 | /** 159 | * Function cloning the given variable. 160 | * 161 | * @todo: implement a faster way to clone an array. 162 | * 163 | * @param {boolean} deep - Should we deep clone the variable. 164 | * @param {mixed} item - The variable to clone 165 | * @return {mixed} - The cloned variable. 166 | */ 167 | function cloner(deep, item) { 168 | if (!item || 169 | typeof item !== 'object' || 170 | item instanceof Error || 171 | item instanceof MonkeyDefinition || 172 | item instanceof Monkey || 173 | ('ArrayBuffer' in global && item instanceof ArrayBuffer)) 174 | return item; 175 | 176 | // Array 177 | if (type.array(item)) { 178 | if (deep) { 179 | const a = new Array(item.length); 180 | 181 | for (let i = 0, l = item.length; i < l; i++) 182 | a[i] = cloner(true, item[i]); 183 | return a; 184 | } 185 | 186 | return slice(item); 187 | } 188 | 189 | // Date 190 | if (item instanceof Date) 191 | return new Date(item.getTime()); 192 | 193 | // RegExp 194 | if (item instanceof RegExp) 195 | return cloneRegexp(item); 196 | 197 | // Object 198 | if (type.object(item)) { 199 | const o = {}; 200 | 201 | // NOTE: could be possible to erase computed properties through `null`. 202 | const props = Object.getOwnPropertyNames(item); 203 | for (let i = 0, l = props.length; i < l; i++) { 204 | const name = props[i]; 205 | const k = Object.getOwnPropertyDescriptor(item, name); 206 | if (k.enumerable === true) { 207 | if (k.get && k.get.isLazyGetter) { 208 | Object.defineProperty(o, name, { 209 | get: k.get, 210 | enumerable: true, 211 | configurable: true 212 | }); 213 | } 214 | else { 215 | o[name] = deep ? cloner(true, item[name]) : item[name]; 216 | } 217 | } 218 | else if (k.enumerable === false) { 219 | Object.defineProperty(o, name, { 220 | value: deep ? cloner(true, k.value) : k.value, 221 | enumerable: false, 222 | writable: true, 223 | configurable: true 224 | }); 225 | } 226 | } 227 | return o; 228 | } 229 | 230 | return item; 231 | } 232 | 233 | /** 234 | * Exporting shallow and deep cloning functions. 235 | */ 236 | const shallowClone = cloner.bind(null, false), 237 | deepClone = cloner.bind(null, true); 238 | 239 | export {shallowClone, deepClone}; 240 | 241 | /** 242 | * Coerce the given variable into a full-fledged path. 243 | * 244 | * @param {mixed} target - The variable to coerce. 245 | * @return {array} - The array path. 246 | */ 247 | export function coercePath(target) { 248 | if (target || target === 0 || target === '') 249 | return target; 250 | return []; 251 | } 252 | 253 | /** 254 | * Function comparing an object's properties to a given descriptive 255 | * object. 256 | * 257 | * @param {object} object - The object to compare. 258 | * @param {object} description - The description's mapping. 259 | * @return {boolean} - Whether the object matches the description. 260 | */ 261 | function compare(object, description) { 262 | let ok = true, 263 | k; 264 | 265 | // If we reached here via a recursive call, object may be undefined because 266 | // not all items in a collection will have the same deep nesting structure. 267 | if (!object) 268 | return false; 269 | 270 | for (k in description) { 271 | if (type.object(description[k])) { 272 | ok = ok && compare(object[k], description[k]); 273 | } 274 | else if (type.array(description[k])) { 275 | ok = ok && !!~description[k].indexOf(object[k]); 276 | } 277 | else { 278 | if (object[k] !== description[k]) 279 | return false; 280 | } 281 | } 282 | 283 | return ok; 284 | } 285 | 286 | /** 287 | * Function freezing the given variable if possible. 288 | * 289 | * @param {boolean} deep - Should we recursively freeze the given objects? 290 | * @param {object} o - The variable to freeze. 291 | * @return {object} - The merged object. 292 | */ 293 | function freezer(deep, o) { 294 | if (typeof o !== 'object' || 295 | o === null || 296 | o instanceof Monkey) 297 | return; 298 | 299 | Object.freeze(o); 300 | 301 | if (!deep) 302 | return; 303 | 304 | if (Array.isArray(o)) { 305 | 306 | // Iterating through the elements 307 | let i, 308 | l; 309 | 310 | for (i = 0, l = o.length; i < l; i++) 311 | deepFreeze(o[i]); 312 | } 313 | else { 314 | let p, 315 | k; 316 | 317 | for (k in o) { 318 | if (type.lazyGetter(o, k)) 319 | continue; 320 | 321 | p = o[k]; 322 | 323 | if (!p || 324 | !hasOwnProp.call(o, k) || 325 | typeof p !== 'object' || 326 | Object.isFrozen(p)) 327 | continue; 328 | 329 | deepFreeze(p); 330 | } 331 | } 332 | } 333 | 334 | const freeze = freezer.bind(null, false), 335 | deepFreeze = freezer.bind(null, true); 336 | 337 | export {freeze, deepFreeze}; 338 | 339 | /** 340 | * Function retrieving nested data within the given object and according to 341 | * the given path. 342 | * 343 | * @todo: work if dynamic path hit objects also. 344 | * @todo: memoized perfgetters. 345 | * 346 | * @param {object} object - The object we need to get data from. 347 | * @param {array} path - The path to follow. 348 | * @return {object} result - The result. 349 | * @return {mixed} result.data - The data at path, or `undefined`. 350 | * @return {array} result.solvedPath - The solved path or `null`. 351 | * @return {boolean} result.exists - Does the path exists in the tree? 352 | */ 353 | const NOT_FOUND_OBJECT = {data: undefined, solvedPath: null, exists: false}; 354 | 355 | export function getIn(object, path) { 356 | if (!path) 357 | return NOT_FOUND_OBJECT; 358 | 359 | const solvedPath = []; 360 | 361 | let exists = true, 362 | c = object, 363 | idx, 364 | i, 365 | l; 366 | 367 | for (i = 0, l = path.length; i < l; i++) { 368 | if (!c) 369 | return { 370 | data: undefined, 371 | solvedPath: solvedPath.concat(path.slice(i)), 372 | exists: false 373 | }; 374 | 375 | if (typeof path[i] === 'function') { 376 | if (!type.array(c)) 377 | return NOT_FOUND_OBJECT; 378 | 379 | idx = index(c, path[i]); 380 | if (!~idx) 381 | return NOT_FOUND_OBJECT; 382 | 383 | solvedPath.push(idx); 384 | c = c[idx]; 385 | } 386 | else if (typeof path[i] === 'object') { 387 | if (!type.array(c)) 388 | return NOT_FOUND_OBJECT; 389 | 390 | idx = index(c, e => compare(e, path[i])); 391 | if (!~idx) 392 | return NOT_FOUND_OBJECT; 393 | 394 | solvedPath.push(idx); 395 | c = c[idx]; 396 | } 397 | else { 398 | solvedPath.push(path[i]); 399 | exists = typeof c === 'object' && path[i] in c; 400 | c = c[path[i]]; 401 | } 402 | } 403 | 404 | return {data: c, solvedPath, exists}; 405 | } 406 | 407 | /** 408 | * Little helper returning a JavaScript error carrying some data with it. 409 | * 410 | * @param {string} message - The error message. 411 | * @param {object} [data] - Optional data to assign to the error. 412 | * @return {Error} - The created error. 413 | */ 414 | export function makeError(message, data) { 415 | const err = new Error(message); 416 | 417 | for (const k in data) 418 | err[k] = data[k]; 419 | 420 | return err; 421 | } 422 | 423 | /** 424 | * Function taking n objects to merge them together. 425 | * Note 1): the latter object will take precedence over the first one. 426 | * Note 2): the first object will be mutated to allow for perf scenarios. 427 | * Note 3): this function will consider monkeys as leaves. 428 | * 429 | * @param {boolean} deep - Whether the merge should be deep or not. 430 | * @param {...object} objects - Objects to merge. 431 | * @return {object} - The merged object. 432 | */ 433 | function merger(deep, ...objects) { 434 | const o = objects[0]; 435 | 436 | let t, 437 | i, 438 | l, 439 | k; 440 | 441 | for (i = 1, l = objects.length; i < l; i++) { 442 | t = objects[i]; 443 | 444 | for (k in t) { 445 | if (deep && 446 | type.object(t[k]) && 447 | !(t[k] instanceof Monkey) && 448 | k !== '__proto__' && 449 | k !== 'constructor' && 450 | k !== 'prototype' 451 | ) { 452 | o[k] = merger(true, o[k] || {}, t[k]); 453 | } 454 | else { 455 | o[k] = t[k]; 456 | } 457 | } 458 | } 459 | 460 | return o; 461 | } 462 | 463 | /** 464 | * Exporting both `shallowMerge` and `deepMerge` functions. 465 | */ 466 | const shallowMerge = merger.bind(null, false), 467 | deepMerge = merger.bind(null, true); 468 | 469 | export {shallowMerge, deepMerge}; 470 | 471 | /** 472 | * Function returning a string hash from a non-dynamic path expressed as an 473 | * array. 474 | * 475 | * @param {array} path - The path to hash. 476 | * @return {string} string - The resultant hash. 477 | */ 478 | export function hashPath(path) { 479 | return 'λ' + path.map(step => { 480 | if (type.function(step) || type.object(step)) 481 | return `#${uniqid()}#`; 482 | 483 | return step; 484 | }).join('λ'); 485 | } 486 | 487 | /** 488 | * Solving a potentially relative path. 489 | * 490 | * @param {array} base - The base path from which to solve the path. 491 | * @param {array} to - The subpath to reach. 492 | * @param {array} - The solved absolute path. 493 | */ 494 | export function solveRelativePath(base, to) { 495 | let solvedPath = []; 496 | 497 | // Coercing to array 498 | to = [].concat(to); 499 | 500 | for (let i = 0, l = to.length; i < l; i++) { 501 | const step = to[i]; 502 | 503 | if (step === '.') { 504 | if (!i) 505 | solvedPath = base.slice(0); 506 | } 507 | else if (step === '..') { 508 | solvedPath = (!i ? base : solvedPath).slice(0, -1); 509 | } 510 | else { 511 | solvedPath.push(step); 512 | } 513 | } 514 | 515 | return solvedPath; 516 | } 517 | 518 | /** 519 | * Function determining whether some paths in the tree were affected by some 520 | * updates that occurred at the given paths. This helper is mainly used at 521 | * cursor level to determine whether the cursor is concerned by the updates 522 | * fired at tree level. 523 | * 524 | * NOTES: 1) If performance become an issue, the following threefold loop 525 | * can be simplified to a complex twofold one. 526 | * 2) A regex version could also work but I am not confident it would 527 | * be faster. 528 | * 3) Another solution would be to keep a register of cursors like with 529 | * the monkeys and update along this tree. 530 | * 531 | * @param {array} affectedPaths - The paths that were updated. 532 | * @param {array} comparedPaths - The paths that we are actually interested in. 533 | * @return {boolean} - Is the update relevant to the compared 534 | * paths? 535 | */ 536 | export function solveUpdate(affectedPaths, comparedPaths) { 537 | let i, j, k, l, m, n, p, c, s; 538 | 539 | // Looping through possible paths 540 | for (i = 0, l = affectedPaths.length; i < l; i++) { 541 | p = affectedPaths[i]; 542 | 543 | if (!p.length) 544 | return true; 545 | 546 | // Looping through logged paths 547 | for (j = 0, m = comparedPaths.length; j < m; j++) { 548 | c = comparedPaths[j]; 549 | 550 | if (!c || !c.length) 551 | return true; 552 | 553 | // Looping through steps 554 | for (k = 0, n = c.length; k < n; k++) { 555 | s = c[k]; 556 | 557 | // If path is not relevant, we break 558 | // NOTE: the '!=' instead of '!==' is required here! 559 | if (s != p[k]) 560 | break; 561 | 562 | // If we reached last item and we are relevant 563 | if (k + 1 === n || k + 1 === p.length) 564 | return true; 565 | } 566 | } 567 | } 568 | 569 | return false; 570 | } 571 | 572 | /** 573 | * Non-mutative version of the splice array method. 574 | * 575 | * @param {array} array - The array to splice. 576 | * @param {integer} startIndex - The start index. 577 | * @param {integer} nb - Number of elements to remove. 578 | * @param {...mixed} elements - Elements to append after splicing. 579 | * @return {array} - The spliced array. 580 | */ 581 | export function splice(array, startIndex, nb, ...elements) { 582 | if (nb === undefined && arguments.length === 2) 583 | nb = array.length - startIndex; 584 | else if (nb === null || nb === undefined) 585 | nb = 0; 586 | else if (isNaN(+nb)) 587 | throw new Error(`argument nb ${nb} can not be parsed into a number!`); 588 | nb = Math.max(0, nb); 589 | 590 | // Solving startIndex 591 | if (type.function(startIndex)) 592 | startIndex = index(array, startIndex); 593 | if (type.object(startIndex)) 594 | startIndex = index(array, e => compare(e, startIndex)); 595 | 596 | // Positive index 597 | if (startIndex >= 0) 598 | return array 599 | .slice(0, startIndex) 600 | .concat(elements) 601 | .concat(array.slice(startIndex + nb)); 602 | 603 | // Negative index 604 | return array 605 | .slice(0, array.length + startIndex) 606 | .concat(elements) 607 | .concat(array.slice(array.length + startIndex + nb)); 608 | } 609 | 610 | /** 611 | * Function returning a unique incremental id each time it is called. 612 | * 613 | * @return {integer} - The latest unique id. 614 | */ 615 | const uniqid = (function() { 616 | let i = 0; 617 | 618 | return function() { 619 | return i++; 620 | }; 621 | })(); 622 | 623 | export {uniqid}; 624 | -------------------------------------------------------------------------------- /src/monkey.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Monkeys 3 | * =============== 4 | * 5 | * Exposing both handy monkey definitions and the underlying working class. 6 | */ 7 | import type from './type'; 8 | import update from './update'; 9 | import { 10 | deepFreeze, 11 | getIn, 12 | makeError, 13 | solveUpdate, 14 | solveRelativePath, 15 | hashPath 16 | } from './helpers'; 17 | 18 | /** 19 | * Monkey Definition class 20 | * Note: The only reason why this is a class is to be able to spot it within 21 | * otherwise ordinary data. 22 | * 23 | * @constructor 24 | * @param {array|object} definition - The formal definition of the monkey. 25 | */ 26 | export class MonkeyDefinition { 27 | constructor(definition) { 28 | const monkeyType = type.monkeyDefinition(definition); 29 | 30 | if (!monkeyType) 31 | throw makeError( 32 | 'Baobab.monkey: invalid definition.', 33 | {definition} 34 | ); 35 | 36 | this.type = monkeyType; 37 | 38 | if (this.type === 'object') { 39 | this.getter = definition.get; 40 | this.projection = definition.cursors || {}; 41 | this.paths = Object.keys(this.projection) 42 | .map(k => this.projection[k]); 43 | this.options = definition.options || {}; 44 | } 45 | else { 46 | let offset = 1, 47 | options = {}; 48 | 49 | if (type.object(definition[definition.length - 1])) { 50 | offset++; 51 | options = definition[definition.length - 1]; 52 | } 53 | 54 | this.getter = definition[definition.length - offset]; 55 | this.projection = definition.slice(0, -offset); 56 | this.paths = this.projection; 57 | this.options = options; 58 | } 59 | 60 | // Coercing paths for convenience 61 | this.paths = this.paths.map(p => [].concat(p)); 62 | 63 | // Does the definition contain dynamic paths 64 | this.hasDynamicPaths = this.paths.some(type.dynamicPath); 65 | } 66 | } 67 | 68 | /** 69 | * Monkey core class 70 | * 71 | * @constructor 72 | * @param {Baobab} tree - The bound tree. 73 | * @param {MonkeyDefinition} definition - A definition instance. 74 | */ 75 | export class Monkey { 76 | constructor(tree, pathInTree, definition) { 77 | 78 | // Properties 79 | this.tree = tree; 80 | this.path = pathInTree; 81 | this.definition = definition; 82 | 83 | // Adapting the definition's paths & projection to this monkey's case 84 | const projection = definition.projection, 85 | relative = solveRelativePath.bind(null, pathInTree.slice(0, -1)); 86 | 87 | if (definition.type === 'object') { 88 | this.projection = Object.keys(projection).reduce(function(acc, k) { 89 | acc[k] = relative(projection[k]); 90 | return acc; 91 | }, {}); 92 | this.depPaths = Object.keys(this.projection) 93 | .map(k => this.projection[k]); 94 | } 95 | else { 96 | this.projection = projection.map(relative); 97 | this.depPaths = this.projection; 98 | } 99 | 100 | // Internal state 101 | this.state = { 102 | killed: false 103 | }; 104 | 105 | /** 106 | * Listener on the tree's `write` event. 107 | * 108 | * When the tree writes, this listener will check whether the updated paths 109 | * are of any use to the monkey and, if so, will update the tree's node 110 | * where the monkey sits. 111 | */ 112 | this.writeListener = ({data: {path}}) => { 113 | if (this.state.killed) 114 | return; 115 | 116 | // Is the monkey affected by the current write event? 117 | const concerned = solveUpdate([path], this.relatedPaths()); 118 | 119 | if (concerned) 120 | this.update(); 121 | }; 122 | 123 | /** 124 | * Listener on the tree's `monkey` event. 125 | * 126 | * When another monkey updates, this listener will check whether the 127 | * updated paths are of any use to the monkey and, if so, will update the 128 | * tree's node where the monkey sits. 129 | */ 130 | this.recursiveListener = ({data: {monkey, path}}) => { 131 | if (this.state.killed) 132 | return; 133 | 134 | // Breaking if this is the same monkey 135 | if (this === monkey) 136 | return; 137 | 138 | // Is the monkey affected by the current monkey event? 139 | const concerned = solveUpdate([path], this.relatedPaths(false)); 140 | 141 | if (concerned) 142 | this.update(); 143 | }; 144 | 145 | // Binding listeners 146 | this.tree.on('write', this.writeListener); 147 | this.tree.on('_monkey', this.recursiveListener); 148 | 149 | // Updating relevant node 150 | this.update(); 151 | } 152 | 153 | /** 154 | * Method returning solved paths related to the monkey. 155 | * 156 | * @param {boolean} recursive - Should we compute recursive paths? 157 | * @return {array} - An array of related paths. 158 | */ 159 | relatedPaths(recursive = true) { 160 | let paths; 161 | 162 | if (this.definition.hasDynamicPaths) 163 | paths = this.depPaths.map( 164 | p => getIn(this.tree._data, p).solvedPath 165 | ); 166 | else 167 | paths = this.depPaths; 168 | 169 | const isRecursive = recursive && this.depPaths.some( 170 | p => !! type.monkeyPath(this.tree._monkeys, p) 171 | ); 172 | 173 | if (!isRecursive) 174 | return paths; 175 | 176 | return paths.reduce((accumulatedPaths, path) => { 177 | const monkeyPath = type.monkeyPath(this.tree._monkeys, path); 178 | 179 | if (!monkeyPath) 180 | return accumulatedPaths.concat([path]); 181 | 182 | // Solving recursive path 183 | const relatedMonkey = getIn(this.tree._monkeys, monkeyPath).data; 184 | 185 | return accumulatedPaths.concat(relatedMonkey.relatedPaths()); 186 | }, []); 187 | } 188 | 189 | /** 190 | * Method used to update the tree's internal data with a lazy getter holding 191 | * the computed data. 192 | * 193 | * @return {Monkey} - Returns itself for chaining purposes. 194 | */ 195 | update() { 196 | const deps = this.tree.project(this.projection); 197 | 198 | const lazyGetter = ((tree, def, data) => { 199 | let cache = null, 200 | alreadyComputed = false; 201 | 202 | return () => { 203 | 204 | if (!alreadyComputed) { 205 | cache = def.getter.apply( 206 | tree, 207 | def.type === 'object' ? 208 | [data] : 209 | data 210 | ); 211 | 212 | if (tree.options.immutable && def.options.immutable !== false) 213 | deepFreeze(cache); 214 | 215 | // update tree affected paths 216 | const hash = hashPath(this.path); 217 | tree._affectedPathsIndex[hash] = true; 218 | 219 | alreadyComputed = true; 220 | } 221 | 222 | return cache; 223 | }; 224 | })(this.tree, this.definition, deps); 225 | 226 | lazyGetter.isLazyGetter = true; 227 | 228 | // Should we write the lazy getter in the tree or solve it right now? 229 | if (this.tree.options.lazyMonkeys) { 230 | this.tree._data = update( 231 | this.tree._data, 232 | this.path, 233 | { 234 | type: 'monkey', 235 | value: lazyGetter 236 | }, 237 | this.tree.options 238 | ).data; 239 | } 240 | else { 241 | const result = update( 242 | this.tree._data, 243 | this.path, 244 | { 245 | type: 'set', 246 | value: lazyGetter(), 247 | options: { 248 | mutableLeaf: !this.definition.options.immutable 249 | } 250 | }, 251 | this.tree.options 252 | ); 253 | 254 | if ('data' in result) 255 | this.tree._data = result.data; 256 | } 257 | 258 | // Notifying the monkey's update so we can handle recursivity 259 | this.tree.emit('_monkey', {monkey: this, path: this.path}); 260 | 261 | return this; 262 | } 263 | 264 | /** 265 | * Method releasing the monkey from memory. 266 | */ 267 | release() { 268 | 269 | // Unbinding events 270 | this.tree.off('write', this.writeListener); 271 | this.tree.off('_monkey', this.recursiveListener); 272 | this.state.killed = true; 273 | 274 | // Deleting properties 275 | // NOTE: not deleting this.definition because some strange things happen 276 | // in the _refreshMonkeys method. See #372. 277 | delete this.projection; 278 | delete this.depPaths; 279 | delete this.tree; 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/type.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Type Checking 3 | * ===================== 4 | * 5 | * Helpers functions used throughout the library to perform some type 6 | * tests at runtime. 7 | * 8 | */ 9 | import {Monkey} from './monkey'; 10 | 11 | const type = {}; 12 | 13 | /** 14 | * Helpers 15 | * -------- 16 | */ 17 | 18 | /** 19 | * Checking whether the given variable is of any of the given types. 20 | * 21 | * @todo Optimize this function by dropping `some`. 22 | * 23 | * @param {mixed} target - Variable to test. 24 | * @param {array} allowed - Array of allowed types. 25 | * @return {boolean} 26 | */ 27 | function anyOf(target, allowed) { 28 | return allowed.some(t => type[t](target)); 29 | } 30 | 31 | /** 32 | * Simple types 33 | * ------------- 34 | */ 35 | 36 | /** 37 | * Checking whether the given variable is an array. 38 | * 39 | * @param {mixed} target - Variable to test. 40 | * @return {boolean} 41 | */ 42 | type.array = function(target) { 43 | return Array.isArray(target); 44 | }; 45 | 46 | /** 47 | * Checking whether the given variable is an object. 48 | * 49 | * @param {mixed} target - Variable to test. 50 | * @return {boolean} 51 | */ 52 | type.object = function(target) { 53 | return target && 54 | typeof target === 'object' && 55 | !Array.isArray(target) && 56 | !(target instanceof Date) && 57 | !(target instanceof RegExp) && 58 | !(typeof Map === 'function' && target instanceof Map) && 59 | !(typeof Set === 'function' && target instanceof Set); 60 | }; 61 | 62 | /** 63 | * Checking whether the given variable is a string. 64 | * 65 | * @param {mixed} target - Variable to test. 66 | * @return {boolean} 67 | */ 68 | type.string = function(target) { 69 | return typeof target === 'string'; 70 | }; 71 | 72 | /** 73 | * Checking whether the given variable is a number. 74 | * 75 | * @param {mixed} target - Variable to test. 76 | * @return {boolean} 77 | */ 78 | type.number = function(target) { 79 | return typeof target === 'number'; 80 | }; 81 | 82 | /** 83 | * Checking whether the given variable is a function. 84 | * 85 | * @param {mixed} target - Variable to test. 86 | * @return {boolean} 87 | */ 88 | type.function = function(target) { 89 | return typeof target === 'function'; 90 | }; 91 | 92 | /** 93 | * Checking whether the given variable is a JavaScript primitive. 94 | * 95 | * @param {mixed} target - Variable to test. 96 | * @return {boolean} 97 | */ 98 | type.primitive = function(target) { 99 | return target !== Object(target); 100 | }; 101 | 102 | /** 103 | * Complex types 104 | * -------------- 105 | */ 106 | 107 | /** 108 | * Checking whether the given variable is a valid splicer. 109 | * 110 | * @param {mixed} target - Variable to test. 111 | * @param {array} [allowed] - Optional valid types in path. 112 | * @return {boolean} 113 | */ 114 | type.splicer = function(target) { 115 | if (!type.array(target) || target.length < 1) 116 | return false; 117 | if (target.length > 1 && isNaN(+target[1])) 118 | return false; 119 | 120 | return anyOf(target[0], ['number', 'function', 'object']); 121 | }; 122 | 123 | /** 124 | * Checking whether the given variable is a valid cursor path. 125 | * 126 | * @param {mixed} target - Variable to test. 127 | * @param {array} [allowed] - Optional valid types in path. 128 | * @return {boolean} 129 | */ 130 | 131 | // Order is important for performance reasons 132 | const ALLOWED_FOR_PATH = ['string', 'number', 'function', 'object']; 133 | 134 | type.path = function(target) { 135 | if (!target && target !== 0 && target !== '') 136 | return false; 137 | 138 | return [].concat(target).every(step => anyOf(step, ALLOWED_FOR_PATH)); 139 | }; 140 | 141 | /** 142 | * Checking whether the given path is a dynamic one. 143 | * 144 | * @param {mixed} path - The path to test. 145 | * @return {boolean} 146 | */ 147 | type.dynamicPath = function(path) { 148 | return path.some(step => type.function(step) || type.object(step)); 149 | }; 150 | 151 | /** 152 | * Retrieve any monkey subpath in the given path or null if the path never comes 153 | * across computed data. 154 | * 155 | * @param {mixed} data - The data to test. 156 | * @param {array} path - The path to test. 157 | * @return {boolean} 158 | */ 159 | type.monkeyPath = function(data, path) { 160 | const subpath = []; 161 | 162 | let c = data, 163 | i, 164 | l; 165 | 166 | for (i = 0, l = path.length; i < l; i++) { 167 | subpath.push(path[i]); 168 | 169 | if (typeof c !== 'object') 170 | return null; 171 | 172 | c = c[path[i]]; 173 | 174 | if (c instanceof Monkey) 175 | return subpath; 176 | } 177 | 178 | return null; 179 | }; 180 | 181 | /** 182 | * Check if the given object property is a lazy getter used by a monkey. 183 | * 184 | * @param {mixed} o - The target object. 185 | * @param {string} propertyKey - The property to test. 186 | * @return {boolean} 187 | */ 188 | type.lazyGetter = function(o, propertyKey) { 189 | const descriptor = Object.getOwnPropertyDescriptor(o, propertyKey); 190 | 191 | return descriptor && 192 | descriptor.get && 193 | descriptor.get.isLazyGetter === true; 194 | }; 195 | 196 | /** 197 | * Returns the type of the given monkey definition or `null` if invalid. 198 | * 199 | * @param {mixed} definition - The definition to check. 200 | * @return {string|null} 201 | */ 202 | type.monkeyDefinition = function(definition) { 203 | 204 | if (type.object(definition)) { 205 | if (!type.function(definition.get) || 206 | (definition.cursors && 207 | (!type.object(definition.cursors) || 208 | !(Object.keys(definition.cursors).every(k => type.path(definition.cursors[k])))))) 209 | return null; 210 | 211 | return 'object'; 212 | } 213 | else if (type.array(definition)) { 214 | let offset = 1; 215 | 216 | if (type.object(definition[definition.length - 1])) 217 | offset++; 218 | 219 | if (!type.function(definition[definition.length - offset]) || 220 | !definition.slice(0, -offset).every(p => type.path(p))) 221 | return null; 222 | 223 | return 'array'; 224 | } 225 | 226 | return null; 227 | }; 228 | 229 | /** 230 | * Checking whether the given watcher definition is valid. 231 | * 232 | * @param {mixed} definition - The definition to check. 233 | * @return {boolean} 234 | */ 235 | type.watcherMapping = function(definition) { 236 | return type.object(definition) && 237 | Object.keys(definition).every(k => type.path(definition[k])); 238 | }; 239 | 240 | /** 241 | * Checking whether the given string is a valid operation type. 242 | * 243 | * @param {mixed} string - The string to test. 244 | * @return {boolean} 245 | */ 246 | 247 | // Ordered by likeliness 248 | const VALID_OPERATIONS = [ 249 | 'set', 250 | 'apply', 251 | 'push', 252 | 'unshift', 253 | 'concat', 254 | 'pop', 255 | 'shift', 256 | 'deepMerge', 257 | 'merge', 258 | 'splice', 259 | 'unset' 260 | ]; 261 | 262 | type.operationType = function(string) { 263 | return typeof string === 'string' && !!~VALID_OPERATIONS.indexOf(string); 264 | }; 265 | 266 | export default type; 267 | -------------------------------------------------------------------------------- /src/update.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Update 3 | * ============== 4 | * 5 | * The tree's update scheme. 6 | */ 7 | import type from './type'; 8 | import { 9 | freeze, 10 | deepFreeze, 11 | deepMerge, 12 | makeError, 13 | shallowClone, 14 | shallowMerge, 15 | splice 16 | } from './helpers'; 17 | 18 | function err(operation, expectedTarget, path) { 19 | return makeError( 20 | `Baobab.update: cannot apply the "${operation}" on ` + 21 | `a non ${expectedTarget} (path: /${path.join('/')}).`, 22 | {path} 23 | ); 24 | } 25 | 26 | /** 27 | * Function aiming at applying a single update operation on the given tree's 28 | * data. 29 | * 30 | * @param {mixed} data - The tree's data. 31 | * @param {path} path - Path of the update. 32 | * @param {object} operation - The operation to apply. 33 | * @param {object} [opts] - Optional options. 34 | * @return {mixed} - Both the new tree's data and the updated node. 35 | */ 36 | export default function update(data, path, operation, opts = {}) { 37 | const {type: operationType, value, options: operationOptions = {}} = operation; 38 | 39 | // Dummy root, so we can shift and alter the root 40 | const dummy = {root: data}, 41 | dummyPath = ['root', ...path], 42 | currentPath = []; 43 | 44 | // Walking the path 45 | let p = dummy, 46 | i, 47 | l, 48 | s; 49 | 50 | for (i = 0, l = dummyPath.length; i < l; i++) { 51 | 52 | // Current item's reference is therefore p[s] 53 | // The reason why we don't create a variable here for convenience 54 | // is because we actually need to mutate the reference. 55 | s = dummyPath[i]; 56 | 57 | // Updating the path 58 | if (i > 0) 59 | currentPath.push(s); 60 | 61 | // If we reached the end of the path, we apply the operation 62 | if (i === l - 1) { 63 | 64 | /** 65 | * Set 66 | */ 67 | if (operationType === 'set') { 68 | 69 | // Purity check 70 | if (opts.pure && p[s] === value) 71 | return {node: p[s]}; 72 | 73 | if (type.lazyGetter(p, s)) { 74 | Object.defineProperty(p, s, { 75 | value, 76 | enumerable: true, 77 | configurable: true 78 | }); 79 | } 80 | else if (opts.persistent && !operationOptions.mutableLeaf) { 81 | p[s] = shallowClone(value); 82 | } 83 | else { 84 | p[s] = value; 85 | } 86 | } 87 | 88 | /** 89 | * Monkey 90 | */ 91 | else if (operationType === 'monkey') { 92 | Object.defineProperty(p, s, { 93 | get: value, 94 | enumerable: true, 95 | configurable: true 96 | }); 97 | } 98 | 99 | /** 100 | * Apply 101 | */ 102 | else if (operationType === 'apply') { 103 | const result = value(p[s]); 104 | 105 | // Purity check 106 | if (opts.pure && p[s] === result) 107 | return {node: p[s]}; 108 | 109 | if (type.lazyGetter(p, s)) { 110 | Object.defineProperty(p, s, { 111 | value: result, 112 | enumerable: true, 113 | configurable: true 114 | }); 115 | } 116 | else if (opts.persistent) { 117 | p[s] = shallowClone(result); 118 | } 119 | else { 120 | p[s] = result; 121 | } 122 | } 123 | 124 | /** 125 | * Push 126 | */ 127 | else if (operationType === 'push') { 128 | if (!type.array(p[s])) 129 | throw err( 130 | 'push', 131 | 'array', 132 | currentPath 133 | ); 134 | 135 | if (opts.persistent) 136 | p[s] = p[s].concat([value]); 137 | else 138 | p[s].push(value); 139 | } 140 | 141 | /** 142 | * Unshift 143 | */ 144 | else if (operationType === 'unshift') { 145 | if (!type.array(p[s])) 146 | throw err( 147 | 'unshift', 148 | 'array', 149 | currentPath 150 | ); 151 | 152 | if (opts.persistent) 153 | p[s] = [value].concat(p[s]); 154 | else 155 | p[s].unshift(value); 156 | } 157 | 158 | /** 159 | * Concat 160 | */ 161 | else if (operationType === 'concat') { 162 | if (!type.array(p[s])) 163 | throw err( 164 | 'concat', 165 | 'array', 166 | currentPath 167 | ); 168 | 169 | if (opts.persistent) 170 | p[s] = p[s].concat(value); 171 | else 172 | p[s].push.apply(p[s], value); 173 | } 174 | 175 | /** 176 | * Splice 177 | */ 178 | else if (operationType === 'splice') { 179 | if (!type.array(p[s])) 180 | throw err( 181 | 'splice', 182 | 'array', 183 | currentPath 184 | ); 185 | 186 | if (opts.persistent) 187 | p[s] = splice.apply(null, [p[s]].concat(value)); 188 | else 189 | p[s].splice.apply(p[s], value); 190 | } 191 | 192 | /** 193 | * Pop 194 | */ 195 | else if (operationType === 'pop') { 196 | if (!type.array(p[s])) 197 | throw err( 198 | 'pop', 199 | 'array', 200 | currentPath 201 | ); 202 | 203 | if (opts.persistent) 204 | p[s] = splice(p[s], -1, 1); 205 | else 206 | p[s].pop(); 207 | } 208 | 209 | /** 210 | * Shift 211 | */ 212 | else if (operationType === 'shift') { 213 | if (!type.array(p[s])) 214 | throw err( 215 | 'shift', 216 | 'array', 217 | currentPath 218 | ); 219 | 220 | if (opts.persistent) 221 | p[s] = splice(p[s], 0, 1); 222 | else 223 | p[s].shift(); 224 | } 225 | 226 | /** 227 | * Unset 228 | */ 229 | else if (operationType === 'unset') { 230 | if (type.object(p)) 231 | delete p[s]; 232 | 233 | else if (type.array(p)) 234 | p.splice(s, 1); 235 | } 236 | 237 | /** 238 | * Merge 239 | */ 240 | else if (operationType === 'merge') { 241 | if (!type.object(p[s])) 242 | throw err( 243 | 'merge', 244 | 'object', 245 | currentPath 246 | ); 247 | 248 | if (opts.persistent) 249 | p[s] = shallowMerge({}, p[s], value); 250 | else 251 | p[s] = shallowMerge(p[s], value); 252 | } 253 | 254 | /** 255 | * Deep merge 256 | */ 257 | else if (operationType === 'deepMerge') { 258 | if (!type.object(p[s])) 259 | throw err( 260 | 'deepMerge', 261 | 'object', 262 | currentPath 263 | ); 264 | 265 | if (opts.persistent) 266 | p[s] = deepMerge({}, p[s], value); 267 | else 268 | p[s] = deepMerge(p[s], value); 269 | } 270 | 271 | // Deep freezing the resulting value 272 | if (opts.immutable && !operationOptions.mutableLeaf) 273 | deepFreeze(p); 274 | 275 | break; 276 | } 277 | 278 | // If we reached a leaf, we override by setting an empty object 279 | else if (type.primitive(p[s])) { 280 | p[s] = {}; 281 | } 282 | 283 | // Else, we shift the reference and continue the path 284 | else if (opts.persistent) { 285 | p[s] = shallowClone(p[s]); 286 | } 287 | 288 | // Should we freeze the current step before continuing? 289 | if (opts.immutable && l > 0) 290 | freeze(p); 291 | 292 | p = p[s]; 293 | } 294 | 295 | // If we are updating a dynamic node, we need not return the affected node 296 | if (type.lazyGetter(p, s)) 297 | return {data: dummy.root}; 298 | 299 | // Returning new data object 300 | return {data: dummy.root, node: p[s]}; 301 | } 302 | -------------------------------------------------------------------------------- /src/watcher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Watchers 3 | * ================ 4 | * 5 | * Abstraction used to listen and retrieve data from multiple parts of a 6 | * Baobab tree at once. 7 | */ 8 | import Emitter from 'emmett'; 9 | import Cursor from './cursor'; 10 | import type from './type'; 11 | import { 12 | getIn, 13 | makeError, 14 | solveUpdate 15 | } from './helpers'; 16 | 17 | /** 18 | * Watcher class. 19 | * 20 | * @constructor 21 | * @param {Baobab} tree - The watched tree. 22 | * @param {object} mapping - A mapping of the paths to watch in the tree. 23 | */ 24 | export default class Watcher extends Emitter { 25 | constructor(tree, mapping) { 26 | super(); 27 | 28 | // Properties 29 | this.tree = tree; 30 | this.mapping = null; 31 | 32 | this.state = { 33 | killed: false 34 | }; 35 | 36 | // Initializing 37 | this.refresh(mapping); 38 | 39 | // Listening 40 | this.handler = (e) => { 41 | if (this.state.killed) 42 | return; 43 | 44 | const watchedPaths = this.getWatchedPaths(); 45 | 46 | if (solveUpdate(e.data.paths, watchedPaths)) 47 | return this.emit('update'); 48 | }; 49 | 50 | this.tree.on('update', this.handler); 51 | } 52 | 53 | /** 54 | * Method used to get the current watched paths. 55 | * 56 | * @return {array} - The array of watched paths. 57 | */ 58 | getWatchedPaths() { 59 | const rawPaths = Object.keys(this.mapping) 60 | .map(k => { 61 | const v = this.mapping[k]; 62 | 63 | // Watcher mappings can accept a cursor 64 | if (v instanceof Cursor) 65 | return v.solvedPath; 66 | 67 | return this.mapping[k]; 68 | }); 69 | 70 | return rawPaths.reduce((cp, p) => { 71 | 72 | // Handling path polymorphisms 73 | p = [].concat(p); 74 | 75 | // Dynamic path? 76 | if (type.dynamicPath(p)) 77 | p = getIn(this.tree._data, p).solvedPath; 78 | 79 | if (!p) 80 | return cp; 81 | 82 | // Facet path? 83 | const monkeyPath = type.monkeyPath(this.tree._monkeys, p); 84 | 85 | if (monkeyPath) 86 | return cp.concat( 87 | getIn(this.tree._monkeys, monkeyPath).data.relatedPaths() 88 | ); 89 | 90 | return cp.concat([p]); 91 | }, []); 92 | } 93 | 94 | /** 95 | * Method used to return a map of the watcher's cursors. 96 | * 97 | * @return {object} - TMap of relevant cursors. 98 | */ 99 | getCursors() { 100 | const cursors = {}; 101 | 102 | Object.keys(this.mapping).forEach(k => { 103 | const path = this.mapping[k]; 104 | 105 | if (path instanceof Cursor) 106 | cursors[k] = path; 107 | else 108 | cursors[k] = this.tree.select(path); 109 | }); 110 | 111 | return cursors; 112 | } 113 | 114 | /** 115 | * Method used to refresh the watcher's mapping. 116 | * 117 | * @param {object} mapping - The new mapping to apply. 118 | * @return {Watcher} - Itself for chaining purposes. 119 | */ 120 | refresh(mapping) { 121 | 122 | if (!type.watcherMapping(mapping)) 123 | throw makeError('Baobab.watch: invalid mapping.', {mapping}); 124 | 125 | this.mapping = mapping; 126 | 127 | // Creating the get method 128 | const projection = {}; 129 | 130 | for (const k in mapping) 131 | projection[k] = mapping[k] instanceof Cursor ? 132 | mapping[k].path : 133 | mapping[k]; 134 | 135 | this.get = this.tree.project.bind(this.tree, projection); 136 | } 137 | 138 | /** 139 | * Methods releasing the watcher from memory. 140 | */ 141 | release() { 142 | 143 | this.tree.off('update', this.handler); 144 | this.state.killed = true; 145 | this.kill(); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /test/register.js: -------------------------------------------------------------------------------- 1 | require('@babel/register')({ 2 | presets: ['@babel/preset-env'] 3 | }); 4 | -------------------------------------------------------------------------------- /test/state.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Testing Sample State 3 | * ===================== 4 | * 5 | * Sample data used throughout the unit tests to hydrate the trees easily. 6 | */ 7 | export default { 8 | undefinedValue: undefined, 9 | primitive: 3, 10 | one: { 11 | subone: { 12 | hello: 'world' 13 | }, 14 | subtwo: { 15 | colors: ['blue', 'yellow'] 16 | } 17 | }, 18 | two: { 19 | firstname: 'John', 20 | lastname: 'Dillinger' 21 | }, 22 | pointer: 1, 23 | setLater: null, 24 | list: [[1, 2], [3, 4]], 25 | longList: [1, 2, 3, 4], 26 | items: [ 27 | {id: 'one'}, 28 | {id: 'two', user: {name: 'John', surname: 'Talbot'}}, 29 | {id: 'three'} 30 | ], 31 | sameStructureItems: [ 32 | {id: 'one', user: {name: 'Jane', surname: 'Talbot'}}, 33 | {id: 'two', user: {name: 'John', surname: 'Talbot'}} 34 | ] 35 | }; 36 | -------------------------------------------------------------------------------- /test/suites/baobab.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Core Unit Tests 3 | * ======================= 4 | */ 5 | import {strict as assert} from 'assert'; 6 | import Baobab, {Cursor, Path} from '../../src/baobab'; 7 | // @ts-ignore 8 | import type from '../../src/type'; 9 | import state from '../state'; 10 | import {assertIsFrozen} from "../utils"; 11 | 12 | const noop = () => undefined; 13 | 14 | describe('Baobab API', function() { 15 | 16 | /** 17 | * Testing the very basics of the API like tree instantiation. 18 | */ 19 | describe('Basics', function() { 20 | 21 | it('should throw an error when trying to instantiate an baobab with incorrect data.', function() { 22 | assert.throws(function() { 23 | const tree = new Baobab(undefined); 24 | tree.set('hello', 'world'); 25 | }, /invalid data/); 26 | }); 27 | }); 28 | 29 | /** 30 | * Selection and cursor creation 31 | */ 32 | describe('Selection', function() { 33 | const tree = new Baobab(state); 34 | 35 | it('selecting data in the baobab should return a cursor.', function() { 36 | assert(tree.select(['one']) instanceof Cursor); 37 | }); 38 | 39 | it('should be possible to use some polymorphism on the selection.', function() { 40 | const altCursor = tree.select('one', 'subtwo', 'colors'); 41 | 42 | assert.deepEqual(altCursor.get(), state.one.subtwo.colors); 43 | }); 44 | 45 | it('should be possible to select data using a function.', function() { 46 | const cursor = tree.select('one', 'subtwo', 'colors', v => v === 'yellow'); 47 | 48 | assert.strictEqual(cursor.get(), 'yellow'); 49 | }); 50 | 51 | it('should be possible to select data using a descriptor object.', function() { 52 | const cursor = tree.select('items', {id: 'one'}); 53 | 54 | assert.deepEqual(cursor.get(), {id: 'one'}); 55 | }); 56 | 57 | it('should be possible to select the tree\'s root safely.', function() { 58 | const cursor = tree.select(); 59 | cursor.release(); 60 | 61 | tree.set('test', 3); 62 | }); 63 | }); 64 | 65 | /** 66 | * Events 67 | */ 68 | describe('Events', function() { 69 | 70 | it('should be possible to listen to update events.', function(done) { 71 | const tree = new Baobab(state); 72 | 73 | tree.on('update', function(e) { 74 | assert.deepEqual(e.data.paths, [['one', 'subtwo', 'colors']]); 75 | done(); 76 | }); 77 | 78 | tree.update( 79 | ['one', 'subtwo', 'colors'], 80 | {type: 'set', value: 'whatever'} 81 | ); 82 | }); 83 | 84 | it('should only fire updates once when committing synchronously but when not in synchronous mode.', function(done) { 85 | const tree = new Baobab({hello: 'world'}); 86 | let count = 0; 87 | 88 | tree.on('update', () => count++); 89 | 90 | tree.set('hello', 'tada'); 91 | tree.commit(); 92 | 93 | setTimeout(function() { 94 | assert.strictEqual(count, 1); 95 | assert.strictEqual(tree.get('hello'), 'tada'); 96 | done(); 97 | }, 30); 98 | }); 99 | 100 | it('should be possible to listen to new selections.', function(done) { 101 | const tree = new Baobab({one: {two: 'hello'}}); 102 | let count = 0; 103 | 104 | tree.on('select', function(e) { 105 | assert.deepEqual(e.data.path, ['one', 'two']); 106 | assert.strictEqual(e.data.cursor.get(), 'hello'); 107 | count++; 108 | }); 109 | 110 | process.nextTick(function() { 111 | assert.strictEqual(count, 1); 112 | done(); 113 | }); 114 | 115 | tree.select('one', 'two'); 116 | }); 117 | 118 | it('should be possible to listen to get events.', function(done) { 119 | const tree = new Baobab({one: {two: 'hello'}}); 120 | let count = 0; 121 | 122 | tree.on('get', function(e) { 123 | assert.deepEqual(e.data.path, ['one', 'two']); 124 | assert.strictEqual(e.data.data, 'hello'); 125 | count++; 126 | }); 127 | 128 | process.nextTick(function() { 129 | assert.strictEqual(count, 1); 130 | done(); 131 | }); 132 | 133 | tree.get('one', 'two'); 134 | }); 135 | 136 | it('should be possible to listen to failed dynamic get events.', function(done) { 137 | const tree = new Baobab({data: [{id: 34, txt: 'Hey'}]}); 138 | 139 | let count = 0; 140 | 141 | tree.on('get', function({data: {path, solvedPath, data}}) { 142 | count++; 143 | 144 | if (count === 1) { 145 | assert.strictEqual(solvedPath, null); 146 | assert.strictEqual(data, undefined); 147 | return; 148 | } 149 | else if (count === 2) { 150 | assert.deepEqual(path, ['data']); 151 | return; 152 | } 153 | 154 | assert.deepEqual(solvedPath, ['data', 0]); 155 | 156 | if (count > 2) 157 | done(); 158 | }); 159 | 160 | tree.get('data', {id: 45}); 161 | tree.get('data'); 162 | tree.get('data', x => x.id === 34); 163 | }); 164 | 165 | it('update events should expose the tree\'s data.', function(done) { 166 | const tree = new Baobab({hello: 'world'}); 167 | 168 | tree.on('update', function(e) { 169 | assert.deepEqual(e.data.previousData, {hello: 'world'}); 170 | assert.deepEqual(e.data.currentData, {hello: 'monde'}); 171 | done(); 172 | }); 173 | 174 | tree.set('hello', 'monde'); 175 | }); 176 | 177 | it('moot updates should not trigger an update of the tree.', function() { 178 | const tree = new Baobab({items: [{id: 1}]}, {asynchronous: false}); 179 | 180 | let triggered = false; 181 | 182 | tree.on('update', function() { 183 | triggered = true; 184 | }); 185 | 186 | tree.set(['items', 0, 'id'], 1); 187 | 188 | assert(!triggered); 189 | }); 190 | 191 | it('manual commit should not trigger an update if the transaction is empty.', function() { 192 | const tree = new Baobab({items: [{id: 1}]}); 193 | 194 | let triggered = false; 195 | 196 | tree.on('update', function() { 197 | triggered = true; 198 | }); 199 | 200 | tree.set(['items', 0, 'id'], 1); 201 | tree.commit(); 202 | 203 | assert(!triggered); 204 | }); 205 | }); 206 | 207 | /** 208 | * Advanced issues 209 | */ 210 | describe('Advanced', function() { 211 | it('should be possible to release a tree.', function() { 212 | const tree = new Baobab(state), 213 | one = tree.select('one'), 214 | two = tree.select('two'); 215 | 216 | tree.on('update', noop); 217 | one.on('update', noop); 218 | two.on('update', noop); 219 | 220 | one.release(); 221 | tree.release(); 222 | 223 | // @ts-ignore 224 | assert(tree._data === undefined); 225 | }); 226 | 227 | it('the tree should shift references on updates.', function() { 228 | const list = [1], 229 | tree = new Baobab({list}, {asynchronous: false}); 230 | 231 | tree.select('list').push(2); 232 | assert.deepEqual(tree.get('list'), [1, 2]); 233 | assert(list !== tree.get('list')); 234 | }); 235 | 236 | it('the `set` operation should also shift the references.', function() { 237 | const tree = new Baobab({test: {}}, {asynchronous: false, pure: false}), 238 | o = tree.get('test'); 239 | 240 | tree.set('test', o); 241 | 242 | assert(o !== tree.get('test')); 243 | }); 244 | 245 | it('the `apply` operation should also shift the references.', function() { 246 | const tree = new Baobab({test: {}}, {asynchronous: false, pure: false}), 247 | o = tree.get('test'); 248 | 249 | tree.apply('test', () => o); 250 | 251 | assert(o !== tree.get('test')); 252 | }); 253 | 254 | it('the tree should also shift parent references.', function() { 255 | const shiftingTree = new Baobab({root: {admin: {items: [1], other: [2]}}}, {asynchronous: false}); 256 | 257 | const shiftingOriginal = shiftingTree.get(); 258 | 259 | shiftingTree.select('root', 'admin', 'items').push(2); 260 | 261 | assert.deepEqual(shiftingTree.get('root', 'admin', 'items'), [1, 2]); 262 | 263 | assert(shiftingTree.get() !== shiftingOriginal); 264 | assert(shiftingTree.get().root !== shiftingOriginal.root); 265 | assert(shiftingTree.get().root.admin !== shiftingOriginal.root.admin); 266 | assert(shiftingTree.get().root.admin.items !== shiftingOriginal.root.admin.items); 267 | assert(shiftingTree.get().root.admin.other === shiftingOriginal.root.admin.other); 268 | }); 269 | }); 270 | 271 | /** 272 | * Options 273 | */ 274 | describe('Options', function() { 275 | it('should be possible to commit changes immediately.', function() { 276 | const tree = new Baobab({hello: 'world'}, {asynchronous: false}); 277 | tree.set('hello', 'you'); 278 | assert.strictEqual(tree.get('hello'), 'you'); 279 | }); 280 | 281 | it('should be possible to let the user commit himself.', function() { 282 | const tree = new Baobab({number: 1}, {autoCommit: false, asynchronous: false}); 283 | 284 | let txCount = 0; 285 | 286 | tree.on('update', () => txCount++); 287 | tree.set('number', 2); 288 | tree.apply('number', x => x + 1); 289 | tree.commit(); 290 | tree.set('number', 5); 291 | 292 | assert.strictEqual(txCount, 1); 293 | }); 294 | 295 | it('should be possible to validate the tree and rollback on fail.', function() { 296 | let invalidCount = 0; 297 | 298 | function v(this: Baobab, previousState: any, nextState: any) { 299 | assert(this instanceof Baobab); 300 | 301 | if (typeof nextState.hello !== 'string') 302 | return new Error('Invalid tree!'); 303 | } 304 | 305 | const tree = new Baobab({hello: 'world'}, {validate: v, asynchronous: false}); 306 | 307 | tree.on('invalid', function(e) { 308 | const error = e.data.error; 309 | 310 | assert.strictEqual(error.message, 'Invalid tree!'); 311 | invalidCount++; 312 | }); 313 | 314 | tree.set('hello', 'John'); 315 | 316 | assert.strictEqual(invalidCount, 0); 317 | assert.strictEqual(tree.get('hello'), 'John'); 318 | 319 | tree.set('hello', 4); 320 | 321 | assert.strictEqual(invalidCount, 1); 322 | assert.strictEqual(tree.get('hello'), 'John'); 323 | }); 324 | 325 | it('should be possible to validate the tree and let the tree update on fail.', function() { 326 | let invalidCount = 0; 327 | 328 | function v(this: Baobab, previousState: any, nextState: any) { 329 | assert(this instanceof Baobab); 330 | 331 | if (typeof nextState.hello !== 'string') 332 | return new Error('Invalid tree!'); 333 | } 334 | 335 | const tree = new Baobab({hello: 'world'}, {validate: v, asynchronous: false, validationBehavior: 'notify'}); 336 | 337 | tree.on('invalid', function(e) { 338 | const error = e.data.error; 339 | 340 | assert.strictEqual(error.message, 'Invalid tree!'); 341 | invalidCount++; 342 | }); 343 | 344 | tree.set('hello', 'John'); 345 | 346 | assert.strictEqual(invalidCount, 0); 347 | assert.strictEqual(tree.get('hello'), 'John'); 348 | 349 | tree.set('hello', 4); 350 | 351 | assert.strictEqual(invalidCount, 1); 352 | assert.strictEqual(tree.get('hello'), 4); 353 | }); 354 | 355 | it('should be possible to validate the initial data of the tree.', function() { 356 | function validate(this: Baobab, previousState: any, nextState: any, paths?: Path[]) { 357 | assert.deepEqual(paths, [[]]); 358 | 359 | if (nextState.one !== 1) 360 | return new Error('Invalid tree!'); 361 | } 362 | 363 | const tree = new Baobab({one: 1}, {validate}); 364 | 365 | assert.strictEqual(tree.get('one'), 1); 366 | 367 | assert.throws(function() { 368 | const failingTree = new Baobab({one: 2}, {validate}); 369 | failingTree.set('one', 1); 370 | }, /invalid/); 371 | }); 372 | 373 | it('the tree should be immutable by default.', function() { 374 | let data; 375 | 376 | const tree = new Baobab( 377 | { 378 | one: { 379 | two: { 380 | three: 'Hello' 381 | } 382 | } 383 | }, 384 | { 385 | immutable: true, 386 | asynchronous: false 387 | } 388 | ); 389 | 390 | function checkFridge() { 391 | const targetData = tree.get(); 392 | 393 | assertIsFrozen(targetData); 394 | assertIsFrozen(targetData.one); 395 | assertIsFrozen(targetData.one.two); 396 | assertIsFrozen(targetData.one.two.three); 397 | 398 | if (targetData.one.two.three.four) 399 | assertIsFrozen(targetData.one.two.three.four); 400 | } 401 | 402 | checkFridge(); 403 | 404 | tree.set(['one', 'two', 'three'], 'world'); 405 | 406 | checkFridge(); 407 | 408 | tree.set(['one', 'two', 'three', 'four'], {five: 'hey'}); 409 | 410 | checkFridge(); 411 | 412 | tree.set({one: {two: {three: {four: 'hey'}}}}); 413 | 414 | tree.unset(['one', 'two']); 415 | 416 | data = tree.get(); 417 | 418 | assertIsFrozen(data); 419 | assertIsFrozen(data.one); 420 | 421 | // Arrays 422 | tree.set([{nb: 1}, {nb: 2}]); 423 | 424 | data = tree.get(); 425 | 426 | assertIsFrozen(data); 427 | assertIsFrozen(data[0]); 428 | assertIsFrozen(data[1]); 429 | 430 | tree.set(0, {nb: 3}); 431 | 432 | assertIsFrozen(data); 433 | assertIsFrozen(data[0]); 434 | assertIsFrozen(data[1]); 435 | 436 | tree.set({one: {}}); 437 | 438 | // Complex update 439 | tree.set('one', { 440 | subone: 'hey', 441 | subtwo: 'ho' 442 | }); 443 | 444 | data = tree.get(); 445 | 446 | assertIsFrozen(data); 447 | assertIsFrozen(data.one); 448 | assertIsFrozen(data.one.subone); 449 | assertIsFrozen(data.one.subtwo); 450 | }); 451 | 452 | it('should be possible to disable immutability.', function() { 453 | const immutableTree = new Baobab({hello: 'John'}), 454 | mutableTree = new Baobab({hello: 'John'}, {immutable: false}); 455 | 456 | const immutableData = immutableTree.get(); 457 | 458 | assert.throws(function() { 459 | immutableData.hello = 'Jack'; 460 | }, Error); 461 | 462 | const mutableData = mutableTree.get(); 463 | mutableData.hello = 'Jack'; 464 | assert.strictEqual(mutableTree.get('hello'), 'Jack'); 465 | }); 466 | 467 | it('if persitence is disabled, so should immutability.', function() { 468 | const tree = new Baobab({}, {persistent: false}); 469 | 470 | assert.strictEqual(tree.options.persistent, false); 471 | assert.strictEqual(tree.options.immutable, false); 472 | }); 473 | 474 | it('turning persistence off should work.', function() { 475 | const tree = new Baobab( 476 | { 477 | list: [], 478 | object: { 479 | one: 1 480 | } 481 | }, 482 | { 483 | asynchronous: false, 484 | persistent: false 485 | } 486 | ); 487 | 488 | const initialList = tree.get('list'), 489 | initialObject = tree.get('object'); 490 | 491 | tree.push('list', 2); 492 | 493 | assert.deepEqual(tree.get('list'), [2]); 494 | assert.strictEqual(tree.get('list'), initialList); 495 | 496 | tree.unshift('list', 1); 497 | 498 | assert.deepEqual(tree.get('list'), [1, 2]); 499 | assert.strictEqual(tree.get('list'), initialList); 500 | 501 | tree.concat('list', [3, 4]); 502 | 503 | assert.deepEqual(tree.get('list'), [1, 2, 3, 4]); 504 | assert.strictEqual(tree.get('list'), initialList); 505 | 506 | tree.splice('list', [0, 1]); 507 | 508 | assert.deepEqual(tree.get('list'), [2, 3, 4]); 509 | assert.strictEqual(tree.get('list'), initialList); 510 | 511 | tree.merge('object', {two: 2}); 512 | 513 | assert.deepEqual(tree.get('object'), {one: 1, two: 2}); 514 | assert.strictEqual(tree.get('object'), initialObject); 515 | }); 516 | 517 | it('should be possible to enforce purity.', function() { 518 | const tree = new Baobab({nb: 2}, {asynchronous: false}), 519 | impureTree = new Baobab({nb: 2}, {asynchronous: false, pure: false}); 520 | 521 | let updated = false, 522 | impureUpdated = false; 523 | 524 | const listener = () => (updated = true), 525 | impureListener = () => (impureUpdated = true); 526 | 527 | tree.on('update', listener); 528 | impureTree.on('update', impureListener); 529 | 530 | tree.set('nb', 2); 531 | impureTree.set('nb', 2); 532 | 533 | assert(!updated && impureUpdated); 534 | }); 535 | 536 | it('should be possible to disable monkeys\'s laziness.', function() { 537 | const tree = new Baobab({ 538 | hey: { 539 | ho: Baobab.monkey([], () => 'ho') 540 | } 541 | }); 542 | 543 | assert.strictEqual(tree.get('hey', 'ho'), 'ho'); 544 | assert(type.lazyGetter(tree.get('hey'), 'ho')); 545 | 546 | const eagerTree = new Baobab({ 547 | hey: { 548 | ho: Baobab.monkey([], () => 'ho') 549 | } 550 | }, {lazyMonkeys: false}); 551 | 552 | assert.strictEqual(eagerTree.get('hey', 'ho'), 'ho'); 553 | assert(!type.lazyGetter(eagerTree.get('hey'), 'ho')); 554 | }); 555 | 556 | it('should handle monkeys as normal object when they are disabled', function() { 557 | const monkeyObject = Baobab.monkey([], () => 'ho'); 558 | 559 | const nonMonkeyTree = new Baobab({ 560 | hey: { 561 | ho: monkeyObject 562 | } 563 | }, { 564 | monkeyBusiness: false 565 | }); 566 | 567 | const monkeyTree = new Baobab({ 568 | hey: { 569 | ho: monkeyObject 570 | } 571 | }, { 572 | monkeyBusiness: true 573 | }); 574 | 575 | assert.strictEqual(nonMonkeyTree.get('hey', 'ho'), monkeyObject); 576 | assert.notEqual(nonMonkeyTree.get('hey', 'ho'), 'ho'); 577 | 578 | assert.strictEqual(monkeyTree.get('hey', 'ho'), 'ho'); 579 | }); 580 | }); 581 | }); 582 | -------------------------------------------------------------------------------- /test/suites/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Helpers Unit Tests 3 | * ========================== 4 | */ 5 | import {strict as assert} from 'assert'; 6 | import { 7 | deepMerge, 8 | getIn, 9 | shallowMerge, 10 | splice, 11 | solveRelativePath 12 | // @ts-ignore 13 | } from '../../src/helpers'; 14 | 15 | describe('Helpers', function() { 16 | 17 | /** 18 | * Nested getter 19 | */ 20 | describe('GetIn', function() { 21 | it('should return both data at path and solved path.', function() { 22 | const data = {a: {b: {c: 'hey'}}}; 23 | 24 | assert.deepEqual( 25 | getIn(data, ['a', 'b', 'c']), 26 | {data: 'hey', solvedPath: ['a', 'b', 'c'], exists: true} 27 | ); 28 | }); 29 | 30 | it('should also work with dynamic paths.', function() { 31 | const data = {a: {b: [null, {id: 34}]}}; 32 | 33 | assert.deepEqual( 34 | getIn(data, ['a', 'b', {id: 34}]), 35 | {data: {id: 34}, solvedPath: ['a', 'b', 1], exists: true} 36 | ); 37 | }); 38 | 39 | it('should return a not-found object when the data cannot be accessed.', function() { 40 | const data = {a: null}; 41 | 42 | assert.deepEqual( 43 | getIn(data, ['a', 'b', 'c']), 44 | {data: undefined, solvedPath: ['a', 'b', 'c'], exists: false} 45 | ); 46 | 47 | const otherData = {a: [{id: 45}]}; 48 | 49 | assert.deepEqual( 50 | getIn(otherData, ['a', (e: any) => e.id === 46]), 51 | {data: undefined, solvedPath: null, exists: false} 52 | ); 53 | }); 54 | }); 55 | 56 | /** 57 | * Merge 58 | */ 59 | describe('Merge', function() { 60 | it('should be possible to shallow merge objects.', function() { 61 | const data = {a: 1, c: 3}, 62 | nestedData = {a: 1, b: {c: 2}}; 63 | 64 | assert.deepEqual(shallowMerge({}, data, {b: 2}), {a: 1, b: 2, c: 3}); 65 | assert.deepEqual(shallowMerge({}, nestedData, {b: {d: 3}}), {a: 1, b: {d: 3}}); 66 | }); 67 | 68 | it('the merge functions should be mutative.', function() { 69 | const data = {a: 1, c: 3}; 70 | 71 | shallowMerge(data, {b: 2}); 72 | 73 | assert.deepEqual(data, {a: 1, b: 2, c: 3}); 74 | }); 75 | 76 | it('should be possible to deep merge objects.', function() { 77 | const data = {inner: {a: 1, c: 3}}; 78 | 79 | assert.deepEqual(deepMerge({}, data, {inner: {b: 2}}), {inner: {a: 1, b: 2, c: 3}}); 80 | }); 81 | 82 | it('deep merge should avoid computed node keys.', function() { 83 | const data = {a: 1, b: {c: 2, $facet: {d: 3}}}; 84 | 85 | assert.deepEqual( 86 | deepMerge({}, data, {a: 5, b: {$facet: 'test'}}), 87 | {a: 5, b: {c: 2, $facet: 'test'}} 88 | ); 89 | }); 90 | 91 | it('should consider arrays are values.', function() { 92 | assert.deepEqual( 93 | deepMerge({}, {one: {two: [1, 2]}, three: 3}, {one: {two: [3, 4]}}), 94 | {one: {two: [3, 4]}, three: 3} 95 | ); 96 | }); 97 | 98 | it('merge should not pollute object prototype.', function() { 99 | const data = JSON.parse('{"__proto__": {"polluted": true}}'); 100 | 101 | deepMerge({}, data); 102 | 103 | assert.equal(Object.keys(Object.prototype).includes('polluted'), false); 104 | }); 105 | }); 106 | 107 | /** 108 | * Non-mutative splice 109 | */ 110 | describe('Splice', function() { 111 | 112 | it('should work in a non-mutative fashion.', function() { 113 | const array = ['yellow', 'blue', 'purple']; 114 | 115 | assert.deepEqual( 116 | splice(array, 0, 0), 117 | array 118 | ); 119 | 120 | assert.deepEqual( 121 | splice(array, 0, 1), 122 | ['blue', 'purple'] 123 | ); 124 | 125 | assert.deepEqual( 126 | splice(array, 1, 1), 127 | ['yellow', 'purple'] 128 | ); 129 | 130 | assert.deepEqual( 131 | splice(array, 2, 1), 132 | ['yellow', 'blue'] 133 | ); 134 | 135 | assert.deepEqual( 136 | splice(array, 2, 0), 137 | array 138 | ); 139 | 140 | assert.deepEqual( 141 | splice(array, 1, 2), 142 | ['yellow'] 143 | ); 144 | 145 | assert.deepEqual( 146 | splice(array, 2, 1, 'orange', 'gold'), 147 | ['yellow', 'blue', 'orange', 'gold'] 148 | ); 149 | 150 | assert.deepEqual( 151 | splice(array, 5, 3), 152 | array 153 | ); 154 | 155 | assert.deepEqual( 156 | splice(array, 5, 3, 'orange', 'gold'), 157 | ['yellow', 'blue', 'purple', 'orange', 'gold'] 158 | ); 159 | 160 | assert.deepEqual( 161 | splice(array, 1, 0, 'gold'), 162 | ['yellow', 'gold', 'blue', 'purple'] 163 | ); 164 | 165 | assert.deepEqual( 166 | splice(array, 1, 1, 'gold'), 167 | ['yellow', 'gold', 'purple'] 168 | ); 169 | }); 170 | 171 | it('should treat a negative nb argument as 0.', function() { 172 | const array = ['yellow', 'blue', 'purple']; 173 | 174 | assert.deepEqual( 175 | splice(array, 0, -1, 'gold'), 176 | ['gold', 'yellow', 'blue', 'purple'] 177 | ); 178 | }); 179 | 180 | it('should properly handle negative indexes.', function() { 181 | const array = [1, 2, 3, 4]; 182 | 183 | assert.deepEqual( 184 | splice(array, -1, 1), 185 | [1, 2, 3] 186 | ); 187 | 188 | assert.deepEqual( 189 | splice(array, -1, 0), 190 | [1, 2, 3, 4] 191 | ); 192 | 193 | assert.deepEqual( 194 | splice(array, -2, 2), 195 | [1, 2] 196 | ); 197 | 198 | assert.deepEqual( 199 | splice(array, -1, 1, 5), 200 | [1, 2, 3, 5] 201 | ); 202 | 203 | assert.deepEqual( 204 | splice(array, -2, 1, 5), 205 | [1, 2, 5, 4] 206 | ); 207 | 208 | assert.deepEqual( 209 | splice(array, -2, 1), 210 | [1, 2, 4] 211 | ); 212 | 213 | assert.deepEqual( 214 | splice(['yellow', 'purple'], -1, 1), 215 | ['yellow'] 216 | ); 217 | }); 218 | 219 | it('should handle predicates & descriptors as start index.', function() { 220 | const collection = [ 221 | {name: 'John'}, 222 | {name: 'Jack'} 223 | ]; 224 | 225 | assert.deepEqual( 226 | splice(collection, (e: any) => e.name === 'Jack', 1, {name: 'Paul'}), 227 | [{name: 'John'}, {name: 'Paul'}] 228 | ); 229 | 230 | assert.deepEqual( 231 | splice(collection, {name: 'Jack'}, 1, {name: 'Paul'}), 232 | [{name: 'John'}, {name: 'Paul'}] 233 | ); 234 | }); 235 | 236 | describe('Issue #472 - tree/cursor.splice does not conform with the specification as of ES6 (ECMAScript 2015)', function () { 237 | it('should be possible to splice an array when omitting the nb (deleteCount) argument', function () { 238 | const array = [0, 1, 2, 3, 4]; 239 | 240 | assert.deepEqual(splice(array, 2), [0, 1]); 241 | 242 | assert.deepEqual(splice(array, -2), [0, 1, 2]); 243 | }); 244 | 245 | it('should ignore the nb (deleteCount) argument when passing null, undefined, empty string, or false', function () { 246 | const array = [0, 1, 2, 3, 4]; 247 | 248 | assert.deepEqual(splice(array, 2, null), [0, 1, 2, 3, 4], 'null for nb'); 249 | assert.deepEqual(splice(array, 2, null, 5), [0, 1, 5, 2, 3, 4], 'null for nb with new item'); 250 | 251 | assert.deepEqual(splice(array, 2, undefined), [0, 1, 2, 3, 4], 'undefined for nb'); 252 | assert.deepEqual(splice(array, 2, undefined, 5), [0, 1, 5, 2, 3, 4], 'undefined for nb with new item'); 253 | 254 | assert.deepEqual(splice(array, 2, ''), [0, 1, 2, 3, 4], '"" for nb'); 255 | assert.deepEqual(splice(array, 2, '', 5), [0, 1, 5, 2, 3, 4], '"" for nb with new item'); 256 | 257 | assert.deepEqual(splice(array, 2, false), [0, 1, 2, 3, 4], 'false for nb'); 258 | assert.deepEqual(splice(array, 2, false, 5), [0, 1, 5, 2, 3, 4], 'false for nb with new item'); 259 | }); 260 | 261 | it('should allow for nb (deleteCount) argument to be true, a coereced string, a decimal, or Infinity', function () { 262 | const array = [0, 1, 2, 3, 4]; 263 | 264 | assert.deepEqual(splice(array, 2, true), [0, 1, 3, 4], 'true for nb'); 265 | 266 | assert.deepEqual(splice(array, 2, '1'), [0, 1, 3, 4], '"1" for nb'); 267 | 268 | assert.deepEqual(splice(array, 2, 1.2), [0, 1, 3, 4], '1.2 for nb'); 269 | 270 | assert.deepEqual(splice(array, 2, Infinity), [0, 1], 'Infinity for nb'); 271 | }); 272 | 273 | it('should throw an error when supplying an argument for nb (deleteCount) which is not parseable as number', function () { 274 | const array = [0, 1, 2, 3, 4]; 275 | 276 | assert.throws(function() { 277 | splice(array, 2, 'a'); 278 | }, Error); 279 | 280 | assert.throws(function() { 281 | splice(array, 2, {}); 282 | }, Error); 283 | }); 284 | }); 285 | }); 286 | 287 | /** 288 | * Solving relative paths 289 | */ 290 | describe('Relative paths solving', function() { 291 | it('should work for every cases.', function() { 292 | const cases = [ 293 | [['one', 'two'], ['one', 'two']], 294 | [['.', 'one', 'two'], ['base', 'sub', 'one', 'two']], 295 | [['.', 'one', '.', 'two'], ['base', 'sub', 'one', 'two']], 296 | [['one', 'two', '.'], ['one', 'two']], 297 | [['..', 'one'], ['base', 'one']], 298 | [['..', 'one', '..'], ['base']], 299 | [['..', '..', '..', '..'], []], 300 | [['..', '..', '..', 'base', '..', 'base'], ['base']] 301 | ]; 302 | 303 | cases.forEach(([path, expected], i) => assert.deepEqual(solveRelativePath(['base', 'sub'], path), expected, 'N° ' + i)); 304 | }); 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /test/suites/monkey.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Monkey Unit Tests 3 | * ========================= 4 | */ 5 | import {strict as assert} from 'assert'; 6 | import Baobab from '../../src/baobab'; 7 | // @ts-ignore 8 | import {Monkey, MonkeyDefinition} from '../../src/monkey'; 9 | // @ts-ignore 10 | import type from '../../src/type'; 11 | import {assertIsFrozen, assertIsNotFrozen} from '../utils'; 12 | import {filter, find} from 'lodash'; 13 | 14 | const noop = () => undefined; 15 | const monkey = Baobab.monkey; 16 | 17 | const getExampleState = () => ({ 18 | data: { 19 | messages: [ 20 | {from: 'John', message: 'Hey'}, 21 | {from: 'Jack', message: 'Ho'} 22 | ], 23 | fromJohn: monkey({ 24 | cursors: { 25 | messages: ['data', 'messages'] 26 | }, 27 | get({messages}) { 28 | return filter(messages, {from: 'John'}); 29 | } 30 | }) 31 | } 32 | }); 33 | 34 | describe('Monkeys', function() { 35 | 36 | it('the monkey definition type should work correctly.', function() { 37 | const validObject = {cursors: {a: ['one']}, get: noop}, 38 | validArray = [['one'], noop], 39 | invalidObject = {hello: 'world'}, 40 | invalidArray = ['so crisp!']; 41 | 42 | assert.strictEqual(type.monkeyDefinition(validObject), 'object'); 43 | assert.strictEqual(type.monkeyDefinition(validArray), 'array'); 44 | assert.strictEqual(type.monkeyDefinition(invalidObject), null); 45 | assert.strictEqual(type.monkeyDefinition(invalidArray), null); 46 | }); 47 | 48 | it('should be possible to create monkey definitions from objects.', function() { 49 | const objectNode = monkey({cursors: {a: ['one']}, get: noop}), 50 | arrayNode = monkey([['one'], noop]); 51 | 52 | assert(objectNode instanceof MonkeyDefinition); 53 | assert(arrayNode instanceof MonkeyDefinition); 54 | 55 | assert.throws(function() { 56 | // @ts-ignore 57 | monkey({hello: 'world'}); 58 | }, /invalid/); 59 | }); 60 | 61 | it('should be possible to create monkeys at instantiation.', function() { 62 | const tree = new Baobab(getExampleState()); 63 | 64 | assert.deepEqual( 65 | tree.get('data', 'fromJohn'), 66 | [{from: 'John', message: 'Hey'}] 67 | ); 68 | }); 69 | 70 | it('should be possible to create monkeys using a shorthand.', function() { 71 | const tree = new Baobab({ 72 | data: { 73 | messages: [ 74 | {from: 'John', message: 'Hey'}, 75 | {from: 'Jack', message: 'Ho'} 76 | ], 77 | greeting: 'Hello', 78 | custom: monkey( 79 | ['data', 'messages'], 80 | ['data', 'greeting'], 81 | function(messages, greeting) { 82 | return greeting + ' ' + messages[0].from; 83 | } 84 | ) 85 | } 86 | }); 87 | 88 | assert.strictEqual(tree.get('data', 'custom'), 'Hello John'); 89 | }); 90 | 91 | it('should be possible to use path polymorphism.', function() { 92 | const tree = new Baobab({ 93 | name: 'John', 94 | greeting: monkey('name', name => `Hello ${name}!`) 95 | }); 96 | 97 | assert.strictEqual(tree.get('greeting'), 'Hello John!'); 98 | }); 99 | 100 | it('should be possible to get monkeys from the tree.', function() { 101 | const tree = new Baobab({ 102 | dynamic: monkey(() => 'John') 103 | }); 104 | 105 | assert(tree.getMonkey('dynamic') instanceof Monkey); 106 | assert(tree.getMonkey('whatever') === null); 107 | }); 108 | 109 | it('computed data should be immutable.', function() { 110 | const tree = new Baobab(getExampleState()), 111 | computedData = tree.get('data', 'fromJohn'); 112 | 113 | assertIsFrozen(computedData); 114 | }); 115 | 116 | it('should be possible to access data from beyond monkeys.', function() { 117 | const tree = new Baobab(getExampleState()); 118 | 119 | assert.strictEqual( 120 | tree.get('data', 'fromJohn', 0, 'message'), 121 | 'Hey' 122 | ); 123 | }); 124 | 125 | it('monkeys should update when their dependencies update.', function() { 126 | const tree = new Baobab(getExampleState()); 127 | 128 | assert.deepEqual( 129 | tree.get('data', 'fromJohn'), 130 | [{from: 'John', message: 'Hey'}] 131 | ); 132 | 133 | tree.push(['data', 'messages'], {from: 'John', message: 'Success!'}); 134 | 135 | assert.deepEqual( 136 | tree.get('data', 'fromJohn'), 137 | [{from: 'John', message: 'Hey'}, {from: 'John', message: 'Success!'}] 138 | ); 139 | }); 140 | 141 | it('monkeys should be able to work with dynamic paths.', function() { 142 | const tree = new Baobab( 143 | { 144 | list: [{id: 1, name: 'John'}], 145 | greeting: monkey({ 146 | cursors: { 147 | person: ['list', {id: 1}] 148 | }, 149 | get({person: {name}}) { 150 | return `Hello ${name}`; 151 | } 152 | }) 153 | } 154 | ); 155 | 156 | assert.strictEqual(tree.get('greeting'), 'Hello John'); 157 | 158 | tree.set(['list', 0, 'name'], 'Jack'); 159 | 160 | assert.strictEqual(tree.get('greeting'), 'Hello Jack'); 161 | }); 162 | 163 | it('cursors with a monkey in the path should work correctly.', function(done) { 164 | const tree = new Baobab(getExampleState()), 165 | cursor = tree.select('data', 'fromJohn'); 166 | 167 | cursor.on('update', () => done()); 168 | 169 | assert.deepEqual( 170 | cursor.get(), 171 | [{from: 'John', message: 'Hey'}] 172 | ); 173 | 174 | tree.push(['data', 'messages'], {from: 'John', message: 'Success!'}); 175 | 176 | assert.deepEqual( 177 | cursor.get(), 178 | [{from: 'John', message: 'Hey'}, {from: 'John', message: 'Success!'}] 179 | ); 180 | }); 181 | 182 | it('cursors should be able to listen to beyond monkeys.', function() { 183 | const tree = new Baobab(getExampleState()), 184 | cursor = tree.select('data', 'fromJohn', 0, 'message'); 185 | 186 | assert.strictEqual( 187 | cursor.get(), 188 | 'Hey' 189 | ); 190 | }); 191 | 192 | it('getting a higher key should correctly solve computed data.', function() { 193 | const tree = new Baobab(getExampleState()); 194 | 195 | assert.deepEqual( 196 | tree.get(), 197 | { 198 | data: { 199 | messages: [ 200 | {from: 'John', message: 'Hey'}, 201 | {from: 'Jack', message: 'Ho'} 202 | ], 203 | fromJohn: [{from: 'John', message: 'Hey'}] 204 | } 205 | } 206 | ); 207 | 208 | assert.deepEqual( 209 | tree.get('data'), 210 | { 211 | messages: [ 212 | {from: 'John', message: 'Hey'}, 213 | {from: 'Jack', message: 'Ho'} 214 | ], 215 | fromJohn: [{from: 'John', message: 'Hey'}] 216 | } 217 | ); 218 | }); 219 | 220 | it('should be possible to serialize the tree or some of its parts.', function() { 221 | const tree = new Baobab(getExampleState()); 222 | 223 | assert.deepEqual( 224 | tree.serialize(), 225 | { 226 | data: { 227 | messages: [ 228 | {from: 'John', message: 'Hey'}, 229 | {from: 'Jack', message: 'Ho'} 230 | ] 231 | } 232 | } 233 | ); 234 | 235 | assert.deepEqual( 236 | tree.serialize('data'), 237 | { 238 | messages: [ 239 | {from: 'John', message: 'Hey'}, 240 | {from: 'Jack', message: 'Ho'} 241 | ] 242 | } 243 | ); 244 | 245 | assert.deepEqual( 246 | tree.select('data').serialize(), 247 | { 248 | messages: [ 249 | {from: 'John', message: 'Hey'}, 250 | {from: 'Jack', message: 'Ho'} 251 | ] 252 | } 253 | ); 254 | }); 255 | 256 | it('should work recursively.', function(done) { 257 | const inc = (x: number) => x + 1; 258 | 259 | const tree = new Baobab({ 260 | data: { 261 | number: 1 262 | }, 263 | computed: { 264 | one: monkey(['data', 'number'], inc), 265 | two: monkey(['computed', 'one'], inc) 266 | } 267 | }); 268 | 269 | assert.strictEqual(tree.get('computed', 'one'), 2); 270 | assert.strictEqual(tree.get('computed', 'two'), 3); 271 | 272 | tree.select('data', 'number').on('update', () => { 273 | assert.strictEqual(tree.get('computed', 'one'), 6); 274 | assert.strictEqual(tree.get('computed', 'two'), 7); 275 | done(); 276 | }); 277 | 278 | tree.set(['data', 'number'], 5); 279 | }); 280 | 281 | it('should handle selections beyond monkeys and recursivity.', function() { 282 | const tree = new Baobab({ 283 | defaultLocale: 'en', 284 | locale: monkey( 285 | ['user', 'locale'], 286 | ['defaultLocale'], 287 | (userLocale, locale) => { 288 | return userLocale || locale; 289 | } 290 | ), 291 | userId: null, 292 | user: monkey( 293 | ['users'], 294 | ['userId'], 295 | (users, id) => users[id] 296 | ), 297 | users: {} 298 | }); 299 | 300 | assert.strictEqual(tree.get('locale'), 'en'); 301 | 302 | tree.set(['users', 1], {id: 1, locale: 'de'}); 303 | tree.set('userId', 1); 304 | tree.commit(); 305 | 306 | assert.strictEqual(tree.get('users', 1, 'locale'), 'de'); 307 | assert.strictEqual(tree.get('user', 'locale'), 'de'); 308 | assert.strictEqual(tree.get('locale'), 'de'); 309 | }); 310 | 311 | it('should even work with complex recursivity.', function() { 312 | const tree = new Baobab({ 313 | activePageNumber: 1, 314 | products: { 315 | genes: { 316 | howManyGenes: null, 317 | customVectorRequired: true, 318 | completed: monkey( 319 | ['.', 'customVectorRequired'], 320 | ['.', 'howManyGenes'], 321 | (customVectorRequired, howManyGenes) => ( 322 | customVectorRequired !== null && 323 | howManyGenes !== null 324 | ) 325 | ) 326 | } 327 | }, 328 | pages: { 329 | UserInfoPage: { 330 | number: 0, 331 | completed: false 332 | }, 333 | SelectProductPage: { 334 | number: 1, 335 | completed: monkey( 336 | ['products'], 337 | products => products.genes.completed 338 | ) 339 | } 340 | }, 341 | currentPage: monkey( 342 | ['activePageNumber'], 343 | ['pages'], 344 | (activePageNumber, pages) => { 345 | return find(pages, page => page.number === activePageNumber); 346 | } 347 | ) 348 | }, {asynchronous: false, lazyMonkeys: false}); 349 | 350 | assert(!tree.get('currentPage', 'completed')); 351 | 352 | tree.set(['products', 'genes', 'howManyGenes'], 1); 353 | 354 | assert.deepEqual(tree.get(['pages', 'SelectProductPage']), { 355 | completed: true, 356 | number: 1 357 | }); 358 | 359 | assert(tree.get('currentPage', 'completed')); 360 | }); 361 | 362 | it('recursivity should take updates into account.', function() { 363 | let count = 0; 364 | 365 | const tree = new Baobab({ 366 | rows: [1, 2, 3, 4, 5], 367 | visibleRows: {start: 0, end: 0}, 368 | rowLength: monkey([ 369 | ['rows'], 370 | function(rows) { 371 | return rows.length; 372 | } 373 | ]), 374 | specialRows: monkey([ 375 | ['rows'], 376 | function(rows) { 377 | return rows; 378 | } 379 | ]), 380 | visibleRowsData: monkey([ 381 | ['specialRows'], 382 | ['visibleRows'], 383 | function(specialRows: number[], visibleRows: {start: number; end: number;}) { 384 | count++; 385 | return specialRows.slice(visibleRows.start, visibleRows.end); 386 | } 387 | ]), 388 | }, {asynchronous: false}); 389 | 390 | tree.get('visibleRowsData'); 391 | tree.select('visibleRows').set({start: 1, end: 4}); 392 | tree.get('visibleRowsData'); 393 | 394 | assert.strictEqual(count, 2); 395 | }); 396 | 397 | it('data retrieved through facets should be immutable by default.', function() { 398 | const tree = new Baobab(getExampleState()), 399 | data = tree.get(); 400 | 401 | assertIsFrozen(data.data); 402 | assertIsFrozen(data.data.fromJohn); 403 | assertIsFrozen(data.data.fromJohn[0]); 404 | 405 | const mutableTree = new Baobab(getExampleState(), {immutable: false}), 406 | mutableData = mutableTree.get(); 407 | 408 | assertIsNotFrozen(mutableData.data); 409 | assertIsNotFrozen(mutableData.data.fromJohn); 410 | assertIsNotFrozen(mutableData.data.fromJohn[0]); 411 | }); 412 | 413 | it('should warn the user when he attempts to update a path beneath a monkey.', function() { 414 | const tree = new Baobab(getExampleState()); 415 | 416 | assert.throws(function() { 417 | tree.set(['data', 'fromJohn', 0, 'text'], 'Shawarma'); 418 | }, /read-only/); 419 | }); 420 | 421 | it('should be possible to add new monkeys at runtime.', function() { 422 | const tree = new Baobab( 423 | { 424 | data: { 425 | colors: ['yellow', 'blue', 'purple'] 426 | } 427 | }, 428 | {asynchronous: false} 429 | ); 430 | 431 | const final = monkey(['data', 'colors'], cl => cl.filter((c: string[]) => c.slice(-1)[0] === 'e')), 432 | leading = monkey(['data', 'colors'], cl => cl.filter((c: string[]) => c[0] === 'y')); 433 | 434 | tree.set(['data', 'filtered'], final); 435 | 436 | assert.deepEqual(tree.get('data', 'filtered'), ['blue', 'purple']); 437 | 438 | tree.set(['data', 'computed', 'leader'], leading); 439 | 440 | assert.deepEqual(tree.get('data', 'computed', 'leader'), ['yellow']); 441 | }); 442 | 443 | it('should be lazy by default.', function() { 444 | let count = 0; 445 | 446 | const tree = new Baobab({ 447 | items: [], 448 | string: monkey(['items'], function(items) { 449 | count++; 450 | return items.join(','); 451 | }) 452 | }); 453 | 454 | const cursor = tree.select('items'); 455 | cursor.push(1); 456 | cursor.push(2); 457 | 458 | assert.strictEqual(count, 0); 459 | 460 | cursor.push(3); 461 | assert.strictEqual(tree.get('string'), '1,2,3'); 462 | assert.strictEqual(count, 1); 463 | }); 464 | 465 | describe('should be possible to replace monkeys at runtime.', function() { 466 | it('with default tree.', function() { 467 | const tree = new Baobab( 468 | { 469 | data: { 470 | colors: ['yellow', 'blue'], 471 | selected: monkey(['data', 'colors'], c => c[0]) 472 | } 473 | }, 474 | {asynchronous: false} 475 | ); 476 | 477 | assert.strictEqual(tree.get('data', 'selected'), 'yellow'); 478 | tree.set(['data', 'selected'], monkey(['data', 'colors'], c => c[1])); 479 | assert.strictEqual(tree.get('data', 'selected'), 'blue'); 480 | tree.set(['data', 'colors', 1], 'purple'); 481 | assert.strictEqual(tree.get('data', 'selected'), 'purple'); 482 | }); 483 | 484 | it('with mutable tree.', function() { 485 | const tree = new Baobab( 486 | { 487 | data: { 488 | colors: ['yellow', 'blue'], 489 | selected: monkey(['data', 'colors'], c => c[0]) 490 | } 491 | }, 492 | {asynchronous: false, immutable: false} 493 | ); 494 | 495 | assert.strictEqual(tree.get('data', 'selected'), 'yellow'); 496 | tree.set(['data', 'selected'], monkey(['data', 'colors'], c => c[1])); 497 | assert.strictEqual(tree.get('data', 'selected'), 'blue'); 498 | tree.set(['data', 'colors', 1], 'purple'); 499 | assert.strictEqual(tree.get('data', 'selected'), 'purple'); 500 | }); 501 | 502 | it('with non-persistent tree.', function() { 503 | const tree = new Baobab( 504 | { 505 | data: { 506 | colors: ['yellow', 'blue'], 507 | selected: monkey(['data', 'colors'], c => c[0]) 508 | } 509 | }, 510 | {asynchronous: false, persistent: false} 511 | ); 512 | 513 | assert.strictEqual(tree.get('data', 'selected'), 'yellow'); 514 | tree.set(['data', 'selected'], monkey(['data', 'colors'], c => c[1])); 515 | assert.strictEqual(tree.get('data', 'selected'), 'blue'); 516 | tree.set(['data', 'colors', 1], 'purple'); 517 | assert.strictEqual(tree.get('data', 'selected'), 'purple'); 518 | }); 519 | 520 | it('with impure tree.', function() { 521 | const tree = new Baobab( 522 | { 523 | data: { 524 | colors: ['yellow', 'blue'], 525 | selected: monkey(['data', 'colors'], c => c[0]) 526 | } 527 | }, 528 | {asynchronous: false, pure: false} 529 | ); 530 | 531 | assert.strictEqual(tree.get('data', 'selected'), 'yellow'); 532 | tree.set(['data', 'selected'], monkey(['data', 'colors'], c => c[1])); 533 | assert.strictEqual(tree.get('data', 'selected'), 'blue'); 534 | tree.set(['data', 'colors', 1], 'purple'); 535 | assert.strictEqual(tree.get('data', 'selected'), 'purple'); 536 | }); 537 | 538 | it('with mutable, non-persistent, impure tree.', function() { 539 | const tree = new Baobab( 540 | { 541 | data: { 542 | colors: ['yellow', 'blue'], 543 | selected: monkey(['data', 'colors'], c => c[0]) 544 | } 545 | }, 546 | {asynchronous: false, immutable: false, persistent: false, pure: false} 547 | ); 548 | 549 | assert.strictEqual(tree.get('data', 'selected'), 'yellow'); 550 | tree.set(['data', 'selected'], monkey(['data', 'colors'], c => c[1])); 551 | assert.strictEqual(tree.get('data', 'selected'), 'blue'); 552 | tree.set(['data', 'colors', 1], 'purple'); 553 | assert.strictEqual(tree.get('data', 'selected'), 'purple'); 554 | }); 555 | 556 | it('releases all listeners from the existing monkey', function() { 557 | const state = { 558 | data: { 559 | colors: ['yellow', 'blue'], 560 | selected: monkey(['data', 'colors'], c => c[0]) 561 | } 562 | }; 563 | const tree = new Baobab(state); 564 | 565 | assert.strictEqual(tree.get('data', 'selected'), 'yellow'); 566 | assert.strictEqual(tree.listeners('write').length, 1); 567 | assert.strictEqual(tree.listeners('_monkey').length, 1); 568 | 569 | tree.set(['data', 'selected'], monkey(['data', 'colors'], c => c[1])); 570 | assert.strictEqual(tree.get('data', 'selected'), 'blue'); 571 | assert.strictEqual(tree.listeners('write').length, 1); 572 | assert.strictEqual(tree.listeners('_monkey').length, 1); 573 | 574 | tree.set(state); 575 | assert.strictEqual(tree.get('data', 'selected'), 'yellow'); 576 | assert.strictEqual(tree.listeners('write').length, 1); 577 | assert.strictEqual(tree.listeners('_monkey').length, 1); 578 | }); 579 | }); 580 | 581 | it('should be possible to drop monkeys somehow.', function() { 582 | const tree = new Baobab( 583 | { 584 | data: { 585 | colors: ['yellow', 'blue'], 586 | selected: monkey(['data', 'colors'], c => c[0]) 587 | } 588 | }, 589 | {asynchronous: false} 590 | ); 591 | 592 | tree.unset(['data', 'selected']); 593 | 594 | assert.deepEqual( 595 | tree.get('data'), 596 | {colors: ['yellow', 'blue']} 597 | ); 598 | }); 599 | 600 | it('merging should not disturb monkeys.', function() { 601 | const tree = new Baobab( 602 | { 603 | user: { 604 | name: 'Jack', 605 | surname: 'White', 606 | fullname: monkey({ 607 | cursors: { 608 | name: ['user', 'name'], 609 | surname: ['user', 'surname'] 610 | }, 611 | get: ({name, surname}) => `${name} ${surname}` 612 | }) 613 | } 614 | }, 615 | { 616 | asynchronous: false 617 | } 618 | ); 619 | 620 | assert.strictEqual(tree.get('user', 'fullname'), 'Jack White'); 621 | 622 | tree.merge('user', {name: 'John', surname: 'Black'}); 623 | 624 | assert.strictEqual(tree.get('user', 'fullname'), 'John Black'); 625 | 626 | const altTree = new Baobab( 627 | { 628 | user: { 629 | name: 'Jack', 630 | surname: 'White', 631 | fullname: monkey({ 632 | cursors: { 633 | name: ['user', 'name'], 634 | surname: ['user', 'surname'] 635 | }, 636 | get: ({name, surname}) => `${name} ${surname}` 637 | }) 638 | } 639 | }, 640 | { 641 | asynchronous: false 642 | } 643 | ); 644 | 645 | assert.strictEqual(altTree.get('user', 'fullname'), 'Jack White'); 646 | 647 | altTree.merge('user', {fullname: monkey(['user', 'name'], name => 'Hello ' + name)}); 648 | 649 | assert.strictEqual(altTree.get('user', 'fullname'), 'Hello Jack'); 650 | }); 651 | 652 | it('should be possible to use relative paths when defining monkeys\' dependencies.', function() { 653 | const fullname = (name: string, surname: string) => `${name} ${surname}`; 654 | 655 | const tree = new Baobab({ 656 | data: { 657 | user: { 658 | name: 'John', 659 | surname: 'Doe', 660 | fullnameArray: monkey( 661 | ['.', 'name'], 662 | ['.', 'surname'], 663 | fullname 664 | ), 665 | fullnameObject: monkey({ 666 | cursors: { 667 | name: ['.', 'name'], 668 | surname: ['.', 'surname'] 669 | }, 670 | get: ({name, surname}) => fullname(name, surname) 671 | }), 672 | nested: { 673 | fullnameNested: monkey( 674 | ['..', 'name'], 675 | ['..', 'surname'], 676 | fullname 677 | ) 678 | } 679 | } 680 | } 681 | }); 682 | 683 | assert.strictEqual(tree.get('data', 'user', 'fullnameArray'), 'John Doe'); 684 | assert.strictEqual(tree.get('data', 'user', 'fullnameObject'), 'John Doe'); 685 | assert.strictEqual(tree.get('data', 'user', 'nested', 'fullnameNested'), 'John Doe'); 686 | }); 687 | 688 | describe('with immutable and persistent tree', function() { 689 | it('should be lazy if added at runtime.', function() { 690 | let shouldHaveBeenCalled = false; 691 | 692 | const tree = new Baobab( 693 | { 694 | data: { 695 | colors: ['yellow', 'blue'], 696 | selected: monkey(['data', 'colors'], c => c[0]) 697 | } 698 | }, 699 | {asynchronous: false, immutable: true, persistent: true} 700 | ); 701 | 702 | const yellow = tree.get('data', 'selected'); 703 | assert.strictEqual('yellow', yellow); 704 | 705 | tree.set(['data', 'selected'], monkey(['data', 'colors'], function(c) { 706 | if (shouldHaveBeenCalled) 707 | return c[1]; 708 | throw new Error('should not be called'); 709 | })); 710 | 711 | shouldHaveBeenCalled = true; 712 | 713 | const blue = tree.get('data', 'selected'); 714 | assert.strictEqual('blue', blue); 715 | }); 716 | 717 | it('should be lazy.', function() { 718 | let shouldHaveBeenCalled = false; 719 | 720 | const tree = new Baobab( 721 | { 722 | data: { 723 | colors: ['yellow', 'blue'], 724 | selected: monkey(['data', 'colors'], function(c) { 725 | if (shouldHaveBeenCalled) 726 | return c[0]; 727 | throw new Error('should not be called'); 728 | }) 729 | } 730 | }, 731 | {asynchronous: false, immutable: true, persistent: true} 732 | ); 733 | 734 | shouldHaveBeenCalled = true; 735 | 736 | const yellow = tree.get('data', 'selected'); 737 | assert.strictEqual('yellow', yellow); 738 | }); 739 | }); 740 | 741 | describe('without immutability or persistence', function() { 742 | it('should be lazy if added at runtime.', function() { 743 | let shouldHaveBeenCalled = false; 744 | 745 | const tree = new Baobab( 746 | { 747 | data: { 748 | colors: ['yellow', 'blue'], 749 | selected: monkey(['data', 'colors'], c => c[0]) 750 | } 751 | }, 752 | {asynchronous: false, immutable: false, persistent: false} 753 | ); 754 | 755 | const yellow = tree.get('data', 'selected'); 756 | assert.strictEqual('yellow', yellow); 757 | 758 | tree.set(['data', 'selected'], monkey(['data', 'colors'], function(c) { 759 | if (shouldHaveBeenCalled) 760 | return c[1]; 761 | throw new Error('should not be called'); 762 | })); 763 | 764 | shouldHaveBeenCalled = true; 765 | 766 | const blue = tree.get('data', 'selected'); 767 | assert.strictEqual('blue', blue); 768 | }); 769 | 770 | it('should be lazy.', function() { 771 | let shouldHaveBeenCalled = false; 772 | 773 | const tree = new Baobab( 774 | { 775 | data: { 776 | colors: ['yellow', 'blue'], 777 | selected: monkey(['data', 'colors'], function(c) { 778 | if (shouldHaveBeenCalled) 779 | return c[0]; 780 | throw new Error('should not be called'); 781 | }) 782 | } 783 | }, 784 | {asynchronous: false, immutable: false, persistent: false} 785 | ); 786 | 787 | shouldHaveBeenCalled = true; 788 | 789 | const yellow = tree.get('data', 'selected'); 790 | assert.strictEqual('yellow', yellow); 791 | }); 792 | }); 793 | 794 | it('should be possible to disable a single monkey\'s immutability.', function() { 795 | const tree = new Baobab({ 796 | node: monkey({ 797 | get: () => ({hello: 'world'}), 798 | options: { 799 | immutable: false 800 | } 801 | }) 802 | }); 803 | 804 | assertIsNotFrozen(tree.get('node')); 805 | assert.deepEqual(tree.get('node'), {hello: 'world'}); 806 | }); 807 | 808 | it('should be possible to disable a single monkey\'s immutability using the shorthand method.', function() { 809 | const tree = new Baobab({ 810 | node: monkey(() => ({hello: 'world'}), {immutable: false}) 811 | }); 812 | 813 | assertIsNotFrozen(tree.get('node')); 814 | assert.deepEqual(tree.get('node'), {hello: 'world'}); 815 | }); 816 | 817 | it('monkey\'s laziness should not mess things up when a monkey\'s immutability is disabled.', function() { 818 | class Record { 819 | list: number[]; 820 | 821 | constructor() { 822 | this.list = []; 823 | } 824 | 825 | add(nb: number) { 826 | this.list.push(nb); 827 | } 828 | } 829 | 830 | const tree = new Baobab({ 831 | record: monkey(() => { 832 | return new Record(); 833 | }, {immutable: false}) 834 | }, {asynchronous: false, lazyMonkeys: false}); 835 | 836 | const record = tree.get('record'); 837 | 838 | assert(record instanceof Record); 839 | 840 | assert.deepEqual(record.list, []); 841 | 842 | record.add(45); 843 | 844 | assert.deepEqual(record.list, [45]); 845 | }); 846 | 847 | describe('Issue #430 - All non-monkey keys are lost during merge when monkey present', function () { 848 | 849 | it('should not drop data', function () { 850 | const tree = new Baobab({ 851 | cat: { 852 | alive: true, 853 | meow: monkey(['cat', 'alive'], hasLife => { 854 | return hasLife ? 'Meeeoooow!' : ''; 855 | }) 856 | }, 857 | birdCage: { 858 | canary: 'canary', 859 | sound: monkey(['birdCage', 'canary'], hasLife => { 860 | return hasLife ? 'Tweet!' : ''; 861 | }) 862 | } 863 | }, {asynchronous: false}); 864 | 865 | tree.merge({cat: {alive: false}}); 866 | assert.strictEqual(tree.get('cat', 'alive'), false); 867 | assert.strictEqual(tree.get('birdCage', 'canary'), 'canary'); 868 | }); 869 | }); 870 | 871 | describe('Issue #422 - nested monkey errors when listening to undefined path', function () { 872 | 873 | it('should not drop monkeys', function () { 874 | const tree = new Baobab({ 875 | blubb: { 876 | data: { 877 | number: 1, 878 | double: monkey(['.', 'number'], n => n * 2) 879 | }, 880 | other: { 881 | tripple: monkey(['..', 'data', 'number'], n => n * 3) 882 | } 883 | } 884 | }, {asynchronous: false}); 885 | 886 | tree.merge(['blubb'], {data: {dummy: 2, number: 7}}); 887 | 888 | assert.strictEqual(tree.get('blubb', 'data', 'dummy'), 2); 889 | assert.strictEqual(tree.get('blubb', 'other', 'tripple'), 21); 890 | }); 891 | 892 | }); 893 | 894 | describe('Issue #448 - Monkeys don\'t emit update events?', function () { 895 | it('update events emitted by a monkey should also reach the parents of the monkey.', function (done) { 896 | const tree = new Baobab({ 897 | value: 5, 898 | monkeys: { 899 | valueSquared: monkey(['value'], value => value * value) 900 | } 901 | }, { 902 | lazyMonkeys: false 903 | }); 904 | 905 | const newValue = 10; 906 | 907 | const parentCursor = tree.select('monkeys'); 908 | parentCursor.on('update', () => { 909 | assert.strictEqual(tree.get(['monkeys', 'valueSquared']), newValue * newValue); 910 | done(); 911 | }); 912 | 913 | tree.set(['value'], newValue); 914 | }); 915 | }); 916 | 917 | }); 918 | -------------------------------------------------------------------------------- /test/suites/watcher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Baobab Watchers Unit Tests 3 | * =========================== 4 | */ 5 | import {strict as assert} from 'assert'; 6 | import Baobab, {Cursor} from '../../src/baobab'; 7 | 8 | describe('Watchers', function() { 9 | 10 | it('should be possible to track some paths within the tree.', function() { 11 | const tree = new Baobab({ 12 | data: { 13 | greeting: 'Hello', 14 | name: 'Jack' 15 | } 16 | }, {asynchronous: false}); 17 | 18 | const watcher = tree.watch({ 19 | greeting: ['data', 'greeting'], 20 | name: ['data', 'name'] 21 | }); 22 | 23 | let count = 0; 24 | const inc = () => count++; 25 | 26 | watcher.on('update', inc); 27 | 28 | assert.deepEqual(watcher.get(), { 29 | greeting: 'Hello', 30 | name: 'Jack' 31 | }); 32 | 33 | tree.set(['data', 'name'], 'John'); 34 | tree.set('data', {}); 35 | tree.set('hey', 'ho'); 36 | 37 | assert.strictEqual(count, 2); 38 | }); 39 | 40 | it('should be possible to give cursors to a watcher.', function() { 41 | const tree = new Baobab({ 42 | data: { 43 | greeting: 'Hello', 44 | name: 'Jack' 45 | } 46 | }, {asynchronous: false}); 47 | 48 | const watcher = tree.watch({ 49 | greeting: tree.select(['data', 'greeting']), 50 | name: tree.select(['data', 'name']) 51 | }); 52 | 53 | let count = 0; 54 | const inc = () => count++; 55 | 56 | watcher.on('update', inc); 57 | 58 | assert.deepEqual(watcher.get(), { 59 | greeting: 'Hello', 60 | name: 'Jack' 61 | }); 62 | 63 | tree.set(['data', 'name'], 'John'); 64 | tree.set('data', {}); 65 | tree.set('hey', 'ho'); 66 | 67 | assert.strictEqual(count, 2); 68 | }); 69 | 70 | it('should be possible to pass paths following usual polymorphisms.', function() { 71 | const tree = new Baobab({name: 'John', surname: 'Talbot'}, {asynchronous: false}); 72 | 73 | const watcher = tree.watch({ 74 | name: 'name', 75 | surname: 'surname' 76 | }); 77 | 78 | assert.deepEqual(watcher.get(), {name: 'John', surname: 'Talbot'}); 79 | 80 | tree.set('name', 'Jack'); 81 | 82 | assert.deepEqual(watcher.get(), {name: 'Jack', surname: 'Talbot'}); 83 | }); 84 | 85 | it('should be possible to use dynamic paths.', function() { 86 | const tree = new Baobab({ 87 | data: [{id: 0, txt: 'Hello'}, {id: 1, txt: 'World'}] 88 | }, {asynchronous: false}); 89 | 90 | const watcher = tree.watch({ 91 | one: ['data', {id: 0}, 'txt'], 92 | two: ['data', x => x.id === 1, 'txt'] 93 | }); 94 | 95 | let count = 0; 96 | const inc = () => count++; 97 | 98 | watcher.on('update', inc); 99 | 100 | assert.deepEqual(watcher.get(), {one: 'Hello', two: 'World'}); 101 | 102 | tree.set(['data', 0, 'txt'], 'Hi'); 103 | tree.set('data', {}); 104 | tree.set('hey', 'ho'); 105 | 106 | assert.strictEqual(count, 1); 107 | }); 108 | 109 | it('should be possible to watch over monkeys.', function(done) { 110 | const tree = new Baobab({ 111 | data: { 112 | colors: ['yellow', 'blue'], 113 | phrase: Baobab.monkey(['data', 'colors', 1], (color) => color + ' jasmine') 114 | } 115 | }); 116 | 117 | const watcher = tree.watch({ 118 | phrase: ['data', 'phrase'] 119 | }); 120 | 121 | watcher.on('update', function() { 122 | assert.deepEqual(watcher.get(), {phrase: 'yellow jasmine'}); 123 | done(); 124 | }); 125 | 126 | tree.unshift(['data', 'colors'], 'purple'); 127 | }); 128 | 129 | it('should be possible to watch over paths beneath monkeys.', function(done) { 130 | const tree = new Baobab({ 131 | object: { 132 | hello: 'Jack' 133 | }, 134 | dynamic: Baobab.monkey(['object'], o => o) 135 | }); 136 | 137 | const watcher = tree.watch({d: ['dynamic', 'hello']}); 138 | 139 | watcher.on('update', function() { 140 | assert.deepEqual(watcher.get(), {d: 'John'}); 141 | done(); 142 | }); 143 | 144 | tree.set('object', {hello: 'John'}); 145 | }); 146 | 147 | it('should be possible to retrieve a mapping of the watcher\'s cursors.', function() { 148 | const tree = new Baobab({ 149 | one: 1, 150 | two: 2 151 | }); 152 | 153 | const watcher = tree.watch({ 154 | one: ['one'], 155 | two: tree.select('two') 156 | }); 157 | 158 | const cursors = watcher.getCursors(); 159 | 160 | assert(Object.keys(cursors).length, 2); 161 | assert(Object.keys(cursors).every(k => cursors[k] instanceof Cursor)); 162 | 163 | assert.strictEqual(cursors.one.get(), 1); 164 | assert.strictEqual(cursors.two.get(), 2); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import {strict as assert} from "assert"; 2 | import {inspect} from 'util'; 3 | // @ts-ignore 4 | import type from '../src/type'; 5 | 6 | // Creating a special assertion for frozen objects 7 | export function assertIsFrozen(v: object) { 8 | assert( 9 | type.primitive(v) || Object.isFrozen(v), 10 | inspect(v) + ' is not frozen.' 11 | ); 12 | }; 13 | 14 | export function assertIsNotFrozen(v: object) { 15 | assert( 16 | type.primitive(v) || !Object.isFrozen(v), 17 | inspect(v) + ' is frozen.' 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "downlevelIteration": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "only-arrow-functions": false, 5 | "one-variable-per-declaration": false, 6 | "max-classes-per-file": false 7 | } 8 | } 9 | --------------------------------------------------------------------------------