common/BaseStateManager.js

import {
  isString,
  isFunction,
  isPlainObject,
} from '@ircam/sc-utils';

import SharedState from './SharedState.js';
import SharedStateCollection, {
  kSharedStateCollectionInit,
} from './SharedStateCollection.js';
import BatchedTransport from './BatchedTransport.js';
import ParameterBag from './ParameterBag.js';
import PromiseStore from './PromiseStore.js';
import {
  CREATE_REQUEST,
  CREATE_RESPONSE,
  CREATE_ERROR,
  ATTACH_REQUEST,
  ATTACH_RESPONSE,
  ATTACH_ERROR,
  OBSERVE_REQUEST,
  OBSERVE_RESPONSE,
  OBSERVE_ERROR,
  OBSERVE_NOTIFICATION,
  UNOBSERVE_NOTIFICATION,
  GET_CLASS_DESCRIPTION_REQUEST,
  GET_CLASS_DESCRIPTION_RESPONSE,
  GET_CLASS_DESCRIPTION_ERROR,
} from './constants.js';
import warnings from './logs/warnings.js';

export const kStateManagerInit = Symbol('soundworks:state-manager-init');
export const kStateManagerDeleteState = Symbol('soundworks:state-manager-delete-state');
export const kPendingSharedStateConstructionData = Symbol('soundworks:state-manager-pending-shared-state-construction-data');
// for testing purposes
export const kStateManagerClient = Symbol('soundworks:state-manager-client');


/**
 * Callback executed when a state is created on the network.
 *
 * @callback stateManagerObserveCallback
 * @async
 * @param {string} className - name of the class
 * @param {number} stateId - id of the state
 * @param {number} nodeId - id of the node that created the state
 */
/**
 * Callback to execute in order to remove a {@link stateManagerObserveCallback}
 * from the list of observer.
 *
 * @callback stateManagerDeleteObserveCallback
 */

/** @private */
class BaseStateManager {
  #statesById = new Map();
  #observeListeners = new Set(); // Set <[observedClassName, callback, options]>
  #observeRequestCallbacks = new Map(); // Map <reqId, [observedClassName, callback, options]>
  #promiseStore = null;
  #status = 'idle';

  constructor() {
    this.#promiseStore = new PromiseStore('BaseStateManager');
    /** @private */
    this[kStateManagerClient] = null;

  }

  /** @private */
  [kStateManagerDeleteState](stateId) {
    this.#statesById.delete(stateId);
  }

