/**
* @callback sharedStateCollectionOnUpdateCallback
* @param {SharedState} state - State that triggered the update.
* @param {Object} newValues - Key / value pairs of the updates that have been
* applied to the state.
* @param {Object} oldValues - Key / value pairs of the updated params before
* the updates has been applied to the state.
* @param {Mixed} [context=null] - Optionnal context object that has been passed
* with the values updates in the `set` call.
*/
/**
* The `SharedStateCollection` interface represent a collection of all states
* created from a given schema name on the network.
*
* It can optionnaly exclude the states created by the current node.
*
* See {@link ClientStateManager#getCollection} and
* {@link ServerStateManager#getCollection} for factory methods API
*
* ```
* const collection = await client.stateManager.getCollection('my-schema');
* const allValues = collection.getValues();
* collection.onUpdate((state, newValues, oldValues, context) => {
* // do something
* });
* ```
* @hideconstructor
*/
class SharedStateCollection {
#stateManager = null;
#schemaName = null;
#filter = null;
#options = null;
#schema = null;
#states = [];
#onUpdateCallbacks = new Set();
#onAttachCallbacks = new Set();
#onDetachCallbacks = new Set();
#unobserve = null;
constructor(stateManager, schemaName, filter = null, options = {}) {
this.#stateManager = stateManager;
this.#schemaName = schemaName;
this.#filter = filter;
this.#options = Object.assign({ excludeLocal: false }, options);
}
/** @private */
async _init() {
this.#schema = await this.#stateManager.getSchema(this.#schemaName);
// if filter is set, check that it contains only valid param names
if (this.#filter !== null) {
const keys = Object.keys(this.#schema);
for (let filter of this.#filter) {
if (!keys.includes(filter)) {
throw new ReferenceError(`[SharedStateCollection] Invalid filter key (${filter}) for schema "${this.#schemaName}"`)
}
}
}
this.#unobserve = await this.#stateManager.observe(this.#schemaName, async (schemaName, stateId) => {
const state = await this.#stateManager.attach(schemaName, stateId, this.#filter);
this.#states.push(state);
state.onDetach(() => {
const index = this.#states.indexOf(state);
this.#states.splice(index, 1);
this.#onDetachCallbacks.forEach(callback => callback(state));
});
state.onUpdate((newValues, oldValues, context) => {
Array.from(this.#onUpdateCallbacks).forEach(callback => {
callback(state, newValues, oldValues, context);
});
});
this.#onAttachCallbacks.forEach(callback => callback(state));
}, this.#options);
}
/**
* Size of the collection, alias `size`
* @type {number}
* @readonly
*/
get length() {
return this.#states.length;
}
/**
* Size of the collection, , alias `length`
* @type {number}
* @readonly
*/
get size() {
return this.#states.length;
}
/**
* Name of the schema from which the collection has been created.
* @type {String}
* @readonly
*/
get schemaName() {
return this.#schemaName;
}
/**
* Definition of schema from which the collection has been created.
*
* @param {string} [name=null] - If given, returns only the definition
* corresponding to the given param name.
* @throws Throws if `name` does not correspond to an existing field
* of the schema.
* @return {object}
* @example
* const schema = collection.getSchema();
*/
getSchema(name = null) {
if (name) {
return this.#schema[name];
}
return this.#schema;
}
/**
* Get the default values as declared in the schema.
*
* @return {object}
* @example
* const defaults = state.getDefaults();
*/
getDefaults() {
const defaults = {};
for (let name in this.#schema) {
defaults[name] = this.#schema[name].default;
}
return defaults;
}
/**
* Return the current values of all the states in the collection.
* @return {Object[]}
*/
getValues() {
return this.#states.map(state => state.getValues());
}
/**
* Return the current values of all the states in the collection.
*
* Similar to `getValues` but returns a reference to the underlying value in
* case of `any` type. May be usefull if the underlying value is big (e.g.
* sensors recordings, etc.) and deep cloning expensive. Be aware that if
* changes are made on the returned object, the state of your application will
* become inconsistent.
*
* @return {Object[]}
*/
getValuesUnsafe() {
return this.#states.map(state => state.getValues());
}
/**
* Return the current param value of all the states in the collection.
*
* @param {String} name - Name of the parameter
* @return {any[]}
*/
get(name) {
// we can delegate to the state.get(name) method for throwing in case of filtered
// keys, as the Promise.all will reject on first reject Promise
return this.#states.map(state => state.get(name));
}
/**
* Similar to `get` but returns a reference to the underlying value in case of
* `any` type. May be usefull if the underlying value is big (e.g. sensors
* recordings, etc.) and deep cloning expensive. Be aware that if changes are
* made on the returned object, the state of your application will become
* inconsistent.
*
* @param {String} name - Name of the parameter
* @return {any[]}
*/
getUnsafe(name) {
// we can delegate to the state.get(name) method for throwing in case of filtered
// keys, as the Promise.all will reject on first reject Promise
return this.#states.map(state => state.get(name));
}
/**
* Update all states of the collection with given values.
* @param {object} updates - key / value pairs of updates to apply to the state.
* @param {mixed} [context=null] - optionnal contextual object that will be propagated
* alongside the updates of the state. The context is valid only for the
* current call and will be passed as third argument to all update listeners.
*/
async set(updates, context = null) {
// we can delegate to the state.set(update) method for throwing in case of
// filtered keys, as the Promise.all will reject on first reject Promise
const promises = this.#states.map(state => state.set(updates, context));
return Promise.all(promises);
}
/**
* Subscribe to any state update of the collection.
*
* @param {sharedStateCollectionOnUpdateCallback}
* callback - Callback to execute when an update is applied on a state.
* @param {Boolean} [executeListener=false] - Execute the callback immediately
* for all underlying states with current state values. (`oldValues` will be
* set to `{}`, and `context` to `null`)
* @returns {Function} - Function that delete the registered listener.
*/
onUpdate(callback, executeListener = false) {
this.#onUpdateCallbacks.add(callback);
if (executeListener === true) {
this.#states.forEach(state => {
const currentValues = state.getValues();
const oldValues = {};
const context = null;
callback(state, currentValues, oldValues, context);
});
}
return () => this.#onUpdateCallbacks.delete(callback);
}
/**
* Register a function to execute when a state is added to the collection.
*
* @param {Function} callback - callback to execute when a state is added to
* the collection.
* @param {Function} executeListener - execute the callback with the states
* already present in the collection.
* @returns {Function} - Function that delete the registered listener.
*/
onAttach(callback, executeListener = false) {
if (executeListener === true) {
this.#states.forEach(state => callback(state));
}
this.#onAttachCallbacks.add(callback);
return () => this.#onAttachCallbacks.delete(callback);
}
/**
* Register a function to execute when a state is removed from the collection.
*
* @param {Function} callback - callback to execute when a state is removed
* from the collection.
* @returns {Function} - Function that delete the registered listener.
*/
onDetach(callback) {
this.#onDetachCallbacks.add(callback);
return () => this.#onDetachCallbacks.delete(callback);
}
/**
* Detach from the collection, i.e. detach all underlying shared states.
* @type {number}
*/
async detach() {
this.#unobserve();
this.#onUpdateCallbacks.clear();
const promises = this.#states.map(state => state.detach());
await Promise.all(promises);
this.#onDetachCallbacks.clear();
}
/**
* Execute the given function once for each states of the collection (see `Array.forEach`).
*
* @param {Function} func - A function to execute for each element in the array.
* Its return value is discarded.
*/
forEach(func) {
this.#states.forEach(func);
}
/**
* Creates a new array populated with the results of calling a provided function
* on every state of the collection (see `Array.map`).
*
* @param {Function} func - A function to execute for each element in the array.
* Its return value is added as a single element in the new array.
*/
map(func) {
return this.#states.map(func);
}
/**
* Creates a shallow copy of a portion of the collection, filtered down to just
* the estates that pass the test implemented by the provided function (see `Array.filter`).
*
* @param {Function} func - A function to execute for each element in the array.
* It should return a truthy to keep the element in the resulting array, and a f
* alsy value otherwise.
*/
filter(func) {
return this.#states.filter(func);
}
/**
* Sort the elements of the collection in place (see `Array.sort`).
*
* @param {Function} func - Function that defines the sort order.
*/
sort(func) {
this.#states.sort(func);
}
/**
* Returns the first element of the collection that satisfies the provided testing
* function. If no values satisfy the testing function, undefined is returned.
*
* @param {Function} func - Function to execute for each element in the array.
* It should return a truthy value to indicate a matching element has been found.
* @return {}
*/
find(func) {
return this.#states.find(func);
}
/**
* Iterable API, e.g. for use in `for .. of` loops
*/
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
if (index >= this.#states.length) {
return { value: undefined, done: true };
} else {
return { value: this.#states[index++], done: false };
}
},
};
}
}
export default SharedStateCollection;