Last active
October 21, 2016 08:17
-
-
Save peeke/da2f81a635bfdc12623ea01fb432824d to your computer and use it in GitHub Desktop.
A single data store for modules to communicate with.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* A single data store for modules to communicate with. Keeping 'the truth' in a single place reduces bugs and allows | |
* for greater seperation of concerns. | |
* | |
* The module can be used as a singleton through DataStore.getSingleton('key'), or on instance basis through the new keyword. | |
* | |
* Uses the Observer module found here: https://gist.github.com/peeke/42a3f30c2a3856c65c224cc8a47b95f9 | |
* | |
* @name DataStore | |
* @author Peeke Kuepers | |
*/ | |
import observer from 'util/observer'; | |
// Keep track of singleton instances | |
const dataStores = new Map(); | |
class DataStore { | |
constructor() { | |
this._data = {}; | |
this._publishers = new Map(); | |
} | |
/** | |
* Get (a copy) of the data stored at the path | |
* @param {String} path - The path | |
* @returns {*} - The data stored at the path | |
*/ | |
get(path) { | |
// In some cases we need to clone the result, because we don't want to pass result by reference. | |
// This would lead to cases where changing an object outside of the DataStore actually changes the data stored within. | |
const result = this._get(path, false); | |
// Clone array if result is an array | |
if (Array.isArray(result)) return result.slice(0); | |
// Clone object if result is an object | |
if (typeof result === 'object') return Object.assign({}, result); | |
// Any other result is safe to pass directly | |
return result; | |
} | |
/** | |
* Update the path with a new value and publish the changes, if not silenced | |
* @param {String} path - The path | |
* @param {*} value - The value to set | |
* @param {Boolean} silent - Whether to silence change publications | |
*/ | |
set(path, value, silent = false) { | |
// Bailout if the value doesn't change with this set | |
if (this._get(path) === value) return; | |
// Update path with the new value | |
this._set(path, value); | |
// Bailout if this is a silent set | |
if (silent) return; | |
// Publish to all relevant paths | |
const lowerPaths = this._lowerPaths(value).map(lowerPath => path + '.' + lowerPath); | |
const higherPaths = this._higherPaths(path); | |
const relevantPaths = [...higherPaths, ...lowerPaths]; | |
relevantPaths.forEach(relevantPath => { | |
observer.publish(this.publisher(relevantPath), 'change', this.get(relevantPath)); | |
}); | |
} | |
/** | |
* Clear the data from the path | |
* @param {String} path - The path | |
*/ | |
delete(path) { | |
// Get data at the location | |
const data = this._get(this._location(path)); | |
// Delete the key | |
delete data[this._key(path)]; | |
// Update data | |
this.set(this._location(path), data); | |
} | |
/** | |
* Get publischer object for a path, to manually listen for changes using the observer | |
* @param {String} path - The path | |
* @returns {Object} - Publisher | |
*/ | |
publisher(path) { | |
if (Array.isArray(path)) { | |
return this._watcher(path); | |
} | |
if (!this._publishers.has(path)) { | |
this._publishers.set(path, {}); | |
} | |
return this._publishers.get(path); | |
} | |
/** | |
* Returns an array of publishers, on which change publications get published all given paths contain valid values | |
* @param {Array} paths - An array of paths to watch | |
* @returns {Array} - An array of publishers | |
* @private | |
*/ | |
_watcher(paths) { | |
// Call the callback function, once all requested paths contain a defined value | |
const fire = () => { | |
const args = paths.map(path => this.get(path)); | |
const undefinedValues = args.filter(arg => typeof arg === 'undefined'); | |
if (undefinedValues.length) return; | |
observer.publish(publishers, 'change', ...args); | |
}; | |
// Listen to paths | |
const publishers = paths.map(path => this.publisher(path)); | |
publishers.forEach(publisher => observer.subscribe(publisher, 'change', fire)); | |
// Update initially, to check if all paths already have a value | |
fire(); | |
return publishers; | |
} | |
// Private | |
/** | |
* Return higher paths for a given path. | |
* E.g.: 'foo.bar.baz' returns ['foo', 'foo.bar', 'foo.bar.baz'] | |
* @param {String} path - The path | |
* @returns {Array} - An array of paths | |
* @private | |
*/ | |
_higherPaths(path) { | |
const parts = path.split('.'); | |
return parts.filter(v => v).map((part, i) => parts.slice(0, i + 1).join('.')); | |
} | |
/** | |
* Converts an object to paths. | |
* E.g.: { foo: { bar: 'baz', baz: { msg: 'helloworld' } } } becomes: | |
* ['foo', 'foo.bar', 'foo.baz', 'foo.baz.msg'] | |
* @param {Object} object - The object to traverse | |
* @returns {Array} - An array of paths | |
* @private | |
*/ | |
_lowerPaths(object) { | |
if (typeof object !== 'object' || Array.isArray(object)) return []; | |
let paths = Object.keys(object); | |
paths.forEach(key => { | |
const lowerPaths = this._lowerPaths(object[key]); | |
paths = [...paths, ...lowerPaths.map(lowerPath => key + '.' + lowerPath)]; | |
}); | |
return paths; | |
} | |
/** | |
* Get the location of the path: the whole path excluding the last part (last part is the key) | |
* E.g.: for 'a.b.c.d', would return 'a.b.c' | |
* @param {String} path - The path | |
* @returns {string} | |
* @private | |
*/ | |
_location(path) { | |
const parts = path.split('.'); | |
return parts.slice(0, parts.length - 1).join('.'); | |
} | |
/** | |
* Get the key of the path: the tail of the path | |
* E.g.: for 'a.b.c.d', would return 'd' | |
* @param {String} path - The path | |
* @returns {String} - The key | |
* @private | |
*/ | |
_key(path) { | |
const parts = path.split('.'); | |
return parts[parts.length - 1]; | |
} | |
/** | |
* Updates this._data with the new value at path merged in | |
* @param {String} path - The path | |
* @param {*} value - The value to set | |
* @private | |
*/ | |
_set(path, value) { | |
// Update data! | |
const foldByPath = (update, higherPath) => { | |
const object = {}; | |
const deadEnd = typeof update !== 'object' || Array.isArray(update); | |
object[this._key(higherPath)] = deadEnd ? update : Object.assign(this._get(higherPath), update); | |
return object; | |
}; | |
const update = this._higherPaths(path) | |
.reverse() | |
.reduce(foldByPath, value); | |
Object.assign(this._data, update); | |
} | |
/** | |
* Get the value at the given path | |
* When forceDefined is true, undefined values on the path are defined with {} | |
* @param {String} path - The path | |
* @param {Boolean} forceDefined - Whether to define undefined values | |
* @returns {*} | |
* @private | |
*/ | |
_get(path, forceDefined = true) { | |
if (!path) { | |
return this._data; | |
} | |
return path.split('.').reduce((result, part) => { | |
if (result && typeof result[part] !== 'undefined') return result[part]; | |
if (forceDefined) return {}; | |
}, this._data); | |
} | |
/** | |
* Returns a singleton instance of this class | |
* @param {String} store - Store identifier | |
* @returns {V} | |
*/ | |
static getSingleton(store) { | |
if (!dataStores.has(store)) { | |
dataStores.set(store, new DataStore(true)); | |
} | |
return dataStores.get(store); | |
} | |
} | |
export default DataStore; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import DataStore from 'concepts/DataStore'; | |
import observer from 'util/observer'; | |
const debug = key => (...args) => console.log(key, '-->', ...args); | |
const store1 = DataStore.getSingleton('store-label'); | |
store1.set('foo.bar.baz', { hello: { world: { message: 'Hi there' } } }); | |
const value1 = store1.get('foo.bar.baz.hello.world.message'); | |
const value2 = store1.get('foo'); | |
debug('tail of path')(value1); | |
// => tail of path --> Hi there | |
debug('head of path')(value2); | |
// => head of path --> Object { foo: { bar: { baz: { hello: { world: { message: "Hi there" } } } } } } | |
const store2 = new DataStore(); | |
const single = store2.publisher('baz'); | |
observer.subscribe(single, 'change', debug('single')); | |
const multiple = store2.publisher(['foo', 'bar', 'baz']); | |
observer.subscribe(multiple, 'change', debug('multiple')); | |
const waitUntill = store2.publisher('baz.bar'); | |
observer.subscribe(waitUntill, 'change', debug('wait untill')); | |
store2.set('baz', { foo: 'helloworld' }); | |
// => single --> Object {foo: "helloworld"} | |
store2.set('foo', 'bar'); | |
store2.delete('baz'); | |
store2.set('bar', 'baz'); | |
setTimeout(() => store2.set('baz', { bar: 'Just waiting around for a bit' }), 4000); | |
// after 4s | |
// => multiple --> Object { bar: "Just waiting around for a bit" } | |
// => single --> Object { bar: "Just waiting around for a bit" } | |
// => wait untill --> Just waiting around for a bit |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
What would be the way to go, listening to changes through as it is done now:
Or subscribe with the path as event:
observer.subscribe(dataStore, 'foo.bar.baz', handlerFn);
Method two seems way easier, but I'm not sure if I want to give the path as an event string, since technically it's not an event.