  /**
   * Executed on `client.init`
   * @param {Number} id - Id of the node.
   * @param {Object} transport - Transport to use for synchronizing the states.
   *  Must implement a basic EventEmitter API.
   *
   * @private
   */
  [kStateManagerInit](id, transport) {
    const batchedTransport = new BatchedTransport(transport);
    this[kStateManagerClient] = { id, transport: batchedTransport };

    this[kStateManagerClient].transport.addListener(CREATE_RESPONSE, this.#onCreateResponse);
    this[kStateManagerClient].transport.addListener(CREATE_ERROR, this.#onCreateError);

    this[kStateManagerClient].transport.addListener(ATTACH_RESPONSE, this.#onAttachResponse);
    this[kStateManagerClient].transport.addListener(ATTACH_ERROR, this.#onAttachError);

    this[kStateManagerClient].transport.addListener(OBSERVE_RESPONSE, this.#onObserveResponse);
    this[kStateManagerClient].transport.addListener(OBSERVE_ERROR, this.#onObserveError);
    this[kStateManagerClient].transport.addListener(OBSERVE_NOTIFICATION, this.#onObserveNotification);

    this[kStateManagerClient].transport.addListener(GET_CLASS_DESCRIPTION_RESPONSE, this.#onGetClassDescriptionResponse);
    this[kStateManagerClient].transport.addListener(GET_CLASS_DESCRIPTION_ERROR, this.#onGetClassDescriptionError);

    // ---------------------------------------------
    // note 2025-05-05: caching of class descriptions has been removed because it
    // could create concurrency issues
    // cf. `should be able to recreate a class with the same name` unit test
    // ---------------------------------------------
    // this[kStateManagerClient].transport.addListener(DELETE_SHARED_STATE_CLASS, _className => {});

    this.#status = 'inited';
  }

  // This pattern allows to have a clean SharedState constructor signature for  which
  // which will provide a cleaner user facing API when derived.
  // cf. instantiation pattern from AudioWorkletProcessor
  // <https://webaudio.github.io/web-audio-api/#AudioWorkletProcessor-instantiation>
  #buildSharedState(options) {
    globalThis[kPendingSharedStateConstructionData] = {
      manager: this,
      className: options.className,
      classDescription: options.classDescription,
      stateId: options.stateId,
      instanceId: options.instanceId,
      isOwner: options.isOwner,
      initValues: options.initValues,
      filter: options.filter,
    };

    const state = new SharedState();
    // cleanup global scope
    delete globalThis[kPendingSharedStateConstructionData];

    return state;
  }

  /**
   * Validates the given class description.
   *
   * @throws Throws if the class description is invalid
   * @param {*} classDescription
   * @returns undefined
   */
  validateClassDescription(classDescription) {
    return ParameterBag.validateDescription(classDescription);
  }

  /**
   * Return a class description from a given class name
   *
   * @param {SharedStateClassName} className - Name of the shared state class.
   *  (cf. ServerStateManager)
   * @return {SharedStateClassDescription}
   * @example
   * const classDescription = await client.stateManager.getClassDescription('my-class');
   */
  async getClassDescription(className) {
    if (this.#status !== 'inited') {
      throw new DOMException(`Cannot execute 'getClassDescription' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
    }

    const { id: reqId, promise } = this.#promiseStore.createPromise();
    this[kStateManagerClient].transport.emit(GET_CLASS_DESCRIPTION_REQUEST, reqId, className);

    return promise;
  }

  #onGetClassDescriptionResponse = (reqId, _className, classDescription) => {
    const fullDescription = ParameterBag.getFullDescription(classDescription);
    this.#promiseStore.resolve(reqId, fullDescription);
  };

  #onGetClassDescriptionError = (reqId, msg) => {
    msg = `Cannot execute 'getClassDescription' on BaseStateManager: ${msg}`;
    this.#promiseStore.reject(reqId, msg);
  };

  /**
   * Create a {@link SharedState} instance from a registered class.
   *
   * @param {SharedStateClassName} className - Name of the class.
   * @param {Object.<string, any>} [initValues={}] - Default values of the created shared state.
   * @returns {Promise<SharedState>}
   * @example
   * const state = await client.stateManager.create('my-class');
   */
  async create(className, initValues = {}) {
    if (this.#status !== 'inited') {
      throw new DOMException(`Cannot execute 'create' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
    }

    const { id: reqId, promise } = this.#promiseStore.createPromise();
    this[kStateManagerClient].transport.emit(CREATE_REQUEST, reqId, className, initValues);

    return promise;
  }

  #onCreateResponse = (reqId, stateId, instanceId, className, classDescription, initValues) => {
    const state = this.#buildSharedState({
      className,
      classDescription,
      stateId,
      instanceId,
      isOwner: true,
      initValues,
      filter: null, // owner cannot filter parameters
    });

    this.#statesById.set(state.id, state);
    this.#promiseStore.resolve(reqId, state);
  };

  #onCreateError = (reqId, msg) => {
    msg = `Cannot execute 'create' on BaseStateManager: ${msg}`;
    this.#promiseStore.reject(reqId, msg);
  };

  /**
   * Attach to an existing {@link SharedState} instance.
   *
   * @overload
   * @param {SharedStateClassName} className - Name of the class.
   * @returns {Promise<SharedState>}
   *
   * @example
   * const state = await client.stateManager.attach('my-class');
   */
  /**
   * Attach to an existing {@link SharedState} instance.
   *
   * @overload
   * @param {SharedStateClassName} className - Name of the class.
   * @param {number} stateId - Id of the state
   * @returns {Promise<SharedState>}
   *
   * @example
   * const state = await client.stateManager.attach('my-class', stateId);
   */
  /**
   * Attach to an existing {@link SharedState} instance.
   *
   * @overload
   * @param {SharedStateClassName} className - Name of the class.
   * @param {Object} [options={}]
   * @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
   * @param {string[]} [options.blackList] - Black list of parameter names to ignore.
   * @returns {Promise<SharedState>}
   *
   * @example
   * const state = await client.stateManager.attach('my-class', ['some-param']);
   */
  /**
   * Attach to an existing {@link SharedState} instance.
   *
   * @overload
   * @param {SharedStateClassName} className - Name of the class.
   * @param {number} stateId - Id of the state
   * @param {object} [options={}]
   * @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
   * @param {string[]} [options.blackList] - Black list of parameter names to ignore.
   * @returns {Promise<SharedState>}
   *
   * @example
   * const state = await client.stateManager.attach('my-class', stateId, ['some-param']);
   */
  /**
   * Attach to an existing {@link SharedState} instance.
   *
   * Alternative signatures:
   * - `stateManager.attach(className)`
   * - `stateManager.attach(className, stateId)`
   * - `stateManager.attach(className, options)`
   * - `stateManager.attach(className, stateId, options)`
   *
   * @param {SharedStateClassName} className - Name of the class.
   * @param {number} [stateId] - Id of the state to attach to. If `null`,
   *  attach to the first state found with the given class name (useful for
   *  globally shared states owned by the server).
   * @param {object} [options={}]
   * @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
   * @param {string[]} [options.blackList] - Black list of parameter names to ignore.
   * @returns {Promise<SharedState>}
   *
   * @example
   * const state = await client.stateManager.attach('my-class');
   */
  async attach(className, stateIdOrOptions = null, options = null) {
    if (this.#status !== 'inited') {
      throw new DOMException(`Cannot execute 'attach' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
    }

    let stateId = null;

    if (!isString(className)) {
      throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 1 must be a string`);
    }

    if (arguments.length === 2) {
      if (stateIdOrOptions === null) {
        stateId = null;
        options = null;
      } else if (Number.isFinite(stateIdOrOptions)) {
        stateId = stateIdOrOptions;
        options = null;
      } else if (Array.isArray(stateIdOrOptions)) {
        // backward compatibility for legacy filter array argument
        warnings.deprecated(
          'argument `filter: string[]` of BaseStateManager#attach',
          'argument `options { whiteList: string[] }`',
          '5.6.0',
        );
        stateId = null;
        options = { whiteList: stateIdOrOptions };
      } else if (isPlainObject(stateIdOrOptions)) {
        stateId = null;
        options = stateIdOrOptions;
      } else {
        throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 2 must be either null, a number or an object`);
      }
    }

    if (arguments.length === 3) {
      stateId = stateIdOrOptions;

      if (stateId !== null && !Number.isFinite(stateId)) {
        throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 2 must be either null or a number`);
      }

      if (options !== null) {
        if (Array.isArray(options)) {
          // backward compatibility for legacy filter array argument
          warnings.deprecated(
            'argument `filter: string[]` of BaseStateManager#attach',
            'argument `options { whiteList: string[] }`',
            '5.6.0',
          );
          options = { whiteList: options };
        } else if (!isPlainObject(options)) {
          throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 3 must be either null or an object`);
        }
        // keep option object as is
      }
    }

    const { id: reqId, promise } = this.#promiseStore.createPromise();
    this[kStateManagerClient].transport.emit(ATTACH_REQUEST, reqId, className, stateId, options);

    return promise;
  }

  #onAttachResponse = (reqId, stateId, instanceId, className, classDescription, currentValues, filter) => {
    const state = this.#buildSharedState({
      manager: this,
      className,
      classDescription,
      stateId,
      instanceId,
      isOwner: false,
      initValues: currentValues,
      filter,
    });

    this.#statesById.set(state.id, state);
    this.#promiseStore.resolve(reqId, state);
  };

  #onAttachError = (reqId, msg) => {
    msg = `Cannot execute 'attach' on BaseStateManager: ${msg}`;
    this.#promiseStore.reject(reqId, msg);
  };

  /**
   * Observe all the {@link SharedState} instances that are created on the network.
   *
   * @overload
   * @param {stateManagerObserveCallback} callback - Function to execute when a
   *   new {@link SharedState} is created on the network.
   * @example
   * client.stateManager.observe(async (className, stateId) => {
   *   if (className === 'my-shared-state-class') {
   *     const attached = await client.stateManager.attach(className, stateId);
   *   }
   * });
   */
  /**
   * Observe all the {@link SharedState} instances of given {@link SharedStateClassName}
   * that are created on the network.
   *
   * @overload
   * @param {SharedStateClassName} className - Observe only ${@link SharedState}
   *   of given name.
   * @param {stateManagerObserveCallback} callback - Function to execute when a
   *   new {@link SharedState} is created on the network.
   * @example
   * client.stateManager.observe('my-shared-state-class', async (className, stateId) => {
   *   const attached = await client.stateManager.attach(className, stateId);
   * });
   */
  /**
   * Observe all the {@link SharedState} instances of given excluding the ones
   * created by the current node.
   *
   * @overload
   * @param {stateManagerObserveCallback} callback - Function to execute when a
   *   new {@link SharedState} is created on the network.
   * @param {object} options - Options.
   * @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
   *   created by the same node from the collection.
   * @example
   * client.stateManager.observe(async (className, stateId) => {
   *   if (className === 'my-shared-state-class') {
   *     const attached = await client.stateManager.attach(className, stateId);
   *   }
   * }, { excludeLocal: true });
   */
  /**
   * Observe all the {@link SharedState} instances of given {@link SharedStateClassName}
   * that are created on the network, excluding the ones created by the current node.
   *
   * @overload
   * @param {SharedStateClassName} className - Observe only ${@link SharedState}
   *   of given name.
   * @param {stateManagerObserveCallback} callback - Function to execute when a
   *   new {@link SharedState} is created on the network.
   * @param {object} options - Options.
   * @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
   *   created by the same node from the collection.
   * @example
   * client.stateManager.observe('my-shared-state-class', async (className, stateId) => {
   *   const attached = await client.stateManager.attach(className, stateId);
   * }, { excludeLocal: true });
   */
  /**
   * Observe all the {@link SharedState} instances that are created on the network.
   *
   * Notes:
   * - The order of execution is not guaranteed between nodes, i.e. a state attached
   * in the `observe` callback can be instantiated before the `async create` method
   * resolves on the creator node.
   * - Filtering, i.e. `observedClassName` and `options.excludeLocal` are handled
   * on the node side, the server just notify all state creation activity and
   * the node executes the given callbacks according to the different filter rules.
   * Such strategy allows to simply share the observe notifications between all observers.
   *
   * Alternative signatures:
   * - `stateManager.observe(callback)`
   * - `stateManager.observe(className, callback)`
   * - `stateManager.observe(callback, options)`
   * - `stateManager.observe(className, callback, options)`
   *
   * @param {SharedStateClassName} [className] - Optional class name to filter the observed
   *  states.
   * @param {stateManagerObserveCallback}
   *  callback - Function to be called when a new state is created on the network.
   * @param {object} options - Options.
   * @param {boolean} [options.excludeLocal = false] - If set to true, exclude states
   *  created locally, i.e. by the same node, from the collection.
   * @returns {Promise<stateManagerDeleteObserveCallback>} - Returns a Promise that resolves when the given
   *  callback as been executed on each existing states. The promise value is a
   *  function which allows to stop observing the states on the network.
   *
   * @example
   * client.stateManager.observe(async (className, stateId) => {
   *   if (className === 'my-shared-state-class') {
   *     const attached = await client.stateManager.attach(className, stateId);
   *   }
   * });
   */
  async observe(...args) {
    if (this.#status !== 'inited') {
      throw new DOMException(`Cannot execute 'observe' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
    }

    const defaultOptions = {
      excludeLocal: false,
    };

    let observedClassName;
    let callback;
    let options;

    switch (args.length) {
      case 1: {
        // variation: .observe(callback)
        if (!isFunction(args[0])) {
          throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 1 should be a function`);
        }

        observedClassName = null;
        callback = args[0];
        options = defaultOptions;
        break;
      }
      case 2: {
        // variation: .observe(className, callback)
        if (isString(args[0])) {
          if (!isFunction(args[1])) {
            throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 2 should be a function`);
          }

          observedClassName = args[0];
          callback = args[1];
          options = defaultOptions;

        // variation: .observe(callback, options)
        } else if (isFunction(args[0])) {
          if (!isPlainObject(args[1])) {
            throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 2 should be an object`);
          }

          observedClassName = null;
          callback = args[0];
          options = Object.assign(defaultOptions, args[1]);

        } else {
          throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Invalid signature, refer to the documentation`);
        }

        break;
      }
      case 3: {
        // variation: .observe(className, callback, options)
        if (!isString(args[0])) {
          throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 1 should be a string`);
        }

        if (!isFunction(args[1])) {
          throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 2 should be a function`);
        }

        if (!isPlainObject(args[2])) {
          throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 3 should be an object`);
        }

        observedClassName = args[0];
        callback = args[1];
        options = Object.assign(defaultOptions, args[2]);

        break;
      }
      // throw in all other cases
      default: {
        throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Invalid signature, refer to the documentation`);
      }
    }

    // resend request to get updated list of states
    const { id: reqId, promise } = this.#promiseStore.createPromise();
    // store the callback for execution on the response. the returned Promise
    // is fulfilled once callback has been executed with each existing states
    const observeInfos = [observedClassName, callback, options];
    this.#observeRequestCallbacks.set(reqId, observeInfos);

    // NOTE: do not store in `_observeListeners` yet as it can produce race
    // conditions, e.g.:
    // ```
    // await client.stateManager.observe(async (className, stateId, nodeId) => {});
    // // client now receives OBSERVE_NOTIFICATIONS
    // await otherClient.stateManager.create('a');
    // // second observer added in between
    // client.stateManager.observe(async (className, stateId, nodeId) => {});
    // ````
    // OBSERVE_NOTIFICATION is received before the OBSERVE_RESPONSE, then the
    // second observer is called twice:
    // - OBSERVE_RESPONSE 1 []
    // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ]
    // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ] // this should not happen
    // - OBSERVE_RESPONSE 1 [ [ 'a', 1, 0 ] ]
    //
    // cf. unit test `observe should properly behave in race condition`

    this[kStateManagerClient].transport.emit(OBSERVE_REQUEST, reqId, observedClassName);

    return promise;
  }

  /** @private */
  #filterObserve(observedClassName, className, ownerId, options) {
    let filter = true;

    if (observedClassName === null || observedClassName === className) {
      filter = false;
    }
    // filter states created by this client if excludeLocal is true
    if (options.excludeLocal === true && ownerId === this[kStateManagerClient].id) {
      filter = true;
    }

    return filter;
  }

  /** @private */
  #onObserveResponse = async (reqId, ...list) => {
    // retrieve the callback that have been stored in `observe()` to make sure
    // we don't call another callback that may have been registered earlier.
    const observeInfos = this.#observeRequestCallbacks.get(reqId);
    const [observedClassName, callback, options] = observeInfos;
    // move observeInfos from `_observeRequestCallbacks` to `_observeListeners`
    // to guarantee future order of execution
    this.#observeRequestCallbacks.delete(reqId);
    this.#observeListeners.add(observeInfos);

    const promises = list.map(([className, stateId, ownerId]) => {
      const filter = this.#filterObserve(observedClassName, className, ownerId, options);

      if (!filter) {
        return callback(className, stateId, ownerId);
      } else {
        return Promise.resolve();
      }
    });

    await Promise.all(promises);

    const unsubscribe = () => {
      this.#observeListeners.delete(observeInfos);
      // no more listeners, we can stop receiving notifications from the server
      if (this.#observeListeners.size === 0) {
        this[kStateManagerClient].transport.emit(UNOBSERVE_NOTIFICATION);
      }
    };

    this.#promiseStore.resolve(reqId, unsubscribe);
  };

  /** @private */
  #onObserveError = (reqId, msg) => {
    msg = `Cannot execute 'observe' on BaseStateManager: ${msg}`;
    this.#observeRequestCallbacks.delete(reqId);
    this.#promiseStore.reject(reqId, msg);
  };

