├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── gulpfile.js ├── package.json ├── proposal.js ├── src ├── index.js ├── service.js ├── subscription.js ├── topic.js ├── traverse.js └── util.js └── test └── GooeySpec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": [ 4 | "syntax-flow", 5 | "transform-flow-strip-types", 6 | ["typecheck", { 7 | "disable": { 8 | "production": true 9 | } 10 | }], 11 | ] 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | bower_components/ 5 | coverage/ 6 | coverage.html 7 | npm-debug.log 8 | private/ 9 | browser/ 10 | .nyc_output 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 MadHax, LLC http://madhax.io 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gooey 2 | 3 | > :cactus: Hierarchical PubSub data synchronization solution for ES6 4 | 5 | ## tl;dr 6 | 7 | Gooey intends to alleviate data and state synchronization challenges in Single Page Applications by combining the following traits, patterns, and philosophies: 8 | 9 | * Publish / Subscribe as primary data / state synchronization mechanism 10 | * Optimized bi-directional data traversals (synchronous and asynchronous) 11 | * Hierarchical acyclic relationships between `Services` 12 | * Allow decoupled communication between `Services` via pattern-matched topics (can go even further with a message-box) 13 | * `Services` are canonical sources of entity states 14 | * `Services` are proxies (data can be safely mutated by a `Service` before being passed on) 15 | * `Promises` everywhere 16 | * Emphasize reactive data structures instead of events or control flows (but don't prevent this) 17 | * URLs are often clumsy or inadequate for representing every view state (i.e. those containing multiple selectable entities that affect other components) and should not act as a canonical source of state 18 | * Components that exist out of view or on other "pages" are often functionally relevant even though they aren't _contextually_ relevant (in other words, state should stick by default instead of being forcefully re-evaluated) 19 | 20 | I will ellaborate more on the benefits of this combination with "proofs" and examples as I find the time :) 21 | Until then, my evaluation of SPA design challenges provides some solid insights, so please give it a read! 22 | 23 | ## Problem 24 | 25 | Single Page Applications (SPAs) enable incredibly responsive user experiences by loading a web application once and then dynamically updating 26 | the state of the client application via JavaScript and asynchronous HTTP requests. 27 | 28 | SPAs are innovative and an integral part of the modern web, but the engineers of these applications are often faced with challenges regarding state. 29 | I've described the issues as I see them in both abstract and concrete terms. 30 | 31 | ### Abstract 32 | 33 | SPAs have a dynamic context of multi-layered components that continually changes based on user interactions with the system. 34 | The states and interactions between these components and their layers often span domains and typically become more complex as the context grows. 35 | 36 | In this dynamic context, the provider layer (HTTP server) is stateless while the consumer layer (HTTP clients) is inherently stateful. 37 | The consumer is therefore responsible for ensuring that its components' states are synchronized properly with that of the provider. 38 | 39 | This architecture allows consumer states to diverge from their providers, and it happens quite easily. This is especially prevelant when provider representation states and/or sub-states are denormalized. 40 | 41 | Gooey aims to ease the management of high-level, complex multi-layer component states by isolating, refining and consolidating the imperative patterns into a single library. 42 | 43 | ### Concrete 44 | 45 | SPAs typically consume Restful HTTP APIs. HTTP is a stateless protocol and SPA clients are inherently stateful, introducing an obvious and interesting conflict. 46 | The client is responsible for ensuring that its own representations of API entity states are accurate, often involving reference entities and nested states of sub-entities. 47 | 48 | This gap in state makes it possible for the client to have one representation of an entity and the API another. 49 | The impact of this state gap scales proportionatily to the number of resource entities / sub-entities, and is invevitably toxic to design sustainability. 50 | 51 | The following is a non-exhaustive list of designs that attempt to alleviate the problem but seem to fall short because they do not address the root issue: 52 | 53 | - Monolithic resource entities and responses 54 | 55 | * Pros 56 | - Fast (at first) 57 | - Cheap (at first) 58 | * Cons 59 | - Bloated resources 60 | - God objects 61 | - Highly redundant (no granular sub-entity updates) 62 | - Violates encapsulation 63 | - Duplication of business logic 64 | - Difficult to validate requests 65 | - Difficult to test 66 | 67 | - Closely reflect the domain model of the Restful API in the client 68 | 69 | * Pros 70 | - Consistent domain model 71 | - Clean code (at first) 72 | - Easy to test and validate API integrations 73 | * Cons 74 | - Expensive 75 | - Low maintainability 76 | - Duplication of business logic 77 | 78 | - Allowing Restful API resources to provide both normalized and denormalized entity responses 79 | 80 | * Pros 81 | - Optimizes request size and number 82 | - Low to zero redundancy 83 | - Generally complements [HATEOS](https://en.wikipedia.org/wiki/HATEOAS) 84 | * Cons 85 | - Complicates client and API entity models with compsition combinations (e.g. A, B, C, AB, AC, BC, ABC) 86 | - Nested sub-entities are difficult to access and work with in API routing systems, Restful or not. To my knowledge no URL standards exist for this. 87 | - Fails to address client issue of cleanly managing responses with complex entity compositions 88 | - Can be difficult to test 89 | 90 | On a semi-related note, the mechanism of data synchronization and "binding" in modern JS frameworks is often re-invented and sometimes implemented with 91 | inefficient and bug-prone solutions that emphasize digest cycles or queued listeners. 92 | 93 | Allowing client-side components to interact with each other via decoupled publish / subscribe messaging enables them to synchronize their state flexibly and efficiently, similar to an Actor-based message system like Erlang or Akka. 94 | As an effect, complex client-side components can more easily interact and synchronize with their API resource counterparts. 95 | 96 | ### Example 97 | 98 | Suppose you are designing an online portal for a company that finances renewable energy system projects. 99 | You might represent your model components as a composition hierarchy: 100 | 101 | User 102 | | 103 | +-----------------------------+ 104 | | | 105 | v v 106 | Quotes Proposals 107 | | 108 | +------------------------------+ 109 | | | 110 | v v 111 | Systems Finance Products 112 | 113 | 114 | In this architecture, the User component is essentially acting as the canonical context of the application since all other 115 | model components depend on it. 116 | 117 | Assume that a User can be viewing either one Quote or Proposal at a time. If a Quote is selected, 118 | then the User must also have one System and one Finance Product selected. 119 | 120 | What can make this simple yet dynamic context difficult to manage? (Examples may represent future business requirements): 121 | 122 | - Component states are often decoupled and split across layers of the stack 123 | * Example: The state of the User and its dependent components must be synchronized with relevant models, views, controllers, and API resources 124 | - Strong coupling between components (components expclitly reference and depend on each other) 125 | * Example: If the address of a User's only Quote is changed, reflect the change in the User's primary address as well 126 | - Distant interdependencies between client components that are difficult to architect cleanly 127 | * Example: If a System reaches a "finance ready" state, the Quote now needs to acquire Documents which the User can sign. 128 | Quote state must be refreshed via API in order to acquire Documents. 129 | Quote and Systems are functionally disabled until Documents are signed or rejected, but changes to Finance Products will 130 | re-generate Documents for a Quote. 131 | - Difficult to synchronize effects and limitations of errors between relevant components 132 | * Example: If a Finance Product component experiences a 500 error, ensure that User can no longer access the Product and, if more no Product options remain, clear out the page and re-select a new Quote for the User. 133 | 134 | ## Architecture 135 | 136 | Gooey loosly follows the [composite pattern](https://en.wikipedia.org/wiki/Composite_pattern) and represents data components as canonical `Services` that 137 | can subscribe and publish data via [topic-based](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern#Message_filtering) messsages. 138 | 139 | `Services` have a 1:1 relationship with an optional parent `Service` and a 1:N relationship with optional child `Services`. 140 | 141 | These relationships naturally establish a tree structure that can scale to support any number of `Services`: 142 | 143 | 144 | (?) Parent Service 145 | | 146 | | 147 | Service 148 | | 149 | +-----------------------------+ 150 | | | 151 | v v 152 | (?) Child Service 1 ... (?) Child Service N 153 | 154 | 155 | `Services` that form a tree can publish data to each other bi-directionally. Gooey supports several 156 | traversal patterns for data publication but performs breadth-first down by default. 157 | 158 | Because `Services` can communicate with related services bi-directionally, they can be extended to support the components 159 | of a modern SPA: 160 | 161 | 162 | (?) Rest Service 163 | | 164 | | 165 | Service 166 | | 167 | +-----------------------------+ 168 | | | 169 | v v 170 | (?) View Service 1 ... (?) View Service N 171 | 172 | 173 | However, this design is out of the scope of Gooey core and will be implemented its own module (`gooey.web`). 174 | 175 | ## Usage 176 | 177 | **Basic** 178 | 179 | The following example outlines the most basic use-case of Gooey - a simple 1:1 publish / subscriber relationship: 180 | 181 | ```javascript 182 | import * as gooey from 'gooey' 183 | 184 | // publisher service 185 | const pub = gooey.service({ name: 'pub' }) 186 | 187 | // subscriber - matches any published object 188 | const sub = pub.on('*', (data) => data.$modified = new Date()) 189 | 190 | pub 191 | .publish({ foo: 'bar' }) 192 | .then(data => console.log(`data modified on ${data.$modified}`)) 193 | .catch(err => console.log(`data failed to publish`, err)) 194 | ``` 195 | 196 | Any data published through `pub` (or, if it existed, a parent `Service`) will now trigger `sub`'s subscription behavior, which appends a last `$modified` property to incoming data 197 | 198 | **Advanced** 199 | 200 | This example represents a more realistic and concrete scenario. 201 | Assume you have a user, messages, and a Growl-style notification: 202 | 203 | ```javascript 204 | import * as gooey from 'gooey' 205 | 206 | const inbox = gooey.service({ 207 | name: 'inbox', 208 | state: [] 209 | }) 210 | 211 | const user = gooey.service({ 212 | name: 'user', 213 | parent: inbox, 214 | state: { 215 | messages: { latest: [] } 216 | } 217 | }) 218 | 219 | const notify = gooey.service({ 220 | name: 'notification', 221 | parent: inbox 222 | }) 223 | 224 | // whenever a message is sent, capture that message in an independent store/session ("latest messages") 225 | // this allows `user` to "share" data with `inbox` without establishing a strict relationship 226 | user.on('/message', (msg) => user.state.messages.latest.push(msg)) 227 | 228 | // could call `document.addChild` or something,but using `alert` for simplicitly 229 | notify.on('/message', (msg) => alert(`New email: ${msg.title}`)) 230 | 231 | // adds a new message to the inbox and 232 | // publishes the result to subscribing `Services` 233 | inbox 234 | .add({ 235 | message: { 236 | title : 'hello', 237 | body : 'world' 238 | } 239 | }) 240 | .then(msg => console.log('message published (all subscriptions reached)', msg)) 241 | .catch(err => console.log('message publication failed', err)) 242 | ``` 243 | 244 | So although the `Services` draw explicit relationships with each other via `parent` and/or `children` properties, it's trivial to allow communication anywhere in in the `Service` forest through disjoint subscriptions. 245 | 246 | This concept scales gracefully to complex domain models that include many interdependent entities since the published data will transparently delegate throughout the `Service` tree (the default traversal strategy is Breadth-First Search). 247 | 248 | Gooey attempts to traverse your `Service` tree as efficiently as possible by visiting each node at most once, and supports additional synchronization strategies so that you can find the most efficient one for your architecture (Depth-First Search and Optimized Bi-directional BFS are in the works). 249 | 250 | ## Installation 251 | 252 | > $ npm link 253 | 254 | ## Testing 255 | 256 | > $ npm test 257 | 258 | ## Contributing 259 | 260 | Gooey is still in its very early stages. Please feel free to message [me@madhax.io](mailto:me@madhax.io) if you are interested in contributing! 261 | 262 | ## Future Features 263 | 264 | - [X] Depth-first Down Traversal 265 | - [ ] Depth-first Up Traversal (in prog.) 266 | - [X] Breadth-first Down Traversal 267 | - [X] Breadth-first Up Traversal 268 | - [ ] Concurrent traversals (in prog.) 269 | - [ ] Sibling collisions (in prog.) 270 | - [ ] Composite/Nested `Services` 271 | - [ ] Integrate [Object.observer](http://mzl.la/1OXjS2Q) or [Proxy object shim](https://github.com/tvcutsem/harmony-reflect) 272 | 273 | ## Roadmap 274 | 275 | - [X] `gooey.http` 276 | - [ ] `gooey.dom` (in prog.) 277 | - [ ] `gooey.debug` 278 | - [ ] `gooey.web` (dependent on `core`, `http`, `dom`, and `debug`) 279 | - [ ] `gooey.socket` (state management on top of socket.io) 280 | - [ ] `gooey.api` (express-based API library with webhook support) 281 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var gulp = require('gulp'), 4 | babel = require('gulp-babel'), 5 | shell = require('gulp-shell'), 6 | uglify = require('gulp-uglify'), 7 | source = require('vinyl-source-stream'), 8 | browserify = require('browserify') 9 | 10 | gulp.task('default', ['compile']) 11 | 12 | gulp.task('clean', shell.task(['rm -rf lib && rm -rf dist'])) 13 | 14 | gulp.task('compile', ['clean'], function() { 15 | return gulp 16 | .src('*.js', {cwd: 'src', read: true}) 17 | .pipe(babel()) 18 | .pipe(gulp.dest('lib')) 19 | }) 20 | 21 | gulp.task('compress', ['compile'], function() { 22 | return gulp 23 | .src('./lib/*.js') 24 | .pipe(uglify()) 25 | .pipe(gulp.dest('dist')) 26 | }) 27 | 28 | gulp.task('browserify', ['compress'], function() { 29 | browserify('./dist/index.js') 30 | .bundle() 31 | .pipe(source('gooey-core.js')) 32 | .pipe(gulp.dest('browser')) 33 | 34 | return gulp 35 | .src('./browser/*.js') 36 | .pipe(uglify()) 37 | .pipe(gulp.dest('browser')) 38 | }) 39 | 40 | gulp.task('test', ['compile'], shell.task(['node ./node_modules/.bin/mocha --reporter nyan --compilers js:babel-core/register test'])) 41 | 42 | gulp.task('coverage', ['compile'], shell.task(['node ./node_modules/.bin/nyc --require babel-core/register node_modules/.bin/mocha test/'])) 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gooey-core", 3 | "version": "0.0.0", 4 | "description": "Hierarchical PubSub data synchronization solution for ES6", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "gulp compile", 8 | "test": "gulp test", 9 | "preinstall": "npm install --ignore-scripts && npm run build" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/slurmulon/gooey" 14 | }, 15 | "keywords": [ 16 | "data", 17 | "binding", 18 | "pub", 19 | "sub", 20 | "publish", 21 | "subscribe", 22 | "reactive", 23 | "react", 24 | "event-driven", 25 | "event" 26 | ], 27 | "author": "MadHax", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/slurmulon/gooey/issues" 31 | }, 32 | "devDependencies": { 33 | "babel-cli": "^6.7.5", 34 | "babel-plugin-syntax-flow": "^6.5.0", 35 | "babel-plugin-transform-flow-strip-types": "^6.7.0", 36 | "babel-plugin-typecheck": "^3.8.0", 37 | "babel-preset-es2015": "^6.6.0", 38 | "browserify": "^13.0.0", 39 | "gulp": "~3.9.0", 40 | "gulp-babel": "^6.1.2", 41 | "gulp-shell": "^0.5.1", 42 | "gulp-uglify": "^1.5.1", 43 | "istanbul": "^0.4.3", 44 | "mocha": "~2.5.3", 45 | "nyc": "^6.4.4", 46 | "should": "~8.4.0", 47 | "vinyl-source-stream": "^1.1.0" 48 | }, 49 | "dependencies": { 50 | "json-where": "1.1.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /proposal.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | ///////////////////////////////////////////////////////////////////////////////// 4 | // ** WARNING ** // 5 | // The following code is not meant to be ran, it is for proposal purposes only // 6 | ///////////////////////////////////////////////////////////////////////////////// 7 | 8 | var gooey = require('gooey') 9 | 10 | var user = gooey.Service('user') 11 | 12 | // Basic usage 13 | 14 | user.subscribe('*', v => { 15 | console.log('responding to all changes') 16 | }) 17 | 18 | user.subscribe(model => { model.data.find(v => v === 10) }, v => { 19 | console.log('responding to a 10 being added to data') 20 | }) 21 | 22 | user.data().push(10) // this would trigger both basic subscriptions to trigger (proxy object) 23 | 24 | // Advanced usage (layers) 25 | 26 | user.on('dom[*]', data => { 27 | console.log('responding to changes to the view layer') 28 | 29 | data.loading = true 30 | 31 | return data 32 | }) 33 | 34 | // Advanced Usage (queries) 35 | 36 | user.on('$.id', id => { 37 | console.log('responding to changes to any objects with an id on the top level') 38 | }) 39 | 40 | user.on('$.name', name => { 41 | console.log('responding to changes to any objects with name on the top level, modifying it before the change is carried through to the next layer based on the direction') 42 | 43 | return capitalize(name) 44 | }) 45 | 46 | // Gooey views 47 | 48 | // * Note that service "injection" 49 | 50 | import gooey from gooey 51 | // import user from app.models.run 52 | 53 | var User = new gooey.service({ 54 | name: 'user', 55 | model: (model) => { 56 | 57 | } 58 | }) 59 | 60 | // 61 | gooey.component({ 62 | name : 'BanHammer', 63 | view : '
Ban Chump
', 64 | model : (model, elem) => { 65 | model.banUser = (id) => { 66 | User.byId(id).upsert({banned: true}) 67 | } 68 | } 69 | 70 | }) 71 | 72 | // 73 | gooey.component({ 74 | name : 'BanAlert', 75 | view : '
You\'ve been bannnnnnned
', 76 | model : (model, elem) => { 77 | User.current().on('$.banned', (banned) => { 78 | if (banned) { 79 | alert('You just got banned!', user) 80 | 81 | _.defer(() => { 82 | elem.$destroy({fade: true}) 83 | }, 5000) 84 | } 85 | }) 86 | } 87 | 88 | }) 89 | 90 | // -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // ▄▄ • ▄▄▄ . ▄· ▄▌ 2 | // ▐█ ▀ ▪▪ ▪ ▀▄.▀·▐█▪██▌ 3 | // ▄█ ▀█▄ ▄█▀▄ ▄█▀▄ ▐▀▀▪▄▐█▌▐█▪ 4 | // ▐█▄▪▐█▐█▌.▐▌▐█▌.▐▌▐█▄▄▌ ▐█▀·. 5 | // ·▀▀▀▀ ▀█▄▀▪ ▀█▄▀▪ ▀▀▀ ▀ • 6 | // 7 | // Copyright 2015-2016, MadHax, LLC 8 | 9 | export * from './service' 10 | export * from './subscription' 11 | export * from './topic' 12 | export * from './traverse' 13 | export * from './util' 14 | 15 | export const log = (msg: string, level: string) => `[gooey:${level || 'INFO'}] ${msg}` 16 | -------------------------------------------------------------------------------- /src/service.js: -------------------------------------------------------------------------------- 1 | import { Subscription } from './subscription' 2 | import { step } from './traverse' 3 | import { isEmpty, values as valuesIn } from './util' 4 | 5 | /** 6 | * Map of all registered services, indexed by name 7 | */ 8 | let _services = Object.create(null) 9 | 10 | /** 11 | * Default service configuration object 12 | */ 13 | let _config = { 14 | data: { 15 | matching: true, 16 | 17 | publish: { 18 | ignore: { 19 | falsy: true 20 | } 21 | } 22 | } 23 | } 24 | 25 | /** 26 | * A canonical, hierarchical, and composable data source that can publish and receive updates bi-directionally with other services 27 | * Forms a full k-ary tree that exists in a global forest 28 | */ 29 | export class Service { 30 | 31 | /** 32 | * Creates and registers a new gooey service with the current module 33 | * 34 | * @param {tring} name 35 | * @param {Function} [model] 36 | * @param {*} [state] 37 | * @param {Service} [parent] 38 | * @param {Array} [children] 39 | * @param {Object} [config] 40 | */ 41 | constructor ( 42 | name : string, 43 | model? : Function, 44 | state? : Object = {}, 45 | parent? : Service, 46 | children? : Array = [], 47 | config? : Object = _config 48 | ) { 49 | if (Object.is(name, void 0) || Service.isRegistered(name)) { 50 | throw `Services must have unique names: ${name}` 51 | } 52 | 53 | this.name = name 54 | this.model = model 55 | this.state = state 56 | 57 | this.parent = parent ? parent.relateTo(this) : null 58 | this.children = this.relateToAll(children) 59 | this.subscriptions = [] 60 | 61 | this.config = config 62 | this.symbol = Symbol(name) 63 | 64 | _services[name] = this 65 | 66 | if (this.model instanceof Function) { 67 | this.model(this.state, this) 68 | } 69 | } 70 | 71 | /** 72 | * Getter alias for `data` 73 | * 74 | * @returns {Object} 75 | */ 76 | get data () { 77 | return this.state 78 | } 79 | 80 | /** 81 | * Setter for `data` that automatically publishes changes with default traversal settings 82 | * 83 | * @param {*} data 84 | */ 85 | set data (data) { 86 | this.update(data) 87 | } 88 | 89 | /** 90 | * Traverses service tree via a conflict-free frontier and matches subscribers against the published data 91 | * 92 | * @param {*} data 93 | * @param {String} [traversal] 94 | * @param {String} [direction] 95 | * @param {Array} [frontier] tracks all services encountered during publication. use caution with overriding this value. 96 | * @returns {Promise} deferred service tree traversal(s) 97 | */ 98 | // TODO: Allow users to provide a custom collision resolver 99 | // TODO: Allow users to publish data with a simple key 100 | publish (data, traversal: string = 'breadth', direction: string = 'down', frontier: Array = []): Promise { 101 | return new Promise((resolve, reject) => { 102 | // ensure data is pure 103 | data = data instanceof Object ? Object.assign({}, data) : data 104 | 105 | // action to perform on this node's step traversal 106 | // (deferred in case traversal circumevents the need) 107 | const action = (data) => { 108 | const matches = this.subscriptions.map(subscrip => subscrip.process(data, false)) 109 | 110 | return matches[0] || data 111 | } 112 | 113 | // recursively calls publish on next node (lazily evalutated during tree traversal) 114 | const next = (node, result, frontier) => node.publish(result, traversal, direction, frontier) 115 | 116 | // traverse service node tree and publish result on each "next" node 117 | return this.traverse( 118 | traversal, direction, data, action, next, frontier 119 | ) 120 | }) 121 | } 122 | 123 | /** 124 | * Creates and registers a publish subscription with the Service 125 | * 126 | * @param {Topic|String} topic 127 | * @param {Function} [on] 128 | */ 129 | subscribe (topic = '*', on?: Function = _ => _): Subscription { 130 | const subscrip = new Subscription(this, topic, on) 131 | 132 | this.subscriptions.push(subscrip) 133 | 134 | return subscrip 135 | } 136 | 137 | /** 138 | * Deregisters a subscription from the Service 139 | * 140 | * @param {Subscription} subscrip 141 | * @param {boolean} freeze 142 | */ 143 | unsubscribe (subscrip: Subscription, freeze: boolean = false) { 144 | subscrip.end(freeze) 145 | } 146 | 147 | /** 148 | * Updates the Service's canonical data source with new data and publishs the change 149 | * 150 | * @param {*} data 151 | * @returns {Promise} 152 | */ 153 | update (data, ...rest): Promise { 154 | this.state = data 155 | 156 | return this.publish(data, ...rest) 157 | } 158 | 159 | /** 160 | * Merges and updates the Service's canonical data source with a new (cloned) 161 | * data object and publishs the change 162 | * 163 | * @param {*} data 164 | * @returns {Promise} 165 | */ 166 | merge (data, ...rest): Promise { 167 | const merged = data instanceof Object ? Object.assign({}, this.state, data) : this.state 168 | 169 | return this.update(merged, ...rest) 170 | } 171 | 172 | /** 173 | * Appends to the Service's canonical data if it's a collection and then 174 | * publishes the change 175 | * 176 | * @param {*} data 177 | * @returns {Promise} 178 | */ 179 | add (data, ...rest): Promise { 180 | if (this.state instanceof Array) { 181 | this.state.push(data) 182 | this.update(this.state, ...rest) 183 | } 184 | } 185 | 186 | /** 187 | * Alias for update 188 | * 189 | * @param {*} data 190 | * @returns {Promise} 191 | */ 192 | use (data, ...rest): Promise { 193 | return this.update(data, ...rest) 194 | } 195 | 196 | /** 197 | * Alias for merge 198 | * 199 | * @param {*} data 200 | * @returns {Promise} 201 | */ 202 | up (data, ...rest): Promise { 203 | return this.merge(data, ...rest) 204 | } 205 | 206 | /** 207 | * Alias for subscribe 208 | * 209 | * @param {Topic|String} topic 210 | * @param {Function} on 211 | * @returns {Subscription} 212 | */ 213 | on (topic, on: Function): Subscription { 214 | return this.subscribe(topic, on) 215 | } 216 | 217 | /** 218 | * Alias for unsubscribe 219 | * 220 | * @param {Subscription} subscrip 221 | * @param {boolean} freeze 222 | */ 223 | off (subscrip: Subscription, freeze: boolean = false) { 224 | return this.unsubscribe(subscrip, freeze) 225 | } 226 | 227 | /** 228 | * Determines set of data that matches the provided subscription's topic 229 | * 230 | * @param {*} data 231 | * @param {Subscription} subscrip 232 | * @returns {Set} 233 | */ 234 | matches (data, subscrip: Subscription) { // TODO - report issue with flow, value gets coerced to Array 235 | return subscrip.matches(data) 236 | } 237 | 238 | /** 239 | * Recursively traverses service tree via provided `next` function 240 | * 241 | * @param {string} traversal supported values defined by gooey.traverse.strategies 242 | * @param {string} direction 243 | * @param {Function} action 244 | * @param {Promise|Function} next 245 | * @param {Array} [frontier] 246 | * @returns {Promise} 247 | */ 248 | traverse (traversal: string, direction: string, data, action: Function, next: Function, frontier: Array): Promise { 249 | return step.call(this, traversal, direction, data, action, next, frontier) 250 | } 251 | 252 | /** 253 | * Establishes strong acyclic child relationship with provided service. 254 | * Child services inherit publications from their parent. 255 | * The opposite is also supported via `up` traversals. 256 | * Silently fails if a cyclic relationship is proposed. 257 | * 258 | * @param {Service} child service to relate to 259 | * @returns {Service} modified service with new child relationship 260 | */ 261 | relateTo (child: Service): Service { 262 | this.children.push(child) 263 | 264 | // if (Service.cycleExists()) { 265 | // this.children.pop() // FIXME - bleh, needs improvement to say the least 266 | // } 267 | 268 | return this 269 | } 270 | 271 | /** 272 | * Establishes service as parent to each provided child service. 273 | * Child services inherit publications from their parent. 274 | * The opposite is also supported via `up` traversals. 275 | * 276 | * @param {Array} children services to relate to 277 | * @returns {Array} modified children services with new parent relationship 278 | */ 279 | relateToAll (children: Array): Array { 280 | return children.map(c => { 281 | c.parent = this 282 | 283 | // this.relateTo(c) // TODO/FIXME 284 | 285 | return c 286 | }) 287 | } 288 | 289 | /** 290 | * Determines a provided service's depth in the service tree 291 | * 292 | * @param {Service} node relative/starting service 293 | * @returns {number} depth of service 294 | */ 295 | depth (node: Service = this): number { 296 | let nodeDepth = 0 297 | 298 | while (node.parent) { 299 | node = node.parent 300 | nodeDepth += 1 301 | } 302 | 303 | return nodeDepth 304 | } 305 | 306 | /** 307 | * Searches for and returns all siblings of the provided service 308 | * 309 | * @param {Service} [node] relative/starting service 310 | * @param {boolean} [globe] return siblings across disjoint trees (true) or siblings in connected hierarchy (false - UNSUPPORTED) 311 | * @returns {Array} siblings of service 312 | */ 313 | siblings (node: Service = this, globe?: boolean = false): Array { 314 | const roots = Service.findRoots() 315 | const depth = node.depth() 316 | 317 | return Service.findAtDepth(depth, roots).filter(svc => svc !== node) 318 | } 319 | 320 | /** 321 | * Determines if the service is a root node in the local service tree 322 | * 323 | * @returns {boolean} 324 | */ 325 | isRoot (): boolean { 326 | return isEmpty(this.parent) 327 | } 328 | 329 | /** 330 | * Determines if the service is a leaf node in the local service tree 331 | * 332 | * @returns {boolean} 333 | */ 334 | isLeaf (): boolean { 335 | return isEmpty(this.children) 336 | } 337 | 338 | /** 339 | * Determines and returns all root node services in the global service tree 340 | * 341 | * @param {Array} [services] service tree to search through (default is global) 342 | * @returns {Array} 343 | */ 344 | static findRoots (services = _services): Array { 345 | return valuesIn(services).filter(svc => svc instanceof Service && svc.isRoot()) 346 | } 347 | 348 | /** 349 | * Determines and returns all leaf node services in the global service tree 350 | * 351 | * @param {Array} [services] service tree to search through (default is global) 352 | * @returns {Array} 353 | */ 354 | static findLeafs (services = _services): Array { 355 | return valuesIn(services).filter(svc => svc instanceof Service && svc.isLeaf()) 356 | } 357 | 358 | /** 359 | * Determines and returns the nodes (out of the provided service tree) at the target depth 360 | * 361 | * @param {number} targetDepth 362 | * @param {Array} [nodes] service tree to search through (default is global) 363 | * @returns {Array} 364 | */ 365 | static findAtDepth (targetDepth: number, nodes: Array = []): Array { 366 | const found = [] 367 | let curDepth = 0 368 | 369 | nodes.forEach(node => { 370 | (node.children || []).forEach(child => { 371 | curDepth = child.depth() 372 | 373 | if (curDepth < targetDepth) { 374 | found.push(...this.findAtDepth(targetDepth, [child])) 375 | } else if (curDepth === targetDepth) { 376 | found.push(child) 377 | } 378 | }) 379 | }) 380 | 381 | return found 382 | } 383 | 384 | /** 385 | * Determines if a cyclic relationship exists anywhere in the provided service tree 386 | * 387 | * @param {Array} [services] service tree to search through (default is global) 388 | * @returns {boolean} 389 | */ 390 | static cycleExists (services = _services): boolean { 391 | const roots = Service.findRoots(services) || [] 392 | const found = roots.map(r => r.name) 393 | const hasRoots = !isEmpty(roots) 394 | const hasServices = !isEmpty(services) 395 | 396 | if (!hasRoots && hasServices) { 397 | return true 398 | } 399 | 400 | let curNode = null 401 | let cyclic = false 402 | 403 | roots.forEach((root, i) => { 404 | curNode = root 405 | 406 | while (!cyclic && !isEmpty(curNode.children)) { 407 | (curNode.children || []).forEach(child => { 408 | if (!~found.indexOf(child.name)) { 409 | found.push(child.name) 410 | 411 | curNode = child 412 | } else { 413 | cyclic = true 414 | } 415 | }) 416 | } 417 | }) 418 | 419 | return cyclic 420 | } 421 | 422 | /** 423 | * Determines if a service name is already registered in the global service tree 424 | * 425 | * @param {string} name 426 | * @returns {boolean} 427 | */ 428 | static isRegistered (name: string): boolean { 429 | return Array.from(_services).map(serv => serv.name).indexOf(name) >= 0 430 | } 431 | 432 | } 433 | 434 | /** 435 | * Alternative destructured alias or Service constructor 436 | * 437 | * @param {string} name 438 | * @param {Function} [model] 439 | * @param {*} [state] 440 | * @param {Service} [parent] 441 | * @param {Array} [children] 442 | * @param {Object} [config] 443 | * @returns {Service} 444 | */ 445 | export const service = ({ name, model, state, parent, children, config }) => new Service(name, model, state, parent, children, config) 446 | 447 | /** 448 | * Exported flat map of module services - to be used with caution 449 | */ 450 | export const services = (() => _services) 451 | 452 | /** 453 | * Detaches services from module 454 | */ 455 | export const clear = () => { _services = {} } 456 | -------------------------------------------------------------------------------- /src/subscription.js: -------------------------------------------------------------------------------- 1 | import { Service } from './service' 2 | import { identify } from './topic' 3 | import { isEmpty } from './util' 4 | 5 | /** 6 | * A topic-based data matcher that reacts to a service's publications 7 | */ 8 | export class Subscription { 9 | 10 | /** 11 | * A topic-based data matcher that reacts to a service's publications 12 | * 13 | * @param {Service} service 14 | * @param {Object} topic topic/pattern to react to ('*' or '$' is wildcard) 15 | * @param {Function} on functionality to be triggered on successful match 16 | */ 17 | constructor (service: Service, topic, on: Function) { 18 | // this.key = key // TODO: will allow subscriptions to be triggered via simple keys 19 | this.service = service 20 | this.topic = topic 21 | this.on = on 22 | this.active = true 23 | } 24 | 25 | /** 26 | * Determines data or a subset of data that matches subscription topic 27 | * 28 | * @param {*} data 29 | * @returns {Set} data matching subscription 30 | */ 31 | matches (data): Set { 32 | const matchSet = new Set() 33 | 34 | if (this.active && this.service.config.data.matching) { 35 | const topicMatches = identify(this.topic).matches(data) 36 | 37 | if (!isEmpty(topicMatches)) { 38 | matchSet.add(...topicMatches) 39 | } 40 | } 41 | 42 | return matchSet 43 | } 44 | 45 | /** 46 | * Determines if data matches the subscription and, if so, allows 47 | * the subscription to mutate and return the data. 48 | * 49 | * @param {boolean} passive return either untouched data on mismatch (true) or null on mismatch (false) 50 | * @returns {*} subscription modified data 51 | */ 52 | process (data, passive: boolean = true) { 53 | return this.matches(data).size ? this.on(data) : (passive ? data : null) 54 | } 55 | 56 | /** 57 | * Unsubscribes a subscription from its service and mark it as inactive. 58 | * Subscription will not react to any messages from service until activated again. 59 | * 60 | * @param {boolean} [freeze] freeze the object after unsubscription, preventing any further changes to Subscription 61 | */ 62 | end (freeze?: boolean = false) { 63 | this.service.subscriptions.splice(this.service.subscriptions.indexOf(this), 1) 64 | 65 | this.active = false 66 | 67 | if (freeze) Object.freeze(this) 68 | } 69 | 70 | /** 71 | * Activates the subscription, permitting it to react to topic-based messages 72 | */ 73 | start () { 74 | this.active = true 75 | } 76 | 77 | /** 78 | * For sane debugging 79 | */ 80 | toString () { 81 | return `[gooey.Service:${this.name}]` 82 | } 83 | 84 | } 85 | 86 | -------------------------------------------------------------------------------- /src/topic.js: -------------------------------------------------------------------------------- 1 | import { $, which } from 'json-where' 2 | import { is, isEmpty } from './util' 3 | 4 | /** 5 | * Base topic. Performs simple equality comparison of value to topic 6 | * 7 | * Utilized in Subscription.matches and (by association) Service.publish 8 | */ 9 | export class Topic { 10 | 11 | /** 12 | * @param {*} key value to use as comparator when matching topics 13 | */ 14 | constructor (key) { 15 | this.key = key 16 | } 17 | 18 | /** 19 | * Determines data that matches the topic key 20 | * 21 | * @param {Object} data 22 | * @return {*} data matching topic key 23 | */ 24 | matches (data): Array { 25 | return is(this.key, data) ? [data] : [] 26 | } 27 | 28 | /** 29 | * Determines if provided data can be used as a topic key 30 | * 31 | * @param {Object} data 32 | * @return {boolean} true 33 | */ 34 | static appliesTo (data): boolean { 35 | return true 36 | } 37 | 38 | } 39 | 40 | /** 41 | * JsonWhere (abstraction over JsonPointer, JsonQuery and JsonPath) query topics 42 | */ 43 | export class JsonWhereTopic extends Topic { 44 | 45 | /** 46 | * @param {string} key valid JsonWhere query string 47 | */ 48 | constructor (key) { 49 | super(key) 50 | } 51 | 52 | /** 53 | * Determines the set / subset of data that matches JsonWhere 54 | * 55 | * @param {Object} data 56 | * @return {Array<*>} data set matching JsonWhere 57 | */ 58 | matches (data): Array { 59 | return $(this.key, data).all() 60 | } 61 | 62 | /** 63 | * Determines if data is a valid JsonWhere 64 | * 65 | * @param {string} data 66 | * @return {boolean} 67 | */ 68 | static appliesTo (data): boolean { 69 | return !!which(data) 70 | } 71 | 72 | } 73 | 74 | /** 75 | * Performs pseudo-reflection on the provided data (typically String) 76 | * in order to imply which Topic the data is intended to be 77 | * 78 | * Returns a base Topic with simple equality comparison in the case of a mis-match 79 | * 80 | * @param {*} data potential topic to identify 81 | * @return {Topic} identified Topic 82 | */ 83 | export function identify (data): Topic { 84 | if (is(data.constructor, String) && JsonWhereTopic.appliesTo(data)) { 85 | return new JsonWhereTopic(data) 86 | } 87 | 88 | return new Topic(data) 89 | } 90 | 91 | 92 | export default { Topic, JsonWhereTopic, identify } 93 | -------------------------------------------------------------------------------- /src/traverse.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Singleton pool of traversal strategies 3 | * that can be used in `gooey.Service.publish` events. 4 | * May be configured with `add(strategy)` or through 5 | * extension of the `strategies` object directly 6 | */ 7 | export const strategies = { 8 | breadth: { 9 | /** 10 | * Performs Breadth-First Search (leafs -> roots) 11 | * 12 | * @param {Function} next stepper function to map on next set of nodes 13 | * @param {*} data 14 | * @param {Array} frontier list of visited nodes in traversal 15 | * @returns {Promise} asynchronous mapping of node "steps" 16 | */ 17 | up: function (next: Function, data, frontier: Array) { 18 | const stepper = (node) => next(node, data, frontier) 19 | const siblings = this.parent.siblings(undefined, true) 20 | const nodes = [this.parent].concat(siblings) 21 | 22 | return Promise.all(nodes.map(stepper)) 23 | }, 24 | 25 | /** 26 | * Performs Breadth-First Search (roots -> leafs) 27 | * 28 | * @param {Function} next stepper function to map on next set of nodes 29 | * @param {*} data 30 | * @param {Array} frontier list of visited nodes in traversal 31 | * @returns {Promise} asynchronous mapping of node "steps" 32 | */ 33 | down: function (next: Function, data, frontier: Array) { 34 | const stepper = (node) => next(node, data, frontier) 35 | 36 | return Promise.all(this.children.map(stepper)) 37 | }, 38 | 39 | // http://www.cs.berkeley.edu/~sbeamer/beamer-sc2012.pdf 40 | // bi: function(next, tail) 41 | }, 42 | 43 | depth: { 44 | /** 45 | * Performs Depth-First Search (roots -> leafs) 46 | * 47 | * @param {Function} next stepper function to map on next set of nodes 48 | * @param {*} data 49 | * @param {Array} frontier list of visited nodes in traversal 50 | * @returns {Promise} asynchronous mapping of node "steps" 51 | */ 52 | down: function (next: Function, data, frontier: Array) { 53 | const stepper = (node) => next(node, data, frontier) 54 | 55 | return this.children.map(stepper) 56 | } 57 | } 58 | 59 | // async: { } 60 | } 61 | 62 | /** 63 | * Executes a traversal step with a strategy that's defined 64 | * in the `traverse.strategies` pool. Passively recursive (user 65 | * must explicitly call `traversal.step` again in their 66 | * `next` function!) 67 | * 68 | * Progression to the user-provided `next` function 69 | * depends on the direction of the traversal and 70 | * whether or not `traverse.step` is recursively 71 | * called in the `next` function. 72 | * 73 | * Patterns are strongly encouraged to strictly utilize 74 | * `Promise`s although it's technically not required. 75 | * 76 | * @param {string} name `breadth` or `depth` 77 | * @param {string} direction `up`, `down` or `bi` 78 | * @param {*} data 79 | * @param {Function} action function to invoke against data on each step 80 | * @param {Function} next function to invoke next after node is visited (typically `publish`) 81 | * @returns {Promise} 82 | */ 83 | export function step (name: string, direction: string, data, action: Function, next: Function, frontier: Array = []): Promise { 84 | const traversal = strategies[name][direction] 85 | 86 | if (traversal) { 87 | const canNext = direction && !!{ 88 | up : this.parent, 89 | down : this.children.length 90 | }[direction] 91 | 92 | const canAddToFrontier = frontier.length === 0 || !~frontier.indexOf(this.name) 93 | 94 | // visit current service node via `action` 95 | if (canAddToFrontier) { 96 | const result = action(data) 97 | 98 | // once result is acquired, add service name to frontier 99 | frontier.push(this.name) 100 | 101 | // progress to next traversal step if necessary 102 | return canNext ? traversal.call(this, next, result, frontier) : Promise(result) 103 | } 104 | 105 | // unvisited node result 106 | return Promise(result) 107 | } else { 108 | return Promise(result).reject(`unknown traversal. name: ${name}, direction: ${direction}`) 109 | } 110 | } 111 | 112 | /** 113 | * Adds a traversal strategy to the `traverse.strategies` 114 | * pool. These may be used (by name) in `gooey.Service.publish` 115 | * and other methods that inherit it. 116 | * 117 | * @param {Object} strategy 118 | */ 119 | export function add (strategy) { 120 | Object.assign(strategies, strategy) 121 | } 122 | 123 | export default { strategies, step, add } 124 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * More convenient object comparison 3 | */ 4 | export const is = (x, y) => x === y || Object.is(x, y) 5 | 6 | /** 7 | * Determines if an object is falsy or an empty array 8 | */ 9 | export const isEmpty = (x) => !x || Object.keys(x).length === 0 10 | 11 | /** 12 | * Safely merges together N objects (leaves source objects untouched) 13 | */ 14 | export const mix = () => Object.assign({}, ...arguments) 15 | 16 | /** 17 | * Object.entries "polyfill" 18 | */ 19 | export const entries = (obj) => Object.keys(obj).map((key) => [key, obj[key]]) 20 | 21 | /** 22 | * Object.values "polyfill" 23 | */ 24 | export const values = (obj) => Object.keys(obj).map((key) => obj[key]) 25 | 26 | 27 | export default { is, isEmpty, mix, entries, values } 28 | 29 | -------------------------------------------------------------------------------- /test/GooeySpec.js: -------------------------------------------------------------------------------- 1 | import * as gooey from '../lib/index' 2 | import should from 'should' 3 | 4 | describe('Service', () => { 5 | 6 | beforeEach(gooey.clear) 7 | 8 | describe('constructor', () => { 9 | it('should not allow services to be defined without a name', () => { 10 | gooey.service.should.throw() 11 | }) 12 | 13 | it('should invoke the model method with a reference to the service\'s state object', () => { 14 | const service = new gooey.Service('foo', data => { 15 | data.touched = true 16 | }) 17 | 18 | service.state.should.eql({touched: true}) 19 | }) 20 | 21 | it('should add valid services to the global service pool', () => { 22 | const service = new gooey.Service('foo') 23 | const services = gooey.services() 24 | 25 | services.hasOwnProperty('foo').should.be.true() 26 | }) 27 | 28 | it('should establish itself as a parent to all child services', () => { 29 | const childService1 = new gooey.Service('child1') 30 | const childService2 = new gooey.Service('child2') 31 | const parentService = new gooey.service({name: 'parent', children: [childService1, childService2]}) 32 | 33 | childService1.parent.should.equal(parentService) 34 | childService2.parent.should.equal(parentService) 35 | }) 36 | 37 | it('should establish itself as a child to its parent service', () => { 38 | const parentService = new gooey.Service('parent') 39 | const childService = new gooey.service({name: 'child', parent: parentService}) 40 | 41 | parentService.children.should.containEql(childService) 42 | }) 43 | 44 | // FIXME 45 | xit('should prevent services with cyclic relationships from being established', () => { 46 | const serviceA = new gooey.Service('A') 47 | const serviceB = new gooey.service({name: 'B', parent: serviceA}) 48 | const serviceC = (() => { 49 | new gooey.service({name: 'C', parent: serviceB, children: [serviceA]}) 50 | }).should.throw() 51 | }) 52 | 53 | it('should prevent services with the same name from co-existing', () => { 54 | const service1 = new gooey.Service('foo') 55 | const service2 = (() => { 56 | gooey.service('foo') 57 | }).should.throw() 58 | }) 59 | 60 | it('should set `isRoot` to true only if the Service has no parent', () => { 61 | const parentService = new gooey.Service('root') 62 | const childService = new gooey.service({name: 'child', parent: parentService}) 63 | 64 | parentService.isRoot().should.be.true() 65 | childService.isRoot().should.be.false() 66 | }) 67 | }) 68 | 69 | describe('publish', () => { 70 | it('should error if an invalid traversal strategy is provided', () => { 71 | new gooey.Service('foo').publish('bar', 'crazy').should.be.rejected 72 | }) 73 | 74 | describe('when traversal is `breadth` and direction is `down`', () => { 75 | it('should recursively traverse child services (depth: syncronous, breadth: asynchronous)', () => { 76 | const childServiceA = new gooey.Service('childA') 77 | const childServiceB = new gooey.Service('childB') 78 | const childService1 = new gooey.Service('child1') 79 | const childService2 = new gooey.service({name: 'child2', children: [childServiceA, childServiceB]}) 80 | const rootService = new gooey.service({name: 'root', children: [childService1, childService2]}) 81 | 82 | const testData1 = {root: true} 83 | const testData2 = {inny: true} 84 | const testData3 = {leaf: true} 85 | const evilData = {evil: true} 86 | const results = [] 87 | 88 | const resultPusher = (data) => { results.push(data) } 89 | 90 | rootService.subscribe('$.root', resultPusher) 91 | childService1.subscribe('$.inny', resultPusher) 92 | childServiceA.subscribe('$.leaf', resultPusher) 93 | childServiceB.subscribe('$.evil', resultPusher) 94 | 95 | rootService.publish(testData1).then(success => { 96 | success.touchedBy = 'root' 97 | return success 98 | }) 99 | 100 | rootService.publish(testData2).then(success => { 101 | success.touchedBy = 'inny' 102 | return success 103 | }) 104 | 105 | rootService.publish(testData3).then(success => { 106 | success.touchedBy = 'leaf' 107 | return success 108 | }) 109 | 110 | results.should.eql([testData1, testData2, testData3]) 111 | }) 112 | 113 | it('should safely modify data when a subscription matches before passing off the data to child services', () => { 114 | const parent = new gooey.Service('parent') 115 | const child = new gooey.service({name: 'child', parent}) 116 | const testData = {find: true, foundBy: []} 117 | 118 | parent.subscribe('$.find', data => { 119 | data.foundBy.push('parent') 120 | return data 121 | }) 122 | 123 | const testScrip = child.subscribe('$.find', data => { 124 | data.foundBy.push('child') 125 | return data 126 | }) 127 | 128 | const result = parent.publish(testData) 129 | 130 | result.should.eventually.containEql('parent') 131 | result.should.eventually.containEql('child') 132 | }) 133 | 134 | it('should not modify data and return it in the original state if no subscriptions match', () => { 135 | const parentService = new gooey.Service('parent') 136 | const childService = new gooey.service({name: 'child', parent: parentService}) 137 | const testData = {avoid: true, foundBy: []} 138 | 139 | childService.subscribe('$.nothing', data => { 140 | data.foundBy.push('childService1') 141 | 142 | return data 143 | }) 144 | 145 | parentService.publish(testData) 146 | 147 | testData.foundBy.should.be.empty 148 | }) 149 | 150 | xit('should ensure that subscription matches are performed safely', () => { 151 | // TODO 152 | }) 153 | 154 | xit('should properly synchronize identical matching subscription responses (same strategy, same service', () => { 155 | // TODO 156 | }) 157 | }) 158 | 159 | describe('when traversal is `breadth` and direction is `up`', () => { 160 | it('should traverse all nodes sharing the depth (siblings) of the parent service (including parent)', () => { 161 | const testData = {foundBy: []} 162 | const testTopic = '$' 163 | 164 | const a1 = new gooey.service({name: 'A1'}) 165 | const a2 = new gooey.service({name: 'A2'}) 166 | const a1b1 = new gooey.service({name: 'A1B1', parent: a1}) 167 | const a1b2 = new gooey.service({name: 'A1B2', parent: a1}) 168 | const a2b1 = new gooey.service({name: 'A2B1', parent: a2}) 169 | const a2b2 = new gooey.service({name: 'A2B2', parent: a2}) 170 | const a1b1c1 = new gooey.service({name: 'A1B1C1', parent: a1b1}) 171 | 172 | const services = gooey.services() 173 | 174 | Object.keys(services).forEach(key => { 175 | const service = services[key] 176 | 177 | service.subscribe(testTopic, (data) => { 178 | data.foundBy.push(service.name) 179 | 180 | return data 181 | }) 182 | }) 183 | 184 | const result = a1b1c1.publish(testData, 'breadth', 'up').then(result => { 185 | return result 186 | }) 187 | 188 | // TODO - export this to util or something 189 | const isUnique = (arr = []) => { 190 | const cache = {} 191 | const results = [] 192 | 193 | arr.forEach((elem, i) => { 194 | if (cache[elem] === true) { 195 | results.push(elem) 196 | } else { 197 | cache[elem] = true 198 | } 199 | }) 200 | 201 | return !results.length 202 | } 203 | 204 | // FIXME - switch to chaiAsPromised, the following assertions should work but have no effect whatsoever 205 | // result.should.be.finally.equal(testData.foundBy) 206 | 207 | // result.should.eventually.containEql(Object.keys(services)) 208 | 209 | // testData.foundBy.should.include(Object.keys(services)) 210 | 211 | isUnique(testData.foundBy).should.be.true() 212 | }) 213 | }) 214 | 215 | xdescribe('when traversal is `depth` and direction is `down`', () => { 216 | it('should traverse the parent service', () => { 217 | // TODO 218 | }) 219 | }) 220 | 221 | xdescribe('when traversal is `depth` and direction is `up`', () => { 222 | it('should traverse the parent service', () => { 223 | // TODO 224 | }) 225 | }) 226 | 227 | it('should properly support nested publish calls to disjoint services', () => { 228 | const serviceA = new gooey.Service('A') 229 | const serviceB = new gooey.Service('B') 230 | const testData = {} 231 | 232 | serviceA.subscribe('$', () => { 233 | testData.a = true 234 | serviceB.update(testData) 235 | }) 236 | 237 | serviceB.subscribe('$.a', () => { 238 | testData.b = true 239 | }) 240 | 241 | serviceA.publish(testData) 242 | 243 | testData.should.have.ownProperty('a') 244 | testData.should.have.ownProperty('b') 245 | }) 246 | 247 | xit('should be able to properly synchronize identical publish events that are being executed concurrently', () => { 248 | // TODO - should be ready for testing 249 | }) 250 | 251 | xit('should traverse the service tree using a hamiltonian path', () => { 252 | // TODO 253 | }) 254 | }) 255 | 256 | describe('subscribe', () => { 257 | it('should create a subscription and return it', () => { 258 | let works = false 259 | const service = new gooey.Service('foo') 260 | const scrip = service.subscribe('$', () => { 261 | works = true 262 | }) 263 | 264 | service.update({any: 'thing'}) 265 | 266 | works.should.be.true() 267 | }) 268 | 269 | it('should register the subscription with the Service upon creation', () => { 270 | const service = new gooey.Service('foo') 271 | const scrip = service.subscribe('$', () => { 272 | works = true 273 | }) 274 | 275 | service.subscriptions.should.containEql(scrip) 276 | }) 277 | 278 | // TODO - it('should prevent identical subscriptions from being registered') 279 | 280 | }) 281 | 282 | describe('unsubscribe', () => { 283 | it('should remove the subscription from the service', () => { 284 | const service = new gooey.Service('foo') 285 | const scrip = service.subscribe('$', (data) => { 286 | delete data.pass 287 | data.fail = true 288 | }) 289 | 290 | service.unsubscribe(scrip) 291 | service.update({pass: true}) 292 | 293 | service.state.should.have.ownProperty('pass') 294 | service.state.should.not.have.ownProperty('fail') 295 | }) 296 | 297 | it('should set the subscription as in-active, preventing further mutation', () => { 298 | const service = new gooey.Service('foo') 299 | const scrip = service.subscribe('$', (data) => { 300 | data.fail = true 301 | }) 302 | 303 | service.unsubscribe(scrip) 304 | service.update({pass: true}) 305 | 306 | scrip.active.should.be.false() 307 | 308 | service.state.should.have.ownProperty('pass') 309 | service.state.should.not.have.ownProperty('fail') 310 | }) 311 | 312 | it('should freeze the subscription to prevent further mutation when freeze is set to true', () => { 313 | const service = new gooey.Service('foo') 314 | const scrip = service.subscribe('$', (data) => { 315 | data.fail = true 316 | }) 317 | 318 | service.unsubscribe(scrip, true) 319 | service.update({pass: true}) 320 | 321 | Object.isFrozen(scrip).should.be.true() 322 | 323 | service.state.should.have.ownProperty('pass') 324 | service.state.should.not.have.ownProperty('fail') 325 | }) 326 | 327 | it('should not freeze the subscription when freeze is set to false', () => { 328 | const service = new gooey.Service('foo') 329 | const scrip = service.subscribe('$', (data) => { 330 | data.fail = true 331 | }) 332 | 333 | service.unsubscribe(scrip, false) 334 | service.update({pass: true}) 335 | 336 | Object.isFrozen(scrip).should.be.false() 337 | 338 | service.state.should.have.ownProperty('pass') 339 | service.state.should.not.have.ownProperty('fail') 340 | }) 341 | }) 342 | 343 | describe('update', () => { 344 | it('should update the Service\'s canonical source of data and publish the change', () => { 345 | const testData = {foo: 'bar'} 346 | const service = new gooey.Service('parent') 347 | 348 | const scrip = service.on('$.foo', data => { 349 | data.matched = true 350 | 351 | return data 352 | }) 353 | 354 | const update = service.use(testData).then(data => { 355 | data.updated = true 356 | 357 | return data 358 | }).then(() => { 359 | testData.should.eql({foo: 'bar', matched: true, updated: true}) // FIXME - should doesn't asser this 360 | }) 361 | }) 362 | }) 363 | 364 | describe('merge', () => { 365 | it('should merge, update and then publish the provided data', () => { 366 | const service = new gooey.Service('foo') 367 | 368 | service.update({a: 'a'}) 369 | service.merge({b: 'b'}) 370 | 371 | service.state.should.eql({a: 'a', b: 'b'}) 372 | }) 373 | }) 374 | 375 | describe('matches', () => { 376 | it('should invoke `matches` on the provided Subscription', () => { 377 | const testData = {foo: 'bar'} 378 | const service = new gooey.service({name: 'foo'}) 379 | const scripStub = Object.create(gooey.Subscription.prototype) 380 | 381 | scripStub.matches = (data) => [data] 382 | 383 | const result = service.matches(testData, scripStub) 384 | 385 | result.should.containEql(testData) 386 | }) 387 | }) 388 | 389 | describe('function aliases', () => { 390 | xdescribe('on', () => { 391 | // TODO 392 | const service = new gooey.service({name: 'foo'}) 393 | 394 | service.on('*', () => 'works') 395 | }) 396 | 397 | xdescribe('use', () => { 398 | // TODO 399 | }) 400 | 401 | xdescribe('up', () => { 402 | // TODO 403 | }) 404 | }) 405 | 406 | xdescribe('relateTo', () => { 407 | xit('should prevent services with cyclic relationships from being established', () => { 408 | // TODO 409 | }) 410 | 411 | xit('should relate the provided service as a child only if the proposed relationship is acyclic', () => { 412 | // TODO 413 | }) 414 | }) 415 | 416 | describe('relateToAll', () => { 417 | it('should relate each provided service as a parent', () => { 418 | const parent = new gooey.Service('parent') 419 | const child1 = new gooey.Service('child1') 420 | const child2 = new gooey.Service('child2') 421 | 422 | parent.relateToAll([child1, child2]) 423 | 424 | child1.parent.should.equal(parent) 425 | child2.parent.should.equal(parent) 426 | }) 427 | }) 428 | 429 | describe('isRoot', () => { 430 | it('should determine if service is a root in the tree', () => { 431 | const root = new gooey.Service('root') 432 | 433 | should.equal(root.parent, null) 434 | root.isRoot().should.be.true() 435 | 436 | const child = new gooey.service({name: 'child', parent: root}) 437 | 438 | child.isRoot().should.be.false() 439 | }) 440 | }) 441 | 442 | describe('isLeaf', () => { 443 | it('should return true for orphan nodes', () => { 444 | new gooey.Service('orphan').isLeaf().should.be.true() 445 | }) 446 | 447 | it('should return false for any parent node', () => { 448 | const parent = new gooey.Service('parent') 449 | const child = new gooey.service({name: 'child', parent}) 450 | 451 | parent.isLeaf().should.be.false() 452 | child.isLeaf().should.be.true() 453 | }) 454 | 455 | it('should return true for any leaf node', () => { 456 | const root = new gooey.Service('root') 457 | const mid = new gooey.service({name: 'mid', parent: root}) 458 | const leaf1 = new gooey.service({name: 'leaf1', parent: mid}) 459 | const leaf2 = new gooey.service({name: 'leaf2', parent: mid}) 460 | 461 | root.isLeaf().should.be.false() 462 | mid.isLeaf().should.be.false() 463 | leaf1.isLeaf().should.be.true() 464 | leaf2.isLeaf().should.be.true() 465 | }) 466 | 467 | }) 468 | 469 | xdescribe('findRoots', () => { 470 | // TODO 471 | }) 472 | 473 | describe('findLeafs', () => { 474 | // TODO 475 | }) 476 | 477 | describe('depth', () => { 478 | it('should return the Service\'s depth in the tree hierarchy', () => { 479 | const parent = new gooey.Service('parent') 480 | const child1 = new gooey.service({name: 'child', parent: parent}) 481 | const child2 = new gooey.service({name: 'child2', parent: parent}) 482 | const childSub1 = new gooey.service({name: 'childSub1', parent: child1}) 483 | 484 | parent.depth().should.equal(0) 485 | child1.depth().should.equal(1) 486 | child2.depth().should.equal(1) 487 | childSub1.depth().should.equal(2) 488 | }) 489 | 490 | describe('siblings', () => { 491 | describe('global search', () => { 492 | it('should return all services in the hierarchy with the same depth, excluding the initiating service', () => { 493 | const parent = new gooey.Service('parent') 494 | const child1 = new gooey.service({name: 'child1', parent: parent}) 495 | const child2 = new gooey.service({name: 'child2', parent: parent}) 496 | const childSub1 = new gooey.service({name: 'childSub1', parent: child1}) 497 | const childSub2 = new gooey.service({name: 'childSub2', parent: child2}) 498 | const childSub3 = new gooey.service({name: 'childSub3', parent: child2}) 499 | 500 | parent.siblings().should.be.empty 501 | child1.siblings().map(s => s.name).should.eql(['child2']) 502 | child2.siblings().map(s => s.name).should.eql(['child1']) 503 | childSub1.siblings().map(s => s.name).should.eql(['childSub2', 'childSub3']) 504 | childSub2.siblings().map(s => s.name).should.eql(['childSub1', 'childSub3']) 505 | childSub3.siblings().map(s => s.name).should.eql(['childSub1', 'childSub2']) 506 | }) 507 | }) 508 | 509 | xdescribe('local search', () => { 510 | it('should return all service in the hierarchy with the same immediate parent', () => { 511 | // TODO 512 | }) 513 | }) 514 | }) 515 | }) 516 | 517 | describe('cycleExists', () => { 518 | it('should return true if there are any cycles in the tree', () => { 519 | const serviceA = new gooey.service({name: 'A'}) 520 | const serviceB = new gooey.service({name: 'B', parent: serviceA}) 521 | const serviceC = new gooey.service({name: 'C', parent: serviceB, children: [serviceA]}) 522 | 523 | gooey.Service.cycleExists().should.be.true() 524 | }) 525 | 526 | it('should reurn false if the tree is acyclic', () => { 527 | const serviceA = new gooey.Service('A.2') 528 | const serviceB = new gooey.service({name: 'B.2', parent: serviceA}) 529 | const serviceC = new gooey.service({name: 'C.2', parent: serviceA}) 530 | 531 | gooey.Service.cycleExists().should.be.false() 532 | }) 533 | }) 534 | 535 | }) 536 | 537 | describe('Subscription', () => { 538 | 539 | beforeEach(gooey.clear) 540 | 541 | describe('matches', () => { 542 | describe('basic', () => { 543 | it('should perform simple equality comparison', () => { 544 | const service = new gooey.Service('foo') 545 | const results = [] 546 | 547 | service.subscribe(123, data => results.push(true)) 548 | service.publish(123) 549 | 550 | results.should.containEql(true) 551 | }) 552 | }) 553 | 554 | describe('JsonPath', () => { 555 | it('should only perform jsonpath matching if the configuration permits (false)', () => { 556 | const service = new gooey.service({name: 'foo', config: {data: {matching: false }}}) 557 | const passiveData = {ignore: true} 558 | const results = [] 559 | 560 | service.subscribe('$.ignore', data => { results.push(data) }) 561 | service.publish(passiveData) 562 | 563 | results.should.not.containEql(passiveData) 564 | }) 565 | 566 | it('should only perform json-where matching if the configuration permits (true)', () => { 567 | const service = new gooey.service({name: 'foo', config: {data: {matching: true }}}) 568 | const activeData = {find: true} 569 | const results = [] 570 | 571 | service.subscribe('$.find', data => { results.push(data) }) 572 | service.publish(activeData) 573 | 574 | results.should.containEql(activeData) 575 | }) 576 | 577 | it('should return json-where matches from all relevant subscribers', () => { 578 | const activeData = {find: 'bar'} 579 | const service = new gooey.service({name: 'foo'}) 580 | const scription = service.subscribe('$.find') 581 | const matches = service.matches(activeData, scription) 582 | 583 | Array.from(matches).should.eql(['bar']) 584 | }) 585 | }) 586 | }) 587 | 588 | }) 589 | --------------------------------------------------------------------------------