rescope
Version:
Flexible state management system based on flux architecture, stores data components & inheritable scopes
946 lines (839 loc) • 31.6 kB
JavaScript
/*
* Copyright (c) 2018 Wise Wild Web .
*
* MIT License
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* @author : Nathanael Braun
* @contact : caipilabs@gmail.com
*/
var is = require('is'),
Scope = require('./Scope'),
EventEmitter = require('./Emitter'),
TaskSequencer = require('./TaskSequencer'),
shortid = require('shortid'),
objProto = Object.getPrototypeOf({});
/**
* @class Store
*/
class Store extends EventEmitter {
static use = [];// overridable list of source stores
static follow;// overridable list of store that will allow push if updated
static require;
static staticScope = new Scope({}, { id: "static" });
static state = undefined;// default state
/**
* if retain goes to 0 :
* false to not destroy,
* 0 to sync auto destroy
* Ms to autodestroy after tm ms if no retain has been called
* @type {boolean|Int}
*/
static persistenceTm = false;
/**
* Constructor, will build a rescope store
*
* (scope, {require,use,apply,state, data})
* (scope)
*
* @param scope {object} scope where to find the other stores (default : static
* staticScope )
* @param keys {Array} (passed to Store::map) Ex : ["session", "otherNamedStore:key",
* otherStore.as("otherKey")]
*/
constructor() {
super();
var argz = [ ...arguments ],
_static = this.constructor,
scope = argz[ 0 ] instanceof Scope
? argz.shift()
: _static.scope ? Scope.getScope(_static.scope)
: is.string(argz[ 0 ])
? Scope.getScope(argz.shift())
: _static.staticScope,
cfg = argz[ 0 ] && !is.array(argz[ 0 ]) && !is.string(argz[ 0 ])
? argz.shift()
: {},
name = is.string(argz[ 0 ]) ? argz[ 0 ] : cfg.name || _static.name,
watchs = is.array(argz[ 0 ]) ? argz.shift() : cfg.use || [],
apply = is.fn(argz[ 0 ]) ? argz.shift() : cfg.apply || null,
initialState = _static.state || _static.initialState || _static.defaultState,
applied;
this._uid = cfg._uid || shortid.generate();
this.__retains = { all: 0 };
this.__locks = { all: 0 };
this._onStabilize = [];
// autoDestroyTm
this._autoDestroy = !!this._persistenceTm;
this._persistenceTm = cfg.persistenceTm || _static.persistenceTm || ( cfg.autoDestroy || _static.autoDestroy ) && 5;
if ( cfg && cfg.on ) {
this.on(cfg.on);
}
this.name = name;
if ( scope.stores ) {
this.scopeObj = scope;
this.scope = scope.stores;
}
else {
this.scopeObj = new Scope(scope);
this.scope = scope.stores;
}
// standardized scope access
this.$scope = this.scopeObj;
this.$stores = this.scopeObj.stores;
this.$actions = this.scopeObj.actions;
this.$dispatch = this.scopeObj.dispatch.bind(this.scopeObj);
this._rev = this.constructor._rev || 0;
this._revs = {};
this.stores = {};
this._require = [];
this._sources = [ name ];
if ( is.array(_static.use) ) {
this._use = [ ...watchs, ...( _static.use || [] ).map(
key => {
let ref = key.match(/^(\!?)([^\:]*)(?:\:(.*))?$/);
if ( ref[ 1 ] ) {
let ref2 = ref[ 2 ].split('.');
this._require.push(ref[ 3 ] || ref2[ ref2.length - 1 ]);
}
return ref[ 2 ];
}
) ];
}
else {
this._use = [ ...watchs, ...(
_static.use ? Object.keys(_static.use)
.map(
key => {
let ref = key.match(/^(\!?)(.*)$/);
ref[ 1 ] && this._require.push(_static.use[ key ]);
return ref[ 2 ] + ( ( _static.use[ key ] === true )
? ''
: ':' + _static.use[ key ] );
}
) : []
) ];
}
if ( _static.require )
this._require.push(..._static.require);
if ( cfg.require )
this._require.push(...cfg.require);
this._followers = [];
this._changesSW = {};
if ( apply )
this.apply = apply;
if ( cfg.snapshot && cfg.snapshot[ this.scopeObj._id + '/' + name ] ) {
this.restore(cfg.snapshot);
this._stable = true;
scope.bind(this, this._use, false);
}
else {
if ( _static.data !== undefined )
this.data = { ..._static.data };
if ( cfg.hasOwnProperty("data") && cfg.data !== undefined )
this.data = cfg.data;
if ( cfg.hasOwnProperty("state") && cfg.state !== undefined )
initialState = { ...initialState, ...cfg.state };
if ( initialState || this._use.length ) {// sync apply
this._changesSW = {
...( initialState || {} ),
...scope.map(this, this._use)
};
this.state = {};
if ( this.shouldApply(this._changesSW) && this.data === undefined ) {
this.data = this.apply(this.data, this._changesSW, this._changesSW);
applied = true;
this.state = this._changesSW;
this._changesSW = {};
}
}
}
if ( ( this.data !== undefined || applied ) && !this.__locks.all ) {
this._stable = true;
this._rev++;
}
else {
this._stable = false;
if ( !_static.managed && !this.state && ( !this._use || !this._use.length ) ) {
console.warn("ReScope store '", this.name, "' have no initial data, state or use. It can't stabilize...");
}
}
!this._stable && this.emit('unstable', this.state);
}
/**
* @deprecated
* @returns {*}
*/
get contextObj() {
return this.scopeObj;
}
/**
* @deprecated
* @returns {*}
*/
get context() {
return this.scope;
}
/**
* @deprecated
* @returns {*}
*/
get datas() {
return this.data;
}
/**
* @deprecated
* @returns {*}
*/
set datas( v ) {
//console.groupCollapsed("Rescope store : Setting datas is depreciated, use
// data"); console.log("Rescope store : Setting datas is depreciated, use data",
// (new Error()).stack); console.groupEnd();
this.data = v;
}
/**
* Get the incoming state ( for immediate state relative actions )
* @returns {{}|*}
*/
get nextState() {
return this._changesSW && { ...this.state, ...this._changesSW } || this.state;
}
/**
* Overridable method to know if a data change should be propag to the listening
* stores & components
*/
shouldPropag( nDatas ) {
return true;
}
hasDataChange( nDatas ) {
var _static = this.constructor, r,
cDatas = this.data;
r = !cDatas && nDatas || cDatas !== nDatas;
!r && cDatas && Object.keys(cDatas).forEach(
( key ) => {
r = r || ( nDatas
? cDatas[ key ] !== nDatas[ key ]
: cDatas && cDatas[ key ] )
}
);
!r && nDatas && Object.keys(nDatas).forEach(
( key ) => {
r = r || ( nDatas
? cDatas[ key ] !== nDatas[ key ]
: cDatas && cDatas[ key ] )
}
);
return r;
}
/**
* Overridable method to know if a state change should be applied
*/
shouldApply( state = this.state ) {
var _static = this.constructor;
return (
!!this.isComplete(state)
) && ( is.array(_static.follow)
? _static.follow
.reduce(( r, i ) => ( r || state && state[ i ] ), false)
: _static.follow
? Object.keys(_static.follow)
.reduce(( r, i ) => (
r
|| state && is.fn(_static.follow[ i ]) && _static.follow[ i ].call(this, state[ i ])
|| _static.follow[ i ] && state[ i ] !== this.state[ i ]
), false) : true
);
}
/**
* Overridable applier / remapper
* If state or lastPublicState are simple hash maps apply will return {...data,
* ...state} if not it will return the last private state
* @param data
* @param state
* @returns {*}
*/
apply( data, state, changes ) {
state = state || this.state;
if ( this.refine )
return this.refine(...arguments);
if ( !data || data.__proto__ !== objProto || state.__proto__ !== objProto )
return state;
else
return { ...data, ...state }
}
/**
* @depreciated
* @param data
* @param state
* @param changes
* @returns {*}
*/
refine( data, state, changes ) {
state = state || this.state;
if ( !data || data.__proto__ !== objProto || state.__proto__ !== objProto )
return state;
else
return { ...data, ...state }
}
/**
* Debounce this store propagation ( & reducing )
* @param cb
*/
stabilize( cb ) {
cb && this.once('stable', cb);
this._stable && this.emit('unstable', this.state, this.data);
this._stable = false;
if ( this._stabilizer )
return;
this._stabilizer = TaskSequencer.pushTask(this, 'pushState');
}
retrieve( path, i = 0, obj = this.data ) {
path = is.string(path) ? path.split('.') : path;
return !obj || !path || !path.length
? obj
: path.length == i + 1
? obj[ path[ i ] ]
: this.retrieve(path, i + 1, obj[ path[ i ] ]);
}
dispatch( action, ...argz ) {
this.scopeObj.dispatch(action, ...argz);
}
trigger( action, ...argz ) {
let { actions } = this.constructor;
if ( actions && actions[ action ] ) {
let ns = actions[ action ].call(this, ...argz);
ns && this.setState(ns);
}
}
/**
* Pull stores in the private state
* @param stores {Array} (passed to Store::map) Ex : ["session",
* "otherNamedStore:key", otherStore.as("otherKey")]
*/
pull( stores, doWait, origin ) {
let initialOutputs = this.scopeObj.map(this, stores);
if ( doWait ) {
this.wait();
stores.forEach(( s ) => this.scope[ s ] && this.wait(this.scope[ s ]));
this.release();
}
return initialOutputs;
}
/**
* Set & Push the result data to followers if stable
* @param cb
*/
push( data, force, cb ) {
cb = force === true ? cb : force;
force = force === true;
if ( !force &&
(
!this.hasDataChange(data)
)
) {
cb && cb();
if ( !this.__locks.all ) {
let stable = this._stable;
this._stable = true;
!stable && this.emit('stable', this.state, this.data);
this._stabilizer = null;
}
return false;
}
this.data = data;
this.wait();
this.release(cb);
}
/**
* Call the apply fn using the current accumulated state update then, push the
* resulting data if stable
* @param force
* @returns {boolean}
*/
pushState( force ) {
if ( !force && !this._changesSW && this.data )
return;
var nextState = { ...this.state, ...( this._changesSW || {} ) },
nextData = this.apply(this.data, nextState, this._changesSW);
this._stabilizer = null;
this.state = nextState;
this._changesSW = null;
if ( !force &&
(
!this.hasDataChange(nextData)
)
) {
if ( !this.__locks.all ) {
let stable = this._stable;
this._stable = true;
!stable && this.emit('stable', this.state, this.data);
this._stabilizer = null;
}
return false;
}
this.data = nextData;
this.wait();
this.release();
}
/**
* Add 'pState' to the current accumulated state updates
* & wait source stores stabilization before pushing these state updates
* @param pState
* @param cb
*/
setState( pState, cb, sync ) {
var i = 0, change,
changes = this._changesSW = this._changesSW || {};
for ( var k in pState )
if ( !this.state
|| changes.hasOwnProperty(k)// todo
&& (
pState[ k ] !== changes[ k ]
) || pState.hasOwnProperty(k)
&& (
pState[ k ] !== this.state[ k ]
||
( this.state[ k ] && pState[ k ] && ( pState[ k ]._rev != this._revs[ k ] ) )// rev/hash update
) ) {
change = true;
this._revs[ k ] = pState[ k ] && pState[ k ]._rev || true;
changes[ k ] = pState[ k ];
}
if ( !this.shouldApply({ ...this.state, ...changes }) ) {
return;
}
if ( sync ) {
this.pushState();
cb && cb();
}
else {
if ( change ) {
this.stabilize(cb);
}
else cb && cb();
}
return this;
}
/**
* Update the current state & push it
* @param pState
* @param cb
*/
setStateSync( pState ) {
var i = 0, change,
changes = this._changesSW = this._changesSW || {};
for ( var k in pState )
if ( !this.state || pState.hasOwnProperty(k)
&& (
pState[ k ] != this.state[ k ]
||
( this.state[ k ] && pState[ k ] && ( pState[ k ]._rev != this._revs[ k ] ) )// rev/hash update
) ) {
change = true;
this._revs[ k ] = pState[ k ] && pState[ k ]._rev || true;
changes[ k ] = pState[ k ];
}
this.shouldApply({ ...( this.state || {} ), ...changes }) && this.pushState();
return this.data;
}
/**
* get a store-key pair for Store::map
* @param {string} name
* @returns {{store: Store, name: *}}
*/
as( name ) {
return { store: this, name };
}
on( lists ) {
if ( !is.string(lists) && lists )
Object.keys(lists).forEach(k => super.on(k, lists[ k ]));
else super.on(...arguments);
}
removeListener( lists ) {
if ( !is.string(lists) && lists )
Object.keys(lists).forEach(k => super.removeListener(k, lists[ k ]));
else super.removeListener(...arguments);
}
/**
* is complete (all requiered keys are here)
* @returns bool
*/
isComplete( state = this.state ) {
var _static = this.constructor;
return (
!this._require
|| !this._require.length
|| state && this._require.reduce(
( r, key ) => ( r && state[ key ] ),
true
)
);
}
/**
* is stable
* @returns bool
*/
isStable() {
return this._stable;
}
/**
* Serialize state & data with sources refs
* @returns bool
*/
serialize( withRefs = true, output = {} ) {
let refs =
withRefs && is.array(this._use) && this._use.reduce(
( map, key ) => {//todo
let name,
alias, path,
store;
if ( key.store && key.name ) {
alias = name = key.name;
}
else if ( is.fn(key) ) {
name = alias = key.name || key.defaultName;
}
else {
key = key.match(/([\w_]+)((?:\.[\w_]+)*)(?:\:([\w_]+))?/);
name = key[ 1 ];
path = key[ 2 ] && key[ 2 ].substr(1);
alias = key[ 3 ] || path && path.match(/([^\.]*)$/)[ 0 ] || key[ 1 ];
}
if ( !this.scopeObj.stores[ name ].scopeObj._.isLocalId )
map[ alias ] = this.scopeObj.stores[ name ].scopeObj._id + '/' + name;
return map;
}, {}
) || {};
output[ this.scopeObj._id + '/' + this.name ] = {
state: this.state &&
( !withRefs
? { ...this.state }
: Object.keys(this.state).reduce(( h, k ) => ( !refs[ k ] && ( h[ k ] = this.state[ k ] ), h ), {}) ),
data : this.data,
refs
};
return output;
}
/**
* restore state & data
* @returns bool
*/
restore( snapshot ) {
let snap = snapshot[ this.scopeObj._id + '/' + this.name ];
if ( snap ) {
this.state = snap.state;
Object.keys(snap.refs).forEach(
( key ) => {//todo
if ( snapshot[ snap.refs[ key ] ] )
snap.state[ key ] = snapshot[ snap.refs[ key ] ].data;
else
console.warn('not found : ', key, snap.refs[ key ])
}
)
this.data = snap.data;
}
}
/**
* Un bind this store off the given component-key
* @param obj
* @param key
* @returns {Array.<*>}
*/
unBind( obj, key, path ) {
var followers = this._followers,
i = followers && followers.length;
while ( followers && i-- )
if ( followers[ i ][ 0 ] === obj && followers[ i ][ 1 ] === key && followers[ i ][ 2 ] === path )
return followers.splice(i, 1);
}
/**
* Bind this store changes to the given component-key
* @param obj {React.Component|Store|function)
* @param key {string} optional key where to map the public state
*/
bind( obj, key, setInitial = true, path ) {
this._followers.push([ obj, key, path ]);
if ( setInitial && this.data && this._stable ) {
let data = path ? this.retrieve(path) : this.data;
if ( typeof obj != "function" ) {
if ( key ) obj.setState({ [ key ]: data });
else obj.setState(data);
}
else {
obj(data);
}
}
}
/**
* once('stable', cb)
* @param obj {React.Component|Store|function)
* @param key {string} optional key where to map the public state
*/
then( cb ) {
if ( this._stable )
return cb(null, this.data);
this.once('stable', e => cb(null, this.data));
}
/**
* Add a lock so the store will not propag it data untill release() is call
* @param previous {Store|number|Array} @optional wf to wait, releases to wait or
* array of stuff to wait
* @returns {TaskFlow}
*/
wait( previous ) {
if ( typeof previous == "number" )
return this.__locks.all += previous;
if ( is.array(previous) )
return previous.map(this.wait.bind(this));
this._stable && this.emit('unstable', this.state, this.data);
this._stable = false;
this.__locks.all++;
let reason = is.string(previous) ? previous : null;
if ( reason ) {
this.__locks[ reason ] = this.__locks[ reason ] || 0;
this.__locks[ reason ]++;
}
if ( previous && is.fn(previous.then) ) {
previous.then(this.release.bind(this, null));
}
return this;
}
/**
* Decrease locks for this store, if it reach 0 ,
* it will be propagated to the followers,
* then, all stuff passed to "then" call back will be exec / released
* @param desync
* @returns {*}
*/
release( reason, cb ) {
var _static = this.constructor, me = this;
let i = 0, wasStable = this._stable;
if ( is.fn(reason) ) {
cb = reason;
reason = null;
}
if ( reason ) {
if ( this.__locks[ reason ] == 0 )
console.error("Release more than locking !", reason);
this.__locks[ reason ] = this.__locks[ reason ] || 0;
this.__locks[ reason ]--;
}
if ( !reason && this.__locks.all == 0 )
console.error("Release more than locking !");
if ( !--this.__locks.all && this.isComplete() ) {
let propag = this.shouldPropag(this.data);
this._stable = true;
propag && this._rev++;//
if ( propag && this._followers.length )
this._followers.forEach(function propag( follower ) {
let data = follower[ 2 ] ? me.retrieve(follower[ 2 ]) : me.data;
//if ( !data ) return;
if ( typeof follower[ 0 ] == "function" ) {
follower[ 0 ](data);
}
else {
//cb && i++;
follower[ 0 ].setState(
( follower[ 1 ] ) ? { [ follower[ 1 ] ]: data }
: data
//,
//cb && (
// () => (!(--i) && cb())
//)
);
}
});
//else
!wasStable && this.emit('stable', this.data);
propag && this.emit('update', this.data);
cb && cb()
}
else cb && this.then(cb);
return this;
}
propag( data ) {
this.emit('update', data);
}
retain( reason ) {
this.__retains.all++;
if ( reason ) {
this.__retains[ reason ] = this.__retains[ reason ] || 0;
this.__retains[ reason ]++;
}
}
dispose( reason ) {
//console.warn("dispose", reason, this.__retains);
if ( reason ) {
if ( !this.__retains[ reason ] )
throw new Error("Dispose more than retaining : " + reason);
this.__retains[ reason ]--;
}
if ( this.__retains.all == 0 )
throw new Error("Dispose more than retaining !");
this.__retains.all--;
if ( !this.__retains.all ) {
if ( this._persistenceTm ) {
this._destroyTM && clearTimeout(this._destroyTM);
this._destroyTM = setTimeout(
e => {
this._destroyTM = null;
//this.then(s => {
!this.__retains.all && this.destroy()
//});
},
this._persistenceTm
);
}
else {
//this.then(s =>
( !this.__retains.all && this.destroy() )
//);
}
}
}
destroy() {
// console.log("destroy", this._uid);
this.emit('destroy', this);
if ( this._stabilizer )
clearTimeout(this._stabilizer);
if ( this._followers.length )
this._followers.forEach(
( follower ) => {
if ( typeof follower[ 0 ] !== "function" ) {
if ( follower[ 0 ].stores )
delete follower[ 0 ].stores[ follower[ 1 ] ];
}
}
);
this._followers.length = 0;
this.constructor._rev = this.rev;
this.dead = true;
this._revs = this.data = this.state = this.scope = null;
this.removeAllListeners();
}
}
/**
* get a static aliased reference of a store
* @param {string} name
* @returns {{store: Store, name: *}}
*/
Store.as = function ( name ) {
return { store: this, name };
}
/**
* Map all named stores in {keys} to the {object}'s state
* Hook componentWillUnmount (for react comp) or destroy to unBind them automatically
* @static
* @param object {Object} target state aware object (React.Component|Store|...)
* @param keys {Array} Ex : ["session", "otherStaticNamedStore:key",
* store.as('anotherKey')]
*/
Store.map = function ( component, keys, scope, origin, setInitial = false ) {
var targetRevs = component._revs || {};
var targetScope = component.stores || ( component.stores = {} );
var initialOutputs = {};
keys = is.array(keys) ? [ ...keys ] : [ keys ];
scope = scope || Store.staticScope;
keys = keys.filter(
// @todo : use query refs
// (store)(\.store)*(\[(\*|(props)\w+)+)\])?(\:alias)
( key ) => {
if ( !key ) {
console.error("Not a mappable store item '" + key + "' in " + origin + ' !!');
return false;
}
let name,
alias,
path,
store;
if ( key.store && key.name ) {
alias = name = key.name;
store = key.store;
}
else if ( is.fn(key) ) {
name = alias = key.name || key.defaultName;
store = key;
}
else {
key = key.match(/([\w_]+)((?:\.[\w_]+)*)(?:\:([\w_]+))?/);
name = key[ 1 ];
path = key[ 2 ] && key[ 2 ].substr(1);
store = scope.stores[ key[ 1 ] ];
alias = key[ 3 ] || path && path.match(/([^\.]*)$/)[ 0 ] || key[ 1 ];
}
if ( targetRevs[ name ] ) return false;// ignore dbl uses for now
if ( !store ) {
console.error("Not a mappable store item '" + name + "/" + alias + "' in " + ( component.name || component ) + ' !!', store);
return false;
}
else if ( is.fn(store) ) {
scope._mount(name)
scope.stores[ name ].bind(component, alias, setInitial, path);
}
else {
store.bind(component, alias, setInitial, path);
}
// give initial store weight basing sources
component._sources.push(...scope.stores[ name ]._sources);
targetRevs[ alias ] = targetRevs[ alias ] || true;
!targetScope[ name ] && ( targetScope[ name ] = scope.stores[ name ] );
if ( scope.stores[ name ].hasOwnProperty('data') )
initialOutputs[ name ] = scope.data[ name ];
return true;
}
);
// ...
var mixedCWUnmount,
unMountKey = component.isReactComponent ? "componentWillUnmount" : "destroy";
if ( component.hasOwnProperty(unMountKey) ) {
mixedCWUnmount = component[ unMountKey ];
}
component[ unMountKey ] = ( ...argz ) => {
delete component[ unMountKey ];
if ( mixedCWUnmount )
component[ unMountKey ] = mixedCWUnmount;
keys.map(
( key ) => {
let name,
alias, path,
store;
if ( key.store && key.name ) {
alias = name = key.name;
store = key.store;
}
else if ( is.fn(key) ) {
name = alias = key.name || key.defaultName;
store = scope.stores[ name ];
}
else {
key = key.match(/([\w_]+)((?:\.[\w_]+)*)(?:\:([\w_]+))?/);
name = key[ 1 ];
path = key[ 2 ] && key[ 2 ].substr(1);
store = scope.stores[ key[ 1 ] ];
alias = key[ 3 ] || path && path.match(/([^\.]*)$/)[ 0 ] || key[ 1 ];
}
store && !is.fn(store) && store.unBind(component, alias, path)
}
);
return component[ unMountKey ] && component[ unMountKey ](...argz);
}
return initialOutputs;
};
export default Store;