  /** @private */
  #onObserveNotification = (className, stateId, ownerId) => {
    this.#observeListeners.forEach(observeInfos => {
      const [observedClassName, callback, options] = observeInfos;
      const filter = this.#filterObserve(observedClassName, className, ownerId, options);

      if (!filter) {
        callback(className, stateId, ownerId);
      }
    });
  };

  /**
   * Returns a collection of all the states created from a given shared state class.
   *
   * @overload
   * @param {SharedStateClassName} className - Name of the shared state class.
   * @returns {Promise<SharedStateCollection>}
   *
   * @example
   * const collection = await client.stateManager.getCollection(className);
   */
  /**
   * Returns a collection of all the states created from a given shared state class.
   *
   * @overload
   * @param {SharedStateClassName} className - Name of the shared state class.
   * @param {object} [options={}] - Options.
   * @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
   *  created by the same node from the collection.
   * @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
   * @param {string[]} [options.blackList] - Black list of parameter names to ignore.
   * @returns {Promise<SharedStateCollection>}
   *
   * @example
   * const collection = await client.stateManager.getCollection(className, {
   *   blackList: ['my-param'],
   *   excludeLocal: true,
   * });
   */
  /**
   * Returns a collection of all the states created from a given shared state class.
   *
   * Alternative signatures:
   * - `stateManager.getCollection(className)`
   * - `stateManager.getCollection(className, options)`
   *
   * @param {SharedStateClassName} className - Name of the shared state class.
   * @param {object} [options={}] - Options.
   * @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
   *  created by the same node from the collection.
   * @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
   * @param {string[]} [options.blackList] - Black list of parameter names to ignore.
   * @returns {Promise<SharedStateCollection>}
   *
   * @example
   * const collection = await client.stateManager.getCollection(className);
   */
  async getCollection(className, options = {}) {
    if (this.#status !== 'inited') {
      throw new DOMException(`Cannot execute 'getCollection' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
    }

    if (!isString(className)) {
      throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 1 should be a string"`);
    }

    if (arguments.length === 2) {
      if (Array.isArray(options)) {
        // backward compatibility for legacy filter array argument
        warnings.deprecated(
          'argument `filter: string[]` of BaseStateManager#getCollection',
          'argument `options { whiteList: string[] }`',
          '5.6.0',
        );

        options = { whiteList: options };
      } else if (!isPlainObject(options)) {
        throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 2 should be either null or an object"`);
      }
    }

    // backward compatibility for legacy filter array argument
    if (arguments.length === 3) {
      // backward compatibility for legacy filter array argument
      warnings.deprecated(
        'argument `filter: string[]` of BaseStateManager#getCollection',
        'argument `options { whiteList: string[] }`',
        '5.6.0',
      );

      const filter = options;
      options = arguments[2];

      if (filter !== null && !Array.isArray(filter)) {
        throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 2 should be either an array or null"`);
      }

      if (options === null || !isPlainObject(options)) {
        throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 3 should be either an object"`);
      }

      options.whiteList = filter;
    }

    const collection = new SharedStateCollection(this, className, options);

    try {
      await collection[kSharedStateCollectionInit]();
    } catch (err) {
      throw new ReferenceError(`Cannot execute 'getCollection' on BaseStateManager: ${err.message}`);
    }

    return collection;
  }

  // ---------------------------------------------------------------------------
  // DEPRECATED
  // ---------------------------------------------------------------------------
  /**
   * @deprecated Use {@link BaseStateManager#getClassDescription} instead.
   */
  async getSchema(className) {
    warnings.deprecated('BaseStateManager#getSchema', 'BaseStateManager#getClassDescription', '4.0.0-alpha.29');
    return this.getClassDescription(className);
  }
}

export default BaseStateManager;