UNPKG

leopold

Version:
483 lines (458 loc) 14.2 kB
'use strict'; import Promise from 'bluebird' import stampit from 'stampit' import cuid from 'cuid' //utility function isFunction(obj) { return obj && toString.call(obj === '[object Function]') } /** * accepts `_id` as an initial id value. if an `id` function * exists (further up the composition chain) it does not override it; * otherwise, it provides its own method for `id()` * */ const identifiable = stampit() .init(function(){ //accept id initializer value let id = this._id ;(delete this._id) if(!isFunction(this.id)) { this.id = function() { return (id || (id = cuid() )) } this.hasIdentity = () => { return (typeof(id) !== 'undefined') } } }) /** * encapsulates behaviors for revisioning of components * */ const revisable = stampit() .init(function() { let revision = 1 /** * either get the current revision or set the revision with `val` * @param {Number} val the revision to set * */ this.revision = function (val) { if(val) { return (revision = val) } return revision } /** * gets next revision (doesnt mutate state) * */ this.nextRevision = () => { return (this.revision() + 1) } }) /** * simple hashmap storage of event providers * */ const hashIdentityMap = stampit().init(function(){ let providers = new Map() this.register = (id, provider) => { if(!id) { throw new Error('`id` is required') } if(!provider) { throw new Error('`provider` is required') } providers.set(id, provider) return provider } this.get = (id) => { if(!id) { throw new Error('`id` is required') } let provider = providers.get(id) if(!provider) { throw new Error('could not locate provider with id "' + id + '""') } return provider } this.release = () => { providers.clear() } }) const nullStorage = stampit() .init(function(){ this.store = () => { } this.events = function*(from, to) { return [] } this.clear = () => { } }) const inMemoryStorage = stampit() .compose(revisable) .init(function() { var envelopes = [] this.store = (env) => { if(!env.revision) { env.revision = this.revision(this.nextRevision()) } envelopes.push(env) return this } /** * clear all envelops. DANGER ZONE! * */ this.clear = () => { envelopes = [] } this.events = function*(from, to) { from = (from || 0) to = (to || Number.MAX_VALUE) if(from > to) { throw new Error('`from` must be less than or equal `to`') } if(!envelopes.length) { return [] } for(let env of envelopes) { if(env.revision > to) { //we are done streaming return } if(env.revision >= from) { for(let ev of env.events) { yield ev } } } } }) const writeableUnitOfWork = stampit() .refs({ storage: undefined , identityMap: undefined }) .methods({ envelope: function enveloper( events) { return { events: events } } }) .init(function(){ let pending = [] this.append = (e) => { pending.push.apply(pending,e) return e } this.commit = () => { //move reference to array in case event arrives while flushing let committable = pending.splice(0,pending.length) let envelope = this.envelope(committable) this.storage.store(envelope) return this } this.register = function() { //no op } }) const readableUnitOfWork = stampit() .refs({ storage: undefined , identityMap: undefined }) .init(function(){ this.append = (e) => { //no op return e } this.commit = () => { return this } this.register = this.identityMap.register //helper function to allow function binding during iteration function asyncApply(event, identityMap) { let target = identityMap.get(event.id) return target.applyEvent(event) } const iterate = (cur, iterator, accumulator) => { if(cur.done) { return accumulator } let event = cur.value let result = undefined if(accumulator.promise) { //chain promises //effectively creating a complicated reduce statement accumulator.promise = result = accumulator.promise .then(asyncApply.bind(this, event, this.identityMap)) } else { let target = this.identityMap.get(event.id) let fn = target.applyEvent.bind(target, event) try { result = fn() } catch(err) { iterator.throw(err) throw err } //was a promise returned? if(result && result.then) { accumulator.promise = result } } return iterate(iterator.next(), iterator, accumulator) } this.restore = (root, from, to) => { if(!root) { throw new Error('`root` is required') } this.register(root.id(),root) let events = this.storage.events(from, to) let accumulator = {} iterate(events.next(),events,accumulator) if(accumulator.promise) { return accumulator.promise } return this } }) const unitOfWork = stampit() .refs({ storage: undefined , identityMap: undefined }) .methods({ envelope: function enveloper( events) { return { events: events } } }) .init(function(){ let current let writeable = writeableUnitOfWork({ envelope : this.envelope , identityMap : this.identityMap , storage : this.storage }) let readable = readableUnitOfWork({ identityMap : this.identityMap , storage : this.storage }) this.append = (e) => { let result = current.append(e) if(!this.atomic) { //each event gets stored this.commit() return result } return result } this.commit = () => { current.commit() this.identityMap.release() return this } this.register = (id, provider) => { return current.register(id, provider) } this.restore = (root, from, to) => { current = readable let result = current.restore(root, from, to) if(result.then) { return result .bind(this) .then(function(){ this.identityMap.release() current = writeable return this }) } else { this.identityMap.release() current = writeable return this } } //by default we are in writeable state current = writeable }) const eventable = stampit() .init(function(){ let uow = this.leo.unitOfWork() //decorate event(s) with critical properties let decorate = (arr) => { let rev = this.nextRevision() return arr.map((e) => { e.id = this.id() e.revision = rev++ return e }) } let assertIdentity = () => { if(!this.hasIdentity()) { throw new Error('identity is unknown') } } let validateEvents = function (arr) { for(let e of arr) { if(!e || !e.event) { throw new Error('`event` is required') } } return arr } let pushEvent = (e) => { assertIdentity() if(!Array.isArray(e)) { e = [e] } validateEvents(e) decorate(e) uow.append(e) return e } /** * raise is the main interface you will use to mutate the eventable * instance and push the events onto the unit of work into storage. * The eventable instance revision is incremented. * @param {Object|Array} e the event(s) to push * @return {eventable} the result of `${event}` calls * */ this.raise = (e) => { return this.applyEvent(pushEvent(e)) } /** * pushEvent allows you to push event(s) directly into * the unit of work without mutating the provider. * this lets you stream into storage without getting into recursive loops * @param {Object|Array} e the event(s) to push * @return {eventable} the stampit eventable instance * */ this.pushEvent = (e) => { e = pushEvent(e) this.revision(e[e.length - 1].revision) return this } const applyEvent = (e, applied) => { if(applied.length === e.length) { return applied } let current = e[applied.length] this.revision(current.revision) applied.length = applied.length + 1 let fn = this['$' + current.event] let result = undefined if(applied.promise) { if(!fn) { return applyEvent(e, applied) } applied.promise = result = applied.promise .return(current) .bind(this) .then(fn) } else { if(!fn) { return applyEvent(e, applied) } result = fn.call(this, current) //received a promise if(result && result.then) { applied.promise = result } } applied.results.push(result) return applyEvent(e, applied) } this.applyEvent = (e) => { if(!Array.isArray(e)) { e = [e] } let applied = { results: [] , async: false , length: 0 } applyEvent(e,applied) if(applied.promise) { return Promise.all(applied.results) } return this } //register this instance on the unit of work uow.register(this.id(), this) }) export default stampit() .static({ /** * null object pattern for storage * when memory footprint is a concern or YAGNI storage * but want the benefits of event provider. * Handy for testing * */ nullStorage: nullStorage }) .compose(identifiable) .refs({ /** * `false` immediately stores events; otherwise, they are * queued to be committed to storage later. * */ atomic: true }) .init(function() { this.storage = (this.storage || inMemoryStorage()) this.identityMap = (this.identityMap || hashIdentityMap()) //default uow impl let uow = unitOfWork({ storage: this.storage , identityMap: this.identityMap , atomic: this.atomic }) /** * Expose an `stamp` that may be use for composition * with another stamp * @method eventable * @return {stamp} factory that may be composed to attach * `eventable` behaviors onto another stamp * */ this.eventable = () => { return stampit() .props({leo: this}) .compose(identifiable) .compose(revisable) .compose(eventable) } /** * convenience method to commit pending events to storage * @return {leopold} * */ this.commit = () => { return this.unitOfWork().commit() } /** * convenience method to unitOfWork inside `eventable` impl * @return {unitOfWork} * */ this.unitOfWork = () => { return uow } /** * mount an envelope having events into storage * useful for testing, or perhaps seeding an app from a backend * */ this.mount = (envelope) => { this.storage.store(envelope) return this } /** * restore to revision `to` from revision `from` * using `root` at the entrypoint. `to` and `from` are inclusive. * @param {eventable} root any `eventable` object * @param {Number} from lower bound revision to include * @param {Number} to upper bound revision to include * @return {Promise} resolving this leo instance */ this.restore = (root, from, to) => { return this.unitOfWork().restore(root, from, to) } this.revision = () => { return this.storage.revision() } })