Instantly share code, notes, and snippets.

@peeke /DataStore.js
Last active Oct 21, 2016

Embed
What would you like to do?
A single data store for modules to communicate with.
/**
* 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;
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
@peeke

This comment has been minimized.

Owner

peeke commented Oct 20, 2016

What would be the way to go, listening to changes through as it is done now:

const publisher = dataStore.publisher('foo.bar.baz');
observer.subscribe(publisher, 'change', handlerFn);

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment