import {
isPlainObject,
isString,
isFunction,
} from '@ircam/sc-utils';
import ParameterBag from './ParameterBag.js';
import PromiseStore from './PromiseStore.js';
import {
DELETE_REQUEST,
DELETE_RESPONSE,
DELETE_ERROR,
DELETE_NOTIFICATION,
DETACH_REQUEST,
DETACH_RESPONSE,
DETACH_ERROR,
UPDATE_REQUEST,
UPDATE_RESPONSE,
UPDATE_ABORT,
UPDATE_NOTIFICATION,
HAS_SIBLINGS_NOTIFICATION,
} from './constants.js';
import {
kStateManagerClient,
kStateManagerDeleteState,
kPendingSharedStateConstructionData,
} from './BaseStateManager.js';
import {
sanitizeOnUpdateParams,
} from './shared-state-utils.js';
import warnings from './logs/warnings.js';
// for testing purposes
export const kSharedStatePromiseStore = Symbol('soundworks:shared-state-promise-store');
/**
* Callback executed when updates are applied on a {@link SharedState}.
*
* @callback sharedStateOnUpdateCallback
* @param {Object|any} newValues - Key / value pairs of the updates that have been
* applied to the state, or single value if the callback has been registered against
* a parameter name filter.
* @param {Object|any} oldValues - Key / value pairs of the updated params before
* the updates has been applied to the state, or single value if the callback has been
* registered against a parameter name filter.
* @param {sharedStateDeleteOnUpdateCallback} unsubscribe - Reference to unsubscribe
* function returned by the `SharedState#onUpdate` method.
*/
/**
* Delete the registered {@link sharedStateOnUpdateCallback}.
*
* @callback sharedStateDeleteOnUpdateCallback
*/
/**
* The `SharedState` is one of the most important and versatile abstraction provided
* by `soundworks`. It represents a set of parameters that are synchronized across
* every nodes of the application (clients and server) that declared some interest
* to the shared state.
*
* A `SharedState` instance is created according to a shared state class definition
* which is composed of a {@link SharedStateClassName} and of a {@link SharedStateClassDescription}
* registered in the {@link ServerStateManager}. Any number of `SharedState`s
* can be created from a single class definition.
*
* A shared state can be created both by the clients or by the server, in which case
* it is generally considered as a global state of the application. Similarly any
* node of the application (clients or server) can declare interest and "attach" to
* a state created by another node. All node attached to a state can modify its values
* and/or react to the modifications applied by other nodes.
*
* Tutorial: {@link https://soundworks.dev/guide/state-manager.html}
*
* ```
* // server-side
* import { Server } from '@soundworks/server/index.js';
*
* const server = new Server(config);
* // define a shared state class
* server.stateManager.defineClass('some-global-state', {
* myRandom: {
* type: 'float',
* default: 0,
* }
* });
*
* await server.start();
*
* // create a global state server-side
* const globalState = await server.stateManager.create('some-global-state');
* // listen and react to the changes made by the clients
* globalState.onUpdate(updates => console.log(updates));
* ```
*
* ```
* // client-side
* import { Client } from '@soundworks/client.index.js';
*
* const client = new Client(config);
* await client.start();
*
* // attach to the global state created by the server
* const globalState = await client.stateManager.attach('some-global-state');
*
* // update the value of a `myRandom` parameter every seconds
* setInterval(() => {
* globalState.set({ myRandom: Math.random() });
* }, 1000);
* ```
*/
class SharedState {
#id = null;
#instanceId = null;
#className = null;
#isOwner = null;
#client = null;
#manager = null;
#filter = null;
#detached = false;
#hasSiblings = false;
#parameters = null;
#onUpdateCallbacks = new Set();
#onDetachCallbacks = new Set();
#onDeleteCallbacks = new Set();
#waitForConditions = new Set();
constructor() {
// cf. BaseStateManager#buildSharedState to get the whole pattern
// rationale is to have a clean constructor API for future derived classes
this.#manager = globalThis[kPendingSharedStateConstructionData].manager;
this.#client = globalThis[kPendingSharedStateConstructionData].manager[kStateManagerClient];
this.#className = globalThis[kPendingSharedStateConstructionData].className;
this.#id = globalThis[kPendingSharedStateConstructionData].stateId;
this.#instanceId =globalThis[kPendingSharedStateConstructionData]. instanceId;
this.#isOwner = globalThis[kPendingSharedStateConstructionData].isOwner; // may be any node
this.#filter = globalThis[kPendingSharedStateConstructionData].filter;
try {
this.#parameters = new ParameterBag(
globalThis[kPendingSharedStateConstructionData].classDescription,
globalThis[kPendingSharedStateConstructionData].initValues,
);
} catch (err) {
throw new Error(`Cannot construct 'SharedState' (${this.#className}): ${err.message}`);
}
/** @private */
this[kSharedStatePromiseStore] = new PromiseStore(this.constructor.name);
this.#client.transport.addListener(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onUpdateResponse);
this.#client.transport.addListener(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`, this.#onUpdateAbort);
this.#client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onUpdateNotification);
this.#client.transport.addListener(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onDeleteNotification);
if (this.#isOwner) {
this.#client.transport.addListener(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDeleteResponse);
this.#client.transport.addListener(`${DELETE_ERROR}-${this.#id}`, this.#onDeleteError);
this.#client.transport.addListener(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onHasSiblingsNotification);
} else {
this.#client.transport.addListener(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDetachResponse);
this.#client.transport.addListener(`${DETACH_ERROR}-${this.#id}`, this.#onDetachError);
}
}
#cleanup() {
this.#detached = true;
this.#manager[kStateManagerDeleteState](this.#id);
this.#client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`);
this.#client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
this.#client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`);
this.#client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
if (this.#isOwner) {
this.#client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`);
this.#client.transport.removeAllListeners(`${DELETE_ERROR}-${this.#id}-${this.#instanceId}`);
this.#client.transport.removeAllListeners(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
} else {
this.#client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`);
this.#client.transport.removeAllListeners(`${DETACH_ERROR}-${this.#id}-${this.#instanceId}`);
}
}
#onUpdateResponse = async (reqId, updates) => {
const updated = await this.#commit(updates, true, true);
this[kSharedStatePromiseStore].resolve(reqId, updated);
this.#checkPendingWaitForConditions();
};
#onUpdateAbort = async (reqId, updates) => {
const updated = await this.#commit(updates, false, true);
this[kSharedStatePromiseStore].resolve(reqId, updated);
this.#checkPendingWaitForConditions();
};
#onUpdateNotification = updates => {
this.#commit(updates, true, false);
this.#checkPendingWaitForConditions();
};
#onDetachResponse = async (reqId) => {
this.#cleanup();
for (let callback of this.#onDetachCallbacks) {
try {
await callback();
} catch (err) {
console.log(err.message);
}
}
this.#onDetachCallbacks.clear();
this.#onDeleteCallbacks.clear();
this[kSharedStatePromiseStore].resolve(reqId, this);
this[kSharedStatePromiseStore].flush();
};
#onDetachError = (reqId, msg) => {
this.#onDetachCallbacks.clear();
this.#onDeleteCallbacks.clear();
this[kSharedStatePromiseStore].reject(reqId, msg);
this[kSharedStatePromiseStore].flush();
};
#onDeleteResponse = async (reqId) => {
this.#cleanup();
for (let callback of this.#onDetachCallbacks) {
try {
await callback();
} catch (err) {
console.log(err.message);
}
}
for (let callback of this.#onDeleteCallbacks) {
try {
await callback();
} catch (err) {
console.log(err.message);
}
}
this.#onDetachCallbacks.clear();
this.#onDeleteCallbacks.clear();
this[kSharedStatePromiseStore].resolve(reqId, this);
this[kSharedStatePromiseStore].flush();
};
#onDeleteError = (reqId, msg) => {
this[kSharedStatePromiseStore].reject(reqId, msg);
};
#onDeleteNotification = async () => {
this.#cleanup();
for (let callback of this.#onDetachCallbacks) {
try {
await callback();
} catch (err) {
console.error(err.message);
}
}
if (this.#isOwner) {
for (let callback of this.#onDeleteCallbacks) {
await callback();
}
}
this.#onDetachCallbacks.clear();
this.#onDeleteCallbacks.clear();
this[kSharedStatePromiseStore].flush();
};
#onHasSiblingsNotification = hasSiblings => {
this.#hasSiblings = hasSiblings;
};
#checkParamNameAgainstFilters(paramName) {
if (this.#filter === null) {
return;
}
const { whiteList, blackList } = this.#filter;
if (whiteList && !whiteList.includes(paramName)) {
throw new Error(`Parameter '${paramName}' is not in white list`);
} else if (blackList && blackList.includes(paramName)) {
throw new Error(`Parameter '${paramName}' is in black list`);
}
}
#sanitizeValuesAgainstFilters(values) {
if (this.#filter === null) {
return values;
}
const { whiteList, blackList } = this.#filter;
for (let name in values) {
if (whiteList && !whiteList.includes(name)) {
delete values[name];
} else if (blackList && blackList.includes(name)) {
delete values[name];
}
}
return values;
}
async #commit(updates, propagate = true, initiator = false) {
const newValues = {};
const oldValues = {};
for (let name in updates) {
const { immediate, event } = this.#parameters.getDescription(name);
// @note 20211209 - we had an issue here server-side, because if the value
// is an object or an array, the reference is shared by everybody, therefore
// `changed` is always false and the new value is never propagated...
// FIXED - `state.get` now returns a deep copy when `type` is `any`
const oldValue = this.#parameters.get(name);
const [newValue, changed] = this.#parameters.set(name, updates[name]);
// handle immediate stuff
if (initiator && immediate) {
// @note - we don't need to check filterChange here because the value
// has been updated in parameters on the `set` side so can rely on `changed`
// to avoid retrigger listeners.
// If the value has been overridden by the server, `changed` will true
// anyway so it should behave correctly.
if (!changed || event) {
continue;
}
}
newValues[name] = newValue;
oldValues[name] = oldValue;
}
// do not propagate if the `UPDATE_REQUEST` as been aborted by the server.
if (propagate && Object.keys(newValues).length > 0) {
const callbackPromises = this.#executeUpdateCallbacks(newValues, oldValues);
// on a given client, `await state.set(update)` resolves after all
// update callbacks have themselves resolved
await Promise.all(callbackPromises);
}
// reset events to null after propagation of all listeners
for (let name in newValues) {
const { event } = this.#parameters.getDescription(name);
if (event) {
this.#parameters.set(name, null);
}
}
return newValues;
}
#executeUpdateCallbacks(newValues, oldValues) {
let promises = [];
this.#onUpdateCallbacks.forEach(listenerPayload => {
let somePromise = this.#executeUpdateCallback(listenerPayload, newValues, oldValues);
promises.push(somePromise);
});
return promises;
}
#executeUpdateCallback(listenerPayload, newValues, oldValues) {
const { paramName, listener, unsubscribe } = listenerPayload;
if (paramName === null) {
return listener(newValues, oldValues, unsubscribe);
} else if (paramName in newValues) {
return listener(newValues[paramName], oldValues[paramName], unsubscribe);
}
}
#checkPendingWaitForConditions() {
const values = this.getValues();
this.#waitForConditions.forEach(payload => {
const { resolve, condition, timeoutId } = payload;
let match;
if (isFunction(condition)) {
match = condition(values);
} else {
match = true;
for (let [key, value] of Object.entries(condition)) {
if (values[key] !== value) {
match = false;
}
}
}
if (match === true) {
clearTimeout(timeoutId);
this.#waitForConditions.delete(payload);
resolve();
}
});
}
/**
* Id of the state
* @type {Number}
*/
get id() {
return this.#id;
}
/**
* Name of the underlying {@link SharedState} class.
* @type {String}
*/
get className() {
return this.#className;
}
/**
* @deprecated Use {@link SharedState#className} instead.
*/
get schemaName() {
warnings.deprecated('SharedState#schemaName', 'SharedState#className', '4.0.0-alpha.29');
return this.className;
}
/**
* Indicates if the node is the owner of the state, i.e. if it created the state.
* @type {Boolean}
*/
get isOwner() {
return this.#isOwner;
}
/**
* Indicates if the node as siblings, i.e. if several versions of the same state
* exists on the network
* Always return true if the state instance is attached
* @type {Boolean}
*/
get hasSiblings() {
if (this.#detached) {
return false;
}
if (!this.isOwner) {
return true;
}
return this.#hasSiblings;
}
/**
* Return the underlying {@link SharedStateClassDescription} or the
* {@link SharedStateParameterDescription} if `paramName` is given.
*
* @param {string} [paramName=null] - If defined, returns the parameter
* description of the given parameter name rather than the full class description.
* @return {SharedStateClassDescription|SharedStateParameterDescription}
* @throws Throws if `paramName` is not null and does not exists.
* @example
* const classDescription = state.getDescription();
* const paramDescription = state.getDescription('my-param');
*/
getDescription(paramName = null) {
try {
return this.#parameters.getDescription(paramName);
} catch (err) {
throw new ReferenceError(`Cannot execute 'getDescription' on SharedState: ${err.message}`);
}
}
/**
* Update the values of the state.
*
* The returned `Promise` resolves on an object that contains the applied updates,
* and resolves after all the `onUpdate` callbacks have resolved themselves
*
* @overload
* @param {object} updates - Key / value pairs of updates to apply to the state.
* @returns {Promise<Object>} - Promise to the (coerced) updates.
*/
/**
* Update the values of the state.
*
* The returned `Promise` resolves on an object that contains the applied updates,
* and resolves after all the `onUpdate` callbacks have resolved themselves
*
* @overload
* @param {SharedStateParameterName} name - Name of the parameter.
* @param {*} value - Value of the parameter.
* @returns {Promise<Object>} - Promise to the (coerced) updates.
*/
/**
* Update the values of the state.
*
* The returned `Promise` resolves on an object that contains the applied updates,
* and resolves after all the `onUpdate` callbacks have resolved themselves, i.e.:
* ```js
* server.stateManager.defineClass('test', {
* myBool: { type: 'boolean', default: false },
* });
* const a = await server.stateManager.create('a');
*
* let asyncCallbackCalled = false;
*
* a.onUpdate(updates => {
* return new Promise(resolve => {
* setTimeout(() => {
* asyncCallbackCalled = true;
* resolve();
* }, 100);
* });
* });
*
* const updates = await a.set({ myBool: true });
* assert.equal(asyncCallbackCalled, true);
* assert.deepEqual(updates, { myBool: true });
* ```
*
* Alternative signatures:
* - `await state.set(updates)`
* - `await state.set(name, value)`
*
* @param {object} updates - Key / value pairs of updates to apply to the state.
* @returns {Promise<Object>} - Promise to the (coerced) updates.
*
* @example
* const state = await client.stateManager.attach('globals');
* const updates = await state.set({ myParam: Math.random() });
*/
async set(updates) {
if (this.#detached) {
return;
}
if (isPlainObject(arguments[0]) && isPlainObject(arguments[1])) {
warnings.removed('`context` argument in SharedState.set(updates, context)', 'a regular parameter configured with `event=true`', '4.0.0-alpha.29');
}
if (arguments.length === 2 && isString(updates)) {
updates = { [updates]: arguments[1] };
} else if (!isPlainObject(updates)) {
throw new TypeError(`Cannot execute 'set' on SharedState (${this.#className}): 'updates' argument should be an object`);
}
const newValues = {};
const oldValues = {};
const locallyResolvedParam = {};
const networkPropagatedParams = {};
let hasLocalParam = false;
let hasRegularSharedParam = false;
let hasNonAcknowledgedParams = false;
let syncedOnUpdate = false;
for (let name in updates) {
// Throw early if some param is blacklisted or not whitelisted
try {
this.#checkParamNameAgainstFilters(name);
} catch (err) {
throw new DOMException(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
}
// Try to coerce value now, so that eventual errors are triggered early
// on the node requesting the update and not only on the server side.
try {
// This also throws if name does not exists
this.#parameters.coerceValue(name, updates[name]);
} catch (err) {
throw new TypeError(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`);
}
// ### `immediate` modifier behavior
//
// If immediate=true
// - call listeners if value changed
// - go through normal server path
// - retrigger only if response from server is different from current value
// If immediate=true && (filterChange=false || event=true)
// - call listeners with value regardless it changed
// - go through normal server path
// - if the node is initiator of the update (UPDATE_RESPONSE), (re-)check
// to prevent execute the listeners twice
// ### `local` modifier behavior
//
// - If the `updates` object only contains local variables, we can call the
// update listeners and return a fulfilled promise immediately
// - If parameters that require network communication are present, we call the
// update callback onces with the local payload, then we need to wait for the server
// response, call update listeners with server response and resolve promise with
// the full payload, i.e. reintegrating the local params in the resolve payload
// ### `acknowledge` modifier behavior
//
// Same as immediate, but no feedback from server
const {
local,
immediate,
filterChange,
event,
acknowledge,
} = this.#parameters.getDescription(name);
if (immediate || local || acknowledge === false) {
const oldValue = this.#parameters.get(name);
const [newValue, changed] = this.#parameters.set(name, updates[name]);
// prepare data for immediate propagation of listeners
if (changed || filterChange === false || event) {
oldValues[name] = oldValue;
newValues[name] = newValue;
syncedOnUpdate = true;
}
}
if (local) {
// these don't go to the network
hasLocalParam = true;
locallyResolvedParam[name] = this.#parameters.get(name);
} else if (acknowledge === false) {
// when acknowledge is false, e.g. streaming values, values are
// propagated on the network only if hasSiblings is true
hasNonAcknowledgedParams = true;
locallyResolvedParam[name] = this.#parameters.get(name);
// propagate only if there is an attached state somewhere
if (this.hasSiblings) {
networkPropagatedParams[name] = updates[name];
}
} else {
// note that immediate params are shared and acknowledged
hasRegularSharedParam = true;
networkPropagatedParams[name] = updates[name];
}
}
// params that trigger a synced onUpdate call are: immediate, local, non-acknowledged
if (syncedOnUpdate) {
this.#executeUpdateCallbacks(newValues, oldValues);
}
// if we only have local params, we can resolve immediately
if (hasLocalParam && !hasNonAcknowledgedParams && !hasRegularSharedParam) {
return Promise.resolve(locallyResolvedParam);
}
// go through server-side normal behavior
const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
// store local params that should be re-merged w/ regular ones
if (Object.keys(locallyResolvedParam).length > 0) {
this[kSharedStatePromiseStore].associateResolveData(reqId, locallyResolvedParam);
}
if (Object.keys(networkPropagatedParams).length > 0) {
this.#client.transport.emit(`${UPDATE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId, networkPropagatedParams);
}
// if we don't have regular shared params, we can resolve now
if (!hasRegularSharedParam) {
this[kSharedStatePromiseStore].resolve(reqId, {});
}
return promise;
}
/**
* Get the value of a parameter of the state.
*
* Be aware that in case of 'any' type, the returned value is deeply copied.
* While this prevents from pollution of the state by mutating the reference,
* this can also lead to performance issues when the parameter contains large
* data. In such cases you should use the {@link SharedState#getUnsafe} method
* and make sure to treat the returned object as readonly.
*
* @param {SharedStateParameterName} name - Name of the param.
* @return {any}
* @throws Throws if `name` does not exists.
* @example
* const value = state.get('paramName');
*/
get(name) {
if (!this.#parameters.has(name)) {
throw new ReferenceError(`Cannot execute 'get' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
}
// Throw early if some param is blacklisted or not whitelisted
try {
this.#checkParamNameAgainstFilters(name);
} catch (err) {
throw new DOMException(`Cannot execute 'get' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
}
return this.#parameters.get(name);
}
/**
* Get an unsafe reference to the value of a parameter of the state.
*
* Similar to `get` but returns a reference to the underlying value in case of
* `any` type. Can be useful if the underlying value is large (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 {SharedStateParameterName} name - Name of the param.
* @return {any}
* @throws Throws if `name` does not exists.
* @example
* const value = state.getUnsafe('paramName');
*/
getUnsafe(name) {
if (!this.#parameters.has(name)) {
throw new ReferenceError(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
}
// Throw early if some param is blacklisted or not whitelisted
try {
this.#checkParamNameAgainstFilters(name);
} catch (err) {
throw new DOMException(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
}
return this.#parameters.getUnsafe(name);
}
/**
* Get all the key / value pairs of the state.
*
* If a parameter is of `any` type, a deep copy is made.
*
* @return {object}
* @example
* const values = state.getValues();
*/
getValues() {
const values = this.#parameters.getValues();
return this.#sanitizeValuesAgainstFilters(values);
}
/**
* Get all the key / value pairs of the state.
*
* Similar to `getValues` but returns a reference to the underlying value in
* case of `any` type. Can be useful 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}
* @example
* const values = state.getValues();
*/
getValuesUnsafe() {
const values = this.#parameters.getValuesUnsafe();
return this.#sanitizeValuesAgainstFilters(values);
}
/**
* Get the values with which the state has been created. May defer from the
* default values declared in the class description.
*
* @return {object}
* @example
* const initValues = state.getInitValues();
*/
getInitValues() {
return this.#parameters.getInitValues();
}
/**
* Get the default values as declared in the class description.
*
* @return {object}
* @example
* const defaults = state.getDefaults();
*/
getDefaults() {
return this.#parameters.getDefaults();
}
/**
* Detach from the state. If the client is the creator of the state, the state
* is deleted and all attached nodes get notified.
*
* @example
* const state = await client.state.attach('globals');
* // later
* await state.detach();
*/
async detach() {
if (this.#detached) {
throw new DOMException(`Cannot execute 'detach' on SharedState (${this.#className}): SharedState (${this.#id}) already detached`, 'NotSupportedError');
}
this.#detached = true; // mark detached early
this.#onUpdateCallbacks.clear();
if (this.#isOwner) {
const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
this.#client.transport.emit(`${DELETE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
return promise;
} else {
const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
this.#client.transport.emit(`${DETACH_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
return promise;
}
}
/**
* Delete the state. Only the creator/owner of the state can use this method.
*
* All nodes attached to the state will be detached, triggering any registered
* `onDetach` callbacks. The creator of the state will also have its own `onDelete`
* callback triggered. The local `onDetach` and `onDelete` callbacks will be
* executed *before* the returned Promise resolves
*
* @throws Throws if the method is called by a node which is not the owner of
* the state.
* @example
* const state = await client.stateManager.create('my-class-name');
* // later
* await state.delete();
*/
async delete() {
if (this.#isOwner) {
if (this.#detached) {
throw new DOMException(`Cannot execute 'delete' on SharedState (${this.#className}): SharedState (${this.#id}) already deleted`, 'NotSupportedError');
}
return this.detach();
} else {
throw new DOMException(`Cannot execute 'delete' on SharedState (${this.#className}): This instance is attached and not owned. Use 'SharedState#detach' instead`, 'NotSupportedError');
}
}
/**
* Subscribe to any updates in the state.
*
* @overload
* @param {sharedStateOnUpdateCallback} callback
* Callback to execute when an update is applied on the state.
* @param {Boolean} [executeListener=false] - Execute the callback immediately
* with current state values. Note that `oldValues` will be set to `{}`.
* @returns {sharedStateDeleteOnUpdateCallback}
* @example
* state.onUpdate(updates => {
* // ...
* });
*/
/**
* Subscribe to updates in the state filtered by a given parameter name.
*
* @overload
* @param {SharedStateParameterName} paramName
* @param {sharedStateOnUpdateCallback} callback
* Callback to execute when an update is applied on the state.
* @param {Boolean} [executeListener=false] - Execute the callback immediately
* with current state values. Note that `oldValues` will be set to `{}`.
* @returns {sharedStateDeleteOnUpdateCallback}
* @example
* state.onUpdate('my-param', myValue => {
* // ...
* });
*/
/**
* Subscribe to state updates.
*
* Alternative signatures:
* - `state.onUpdate(callback, executeListener)`
* - `state.onUpdate(paramName, callback, executeListener)`
*
* @param {SharedStateParameterName} paramName
* @param {sharedStateOnUpdateCallback} callback
* Callback to execute when an update is applied on the state.
* @param {Boolean} [executeListener=false] - Execute the callback immediately
* with current state values. Note that `oldValues` will be set to `{}`.
* @returns {sharedStateDeleteOnUpdateCallback}
* @example
* const unsubscribe = state.onUpdate(async (newValues, oldValues) => {
* for (let [key, value] of Object.entries(newValues)) {
* switch (key) {
* // do something
* }
* }
* });
*
* // later
* unsubscribe();
*/
onUpdate(...args) {
const { paramName, listener, executeListener } = sanitizeOnUpdateParams(this, ...args);
const listenerPayload = { paramName, listener };
this.#onUpdateCallbacks.add(listenerPayload);
// create unsubscribe and add to payload, to pass it back to the listeners as argument
listenerPayload.unsubscribe = () => this.#onUpdateCallbacks.delete(listenerPayload);
if (executeListener === true) {
const currentValues = this.getValues();
// filter `event: true` parameters from currentValues, having them here is
// misleading as we are in the context of a callback, not from an active read
const classDescription = this.getDescription();
for (let name in classDescription) {
if (classDescription[name].event === true) {
delete currentValues[name];
}
}
this.#executeUpdateCallback(listenerPayload, currentValues, {});
}
return listenerPayload.unsubscribe;
}
/**
* Register a function to execute when detaching from the state. The function
* will be executed before the `detach` promise resolves.
*
* @param {Function} callback - Callback to execute when detaching from the state.
* Whether the client as called `detach`, or the state has been deleted by its
* creator.
*/
onDetach(callback) {
this.#onDetachCallbacks.add(callback);
return () => this.#onDetachCallbacks.delete(callback);
}
/**
* Register a function to execute when the state is deleted. Only called if the
* node was the creator of the state. Is called after `onDetach` and executed
* before the `delete` Promise resolves.
*
* @param {Function} callback - Callback to execute when the state is deleted.
*/
onDelete(callback) {
if (!this.#isOwner) {
throw new DOMException(`Cannot execute 'onDelete' on SharedState (${this.#className}): This instance is attached and not owned. Use 'SharedState#onDetach' instead`, 'NotSupportedError');
}
this.#onDeleteCallbacks.add(callback);
return () => this.#onDeleteCallbacks.delete(callback);
}
/**
* Wait for a given condition in the state of the SharedState instance.
* If the state watches the condition when `waitFor` is called, the promise is
* resolved immediately.
*
* @note - mark as private until tested in real-world
*
* @private
* @param {object} condition - Condition to be meet in the state for the
* returned promise to resolve. The condition is either a function which returns
* `true` when the condition is met or an object that describe a desired state
* of a subset the `SharedState` values.
* @param {object} timeout - Timeout (in milliseconds) that trigger the rejection
* of the returned promise.
* @return {Promise}
*/
waitFor(condition, timeout = null) {
if (!isPlainObject(condition) && !isFunction(condition)) {
throw new TypeError(`Cannot execute 'waitFor' on SharedState: argument 0 must be either a function or an object`);
}
if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new TypeError(`Cannot execute 'waitFor' on SharedState: optional argument 1 must be a finite strictly positive number`);
}
// @todo - check condition keys are not filtered, this could never resolve
const descriptionKeys = Object.keys(this.getDescription());
const conditionKeys = Object.keys(condition);
const diff = conditionKeys.filter(key => !descriptionKeys.includes(key));
if (diff.length > 0) {
throw new ReferenceError(`Cannot execute 'waitFor' on SharedState: condition contains keys (${diff.join(', ')}) that are not declared in class description`);
}
const { promise, resolve, reject } = Promise.withResolvers();
const payload = { resolve, condition };
if (timeout !== null) {
payload.timeoutId = setTimeout(() => {
this.#waitForConditions.delete(payload);
reject();
}, timeout);
}
this.#waitForConditions.add(payload);
this.#checkPendingWaitForConditions();
return promise;
}
// ---------------------------------------------------------------------------
// DEPRECATED
// ---------------------------------------------------------------------------
/**
* @deprecated Use {@link SharedState#getDescription} instead.
*/
getSchema(paramName = null) {
warnings.deprecated('SharedState#getSchema', 'SharedState#getDescription', '4.0.0-alpha.29');
return this.getDescription(paramName);
}
}
export default SharedState;