@soundworks/core 3.0.4 → 3.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,9 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.default = void 0;
7
7
 
8
- var _parameters = _interopRequireDefault(require("@ircam/parameters"));
9
-
10
- var _lodash = _interopRequireDefault(require("lodash.clonedeep"));
8
+ var _ParameterBag = _interopRequireDefault(require("./ParameterBag.js"));
11
9
 
12
10
  var _sharedStateUtils = require("./shared-state-utils.js");
13
11
 
@@ -27,18 +25,17 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
27
25
  * @see {server.SharedStateManagerServer}
28
26
  */
29
27
  class SharedState {
30
- constructor(id, remoteId, schemaName, schema, client, isCreator, manager, initValues = {}) {
28
+ constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
31
29
  this.id = id;
32
30
  this.remoteId = remoteId;
33
31
  this.schemaName = schemaName;
34
- this._schema = (0, _lodash.default)(schema);
35
- this._isCreator = isCreator; // may be the server or any client
32
+ this._isOwner = isOwner; // may be the server or any client
36
33
 
37
34
  this._client = client;
38
35
  this._manager = manager;
39
36
 
40
37
  try {
41
- this._parameters = (0, _parameters.default)(schema, initValues);
38
+ this._parameters = new _ParameterBag.default(schema, initValues);
42
39
  } catch (err) {
43
40
  console.error(err.stack);
44
41
  throw new Error(`Error creating or attaching state "${schemaName}" w/ values:\n
@@ -49,20 +46,18 @@ ${JSON.stringify(initValues, null, 2)}`);
49
46
  this._onDetachCallbacks = new Set();
50
47
  this._onDeleteCallbacks = new Set(); // add listener for state updates
51
48
 
52
- client.transport.addListener(`${_sharedStateUtils.UPDATE_RESPONSE}-${id}-${this.remoteId}`, (reqId, updates) => {
53
- const updated = this._commit(updates);
54
-
49
+ client.transport.addListener(`${_sharedStateUtils.UPDATE_RESPONSE}-${id}-${this.remoteId}`, async (reqId, updates, context) => {
50
+ const updated = await this._commit(updates, context, true, true);
55
51
  (0, _sharedStateUtils.resolveRequest)(reqId, updated);
56
52
  }); // retrieve values but do not propagate to subscriptions
57
53
 
58
- client.transport.addListener(`${_sharedStateUtils.UPDATE_ABORT}-${id}-${this.remoteId}`, (reqId, updates) => {
59
- const updated = this._commit(updates, false);
60
-
54
+ client.transport.addListener(`${_sharedStateUtils.UPDATE_ABORT}-${id}-${this.remoteId}`, async (reqId, updates, context) => {
55
+ const updated = await this._commit(updates, context, false, true);
61
56
  (0, _sharedStateUtils.resolveRequest)(reqId, updated);
62
57
  });
63
- client.transport.addListener(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${id}-${this.remoteId}`, updates => {
58
+ client.transport.addListener(`${_sharedStateUtils.UPDATE_NOTIFICATION}-${id}-${this.remoteId}`, async (updates, context) => {
64
59
  // cf. https://github.com/collective-soundworks/soundworks/issues/18
65
- this._commit(updates);
60
+ await this._commit(updates, context, true, false);
66
61
  }); // ---------------------------------------------
67
62
  // DELETE initiated by creator, or schema deleted
68
63
  // ---------------------------------------------
@@ -79,7 +74,7 @@ ${JSON.stringify(initValues, null, 2)}`);
79
74
  this._clearDetach();
80
75
  });
81
76
 
82
- if (this._isCreator) {
77
+ if (this._isOwner) {
83
78
  // ---------------------------------------------
84
79
  // DELETE (can only delete if creator)
85
80
  // ---------------------------------------------
@@ -124,12 +119,12 @@ ${JSON.stringify(initValues, null, 2)}`);
124
119
  _clearDetach() {
125
120
  this._onDetachCallbacks.clear();
126
121
 
127
- this._onDeleteCallbacks.clear(); // Monkey patch detach so it throws we called twice. Doing nothing blocks
128
- // the process on a second `detach` call as the Promise was never resolved
122
+ this._onDeleteCallbacks.clear(); // Monkey patch detach so it throws if called twice. Doing nothing blocks
123
+ // the process on a second `detach` call as the Promise never resolves
129
124
 
130
125
 
131
126
  this.detach = () => {
132
- throw new Error(`State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
127
+ throw new Error(`[stateManager] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
133
128
  };
134
129
  }
135
130
 
@@ -148,50 +143,141 @@ ${JSON.stringify(initValues, null, 2)}`);
148
143
  /** @private */
149
144
 
150
145
 
151
- _commit(obj, propagate = true) {
152
- const updated = {};
146
+ async _commit(updates, context, propagate = true, initiator = false) {
147
+ const newValues = {};
148
+ const oldValues = {};
149
+
150
+ for (let name in updates) {
151
+ const {
152
+ immediate,
153
+ filterChange,
154
+ event
155
+ } = this._parameters.getSchema(name); // @note 20211209 - we have an issue here server-side, because if the value
156
+ // is an object or an array, the reference is shared by everybody, therefore
157
+ // `changed` is always false and the new value is never propagated...
158
+ // FIXED - `state.get` now returns a deep copy when `type` is `any`
159
+
160
+
161
+ const oldValue = this._parameters.get(name);
162
+
163
+ const [newValue, changed] = this._parameters.set(name, updates[name]); // handle immediate stuff
164
+
165
+
166
+ if (initiator && immediate) {
167
+ // @note - we don't need to check filterChange here because the value
168
+ // has been updated in parameters on the `set` side so can rely on `changed`
169
+ // to avoid retrigger listeners.
170
+ // If the value has been overriden by the server, `changed` will true
171
+ // anyway so it should behave correctly.
172
+ if (!changed || event) {
173
+ continue;
174
+ }
175
+ }
153
176
 
154
- for (let name in obj) {
155
- updated[name] = this._parameters.set(name, obj[name]);
177
+ newValues[name] = newValue;
178
+ oldValues[name] = oldValue;
156
179
  } // if the `UPDATE_REQUEST` as been aborted by the server, do not propagate
157
180
 
158
181
 
159
- if (propagate) {
160
- this._subscriptions.forEach(listener => listener(updated));
182
+ let promises = [];
183
+
184
+ if (propagate && Object.keys(newValues).length > 0) {
185
+ this._subscriptions.forEach(listener => {
186
+ promises.push(listener(newValues, oldValues, context));
187
+ });
161
188
  }
162
189
 
163
- return updated;
190
+ await Promise.all(promises);
191
+ return newValues;
164
192
  }
165
193
  /**
166
- * Get the schema that describes the state.
194
+ * Updates values of the state. Wait for all subscriptions to be resolved
195
+ * before resolving itself, i.e.:
167
196
  *
168
- * @return {Object}
169
- */
170
-
171
-
172
- getSchema() {
173
- return this._schema;
174
- }
175
- /**
176
- * Updates values of the state.
197
+ * ```js
198
+ * const a = await server.stateManager.create('a');
199
+ * let asyncCallbackCalled = false;
200
+ *
201
+ * a.subscribe(updates => {
202
+ * return new Promise(resolve => {
203
+ * setTimeout(() => {
204
+ * asyncCallbackCalled = true;
205
+ * resolve();
206
+ * }, 100);
207
+ * });
208
+ * });
209
+ *
210
+ * await a.set({ bool: true });
211
+ * assert.equal(asyncCallbackCalled, true);
212
+ * ```
177
213
  *
178
214
  * @async
179
- * @param {Object} updates - key / value pairs of updates to apply to the state
180
- * @return {Object}
215
+ * @param {Object} updates - key / value pairs of updates to apply to the state.
216
+ * @param {Mixed} [context=null] - optionnal context that will be propagated
217
+ * alongside the updates of the state. The context is valid only for the
218
+ * current call and will be passed as third argument to any subscribe listeners.
219
+ * @return {Promise<Object>} A promise to the (coerced) updates.
220
+ *
221
+ * @see {common.SharedState~subscribeCallback}
181
222
  */
182
223
 
183
224
 
184
- async set(updates) {
225
+ async set(updates, context = null) {
226
+ // handle immediate option
227
+ const immediateNewValues = {};
228
+ const immediateOldValues = {};
229
+ let propagateNow = false;
230
+
231
+ for (let name in updates) {
232
+ // throw early (client-side and not only server-side) if parameter is undefined
233
+ if (!this._parameters.has(name)) {
234
+ throw new ReferenceError(`[stateManager] Cannot set value of undefined parameter "${name}"`);
235
+ } // @note: general idea...
236
+ // if immediate=true
237
+ // - call listeners if value changed
238
+ // - go through normal server path
239
+ // - retrigger only if response from server is different from current value
240
+ // if immediate=true && (filterChange=false || event=true)
241
+ // - call listeners with value regarless it changed
242
+ // - go through normal server path
243
+ // - if the node is initiator of the update (UPDATE_RESPONSE), (re-)check
244
+ // to prevent execute the listeners twice
245
+
246
+
247
+ const {
248
+ immediate,
249
+ filterChange,
250
+ event
251
+ } = this._parameters.getSchema(name);
252
+
253
+ if (immediate) {
254
+ const oldValue = this._parameters.get(name);
255
+
256
+ const [newValue, changed] = this._parameters.set(name, updates[name]);
257
+
258
+ if (changed || filterChange === false) {
259
+ immediateOldValues[name] = oldValue;
260
+ immediateNewValues[name] = newValue;
261
+ propagateNow = true;
262
+ }
263
+ }
264
+ }
265
+
266
+ if (propagateNow) {
267
+ this._subscriptions.forEach(listener => listener(immediateNewValues, immediateOldValues, context));
268
+ } // go through server-side normal behavior
269
+
270
+
185
271
  return new Promise((resolve, reject) => {
186
272
  const reqId = (0, _sharedStateUtils.storeRequestPromise)(resolve, reject);
187
273
 
188
- this._client.transport.emit(`${_sharedStateUtils.UPDATE_REQUEST}-${this.id}-${this.remoteId}`, reqId, updates);
274
+ this._client.transport.emit(`${_sharedStateUtils.UPDATE_REQUEST}-${this.id}-${this.remoteId}`, reqId, updates, context);
189
275
  });
190
276
  }
191
277
  /**
192
278
  * Get a value of the state by its name
193
279
  *
194
- * @param {String} name - Name of the param
280
+ * @param {String} name - Name of the param. Throws an error if the name is invalid.
195
281
  * @return {Mixed}
196
282
  */
197
283
 
@@ -209,29 +295,89 @@ ${JSON.stringify(initValues, null, 2)}`);
209
295
  getValues() {
210
296
  return this._parameters.getValues();
211
297
  }
298
+ /**
299
+ * Get the schema that describes the state.
300
+ *
301
+ * @param {String} [name=null] - if given, returns only the definition
302
+ * of the given param name. Throws an error if the name is invalid.
303
+ * @return {Object}
304
+ */
305
+
306
+
307
+ getSchema(name = null) {
308
+ return this._parameters.getSchema(name);
309
+ }
310
+ /**
311
+ * Get the values with which the state has been initialized.
312
+ *
313
+ * @return {Object}
314
+ */
315
+
316
+
317
+ getInitValues() {
318
+ return this._parameters.getInitValues();
319
+ }
320
+ /**
321
+ * Get the default values that has been declared in the schema.
322
+ *
323
+ * @return {Object}
324
+ */
325
+
326
+
327
+ getDefaults() {
328
+ return this._parameters.getDefaults();
329
+ }
212
330
  /**
213
331
  * @callback common.SharedState~subscribeCallback
214
- * @param {Object} updates - key / value pairs of the updates that have been
215
- * applied to the state
332
+ * @param {Object} newValues - key / value pairs of the updates that have been
333
+ * applied to the state.
334
+ * @param {Object} oldValues - key / value pairs of the related params before
335
+ * the updates has been applied to the state.
336
+ * @param {Mixed} [context=null] - Optionnal context data that has been passed
337
+ * with the updates in the `set` call.
338
+ *
339
+ * @example
340
+ * state.subscribe(async (newValues, oldValues[, context=null]) => {
341
+ * for (let [key, value] of Object.entries(newValues)) {
342
+ * switch (key) {
343
+ * // do something
344
+ * }
345
+ * }
346
+ * });
347
+ *
348
+ * @see {common.SharedState#set}
349
+ * @see {common.SharedState#subscribe}
216
350
  */
217
351
 
218
352
  /**
219
353
  * Subscribe to state updates
220
354
  *
221
355
  * @param {common.SharedState~subscribeCallback} callback - callback to execute
222
- * when an update is commited on the state.
356
+ * when an update is applied on the state.
357
+ * @param {Boolean} [executeListener=false] - execute the given listener with
358
+ * current state values. (`oldValues` will be set to `{}`, and `context` to `null`)
359
+ *
223
360
  * @example
224
- * state.subscribe(async (updates) => {
225
- * for (let [key, value] of Object.entries(updates)) {
226
- * // dispatch
361
+ * state.subscribe(async (newValues, oldValues) => {
362
+ * for (let [key, value] of Object.entries(newValues)) {
363
+ * switch (key) {
364
+ * // do something
365
+ * }
227
366
  * }
228
- * }
367
+ * });
229
368
  */
230
369
 
231
370
 
232
- subscribe(listener) {
371
+ subscribe(listener, executeListener = false) {
233
372
  this._subscriptions.add(listener);
234
373
 
374
+ if (executeListener === true) {
375
+ const currentValues = this.getValues();
376
+ const oldValues = {};
377
+ const context = null;
378
+ listener(currentValues, oldValues, context);
379
+ }
380
+
235
381
  return () => {
236
382
  this._subscriptions.delete(listener);
237
383
  };
@@ -253,7 +399,7 @@ ${JSON.stringify(initValues, null, 2)}`);
253
399
  async detach() {
254
400
  this._subscriptions.clear();
255
401
 
256
- if (this._isCreator) {
402
+ if (this._isOwner) {
257
403
  return new Promise((resolve, reject) => {
258
404
  const reqId = (0, _sharedStateUtils.storeRequestPromise)(resolve, reject);
259
405
 
@@ -267,6 +413,28 @@ ${JSON.stringify(initValues, null, 2)}`);
267
413
  });
268
414
  }
269
415
  }
416
+ /**
417
+ * Delete the state. Only the creator/owner of the state (i.e. a state created using
418
+ * `create`) can use this method. If a non-owner call this method (i.e. a
419
+ * state created using `attach`), an error will be thrown.
420
+ *
421
+ * @async
422
+ * @see {common.SharedState#onDetach}
423
+ * @see {common.SharedState#onDelete}
424
+ * @see {client.SharedStateManagerClient#create}
425
+ * @see {server.SharedStateManagerClient#create}
426
+ * @see {client.SharedStateManagerClient#attach}
427
+ * @see {server.SharedStateManagerClient#attach}
428
+ */
429
+
430
+
431
+ async delete() {
432
+ if (this._isOwner) {
433
+ return this.detach();
434
+ } else {
435
+ throw new Error(`[stateManager] can delete state "${this.schemaName}", only owner of the state (i.e. the node that "create[d]" it) can delete it`);
436
+ }
437
+ }
270
438
  /**
271
439
  * Register a function to execute when detaching from the state
272
440
  *
@@ -15,7 +15,7 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
15
15
  * Component dedicated at managing distributed states, accessible through {@link common.SharedState} instances, among the application.
16
16
  *
17
17
  * An instance of `SharedStateManagerClient` is automatically created by the
18
- * `soundworks.Client` (cf. {@link client.Client#stateManager}).
18
+ * `soundworks.Client` at initialization (cf. {@link client.Client#stateManager}).
19
19
  *
20
20
  * Tutorial: [https://collective-soundworks.github.io/tutorials/state-manager.html](https://collective-soundworks.github.io/tutorials/state-manager.html)
21
21
  *
@@ -99,6 +99,12 @@ class SharedStateManagerClient {
99
99
  list.forEach(([schemaName, stateId, nodeId]) => {
100
100
  this._observeListeners.forEach(callback => callback(schemaName, stateId, nodeId));
101
101
  });
102
+ }); // ---------------------------------------------
103
+ // Clear cache when schema is deleted
104
+ // ---------------------------------------------
105
+
106
+ this.client.transport.addListener(_sharedStateUtils.DELETE_SCHEMA, schemaName => {
107
+ this._cachedSchemas.delete(schemaName);
102
108
  });
103
109
  }
104
110
  /**
@@ -7,7 +7,7 @@ exports.default = void 0;
7
7
 
8
8
  var _events = _interopRequireDefault(require("events"));
9
9
 
10
- var _parameters = _interopRequireDefault(require("@ircam/parameters"));
10
+ var _ParameterBag = _interopRequireDefault(require("./ParameterBag.js"));
11
11
 
12
12
  var _lodash = _interopRequireDefault(require("lodash.clonedeep"));
13
13
 
@@ -26,8 +26,8 @@ const generateRemoteId = (0, _sharedStateUtils.idGenerator)();
26
26
  * The `SharedStateManagerServer` extends the `SharedStateManagerClient` with
27
27
  * additionnal functionnalities to register schemas and handle clients.
28
28
  *
29
- * An instance of `SharedStateManagerClient` is automatically created by the
30
- * `soundworks.Server` (cf. {@link server.Server#stateManager}).
29
+ * An instance of `SharedStateManagerServer` is automatically created by the
30
+ * `soundworks.Server` at initialization (cf. {@link server.Server#stateManager}).
31
31
  *
32
32
  * Tutorial: [https://collective-soundworks.github.io/tutorials/state-manager.html](https://collective-soundworks.github.io/tutorials/state-manager.html)
33
33
  *
@@ -49,6 +49,8 @@ class SharedStateManagerServer extends _SharedStateManagerClient.default {
49
49
  this._serverStatesById = new Map();
50
50
  this._schemas = new Map();
51
51
  this._observers = new Set();
52
+ this._hooksBySchemaName = new Map(); // protected
53
+
52
54
  this.addClient(localClientId, localTransport);
53
55
  }
54
56
  /**
@@ -222,38 +224,56 @@ class SharedStateManagerServer extends _SharedStateManagerClient.default {
222
224
  this._clientByNodeId.delete(nodeId);
223
225
  }
224
226
  /**
225
- * Register a schema. The schema definition follows the convention described
226
- * here [https://github.com/ircam-jstools/parameters#booleandefinition--object](https://github.com/ircam-jstools/parameters#booleandefinition--object) (@todo - document somewhere else).
227
+ * Register a schema from which shared states (cf. {@link common.SharedState})
228
+ * can be instanciated.
227
229
  *
228
230
  * @param {String} schemaName - Name of the schema.
229
- * @param {Object} schema - Description of the state data structure.
231
+ * @param {server.SharedStateManagerServer~schema} schema - Data structure
232
+ * describing the states that will be created from this schema.
233
+ *
234
+ * @see {@link server.SharedStateManagerServer#create}
235
+ * @see {@link client.SharedStateManagerClient#create}
236
+ *
237
+ * @example
238
+ * server.stateManager.registerSchema('my-schema', {
239
+ * myBoolean: {
240
+ * type: 'boolean'
241
+ * default: false,
242
+ * },
243
+ * myFloat: {
244
+ * type: 'float'
245
+ * default: 0.1,
246
+ * min: -1,
247
+ * max: 1
248
+ * }
249
+ * })
230
250
  */
231
251
 
232
252
 
233
253
  registerSchema(schemaName, schema) {
234
254
  if (this._schemas.has(schemaName)) {
235
- throw new Error(`schema "${schemaName}" already registered`);
236
- } // throw is schema is invalid
255
+ throw new Error(`[stateManager.registerSchema] cannot register schema with name: "${schemaName}", schema name already exists`);
256
+ }
237
257
 
258
+ _ParameterBag.default.validateSchema(schema);
259
+
260
+ this._schemas.set(schemaName, (0, _lodash.default)(schema)); // create hooks list
238
261
 
239
- try {
240
- (0, _parameters.default)(schema, {});
241
- } catch (err) {
242
- throw new Error(`Invalid schema "${schemaName}": ${err.message}`);
243
- }
244
262
 
245
- this._schemas.set(schemaName, (0, _lodash.default)(schema));
263
+ this._hooksBySchemaName.set(schemaName, new Set());
246
264
  }
247
265
  /**
248
266
  * Delete a schema and all associated states.
249
- * When a schema is deleted, all attached clients are detached
250
- * and the `onDetach` and `onDelete` callbacks are called.
267
+ * When a schema is deleted, all states created from this schema are deleted
268
+ * as well, therefore all attached clients are detached and the `onDetach`
269
+ * and `onDelete` callbacks are called on the related states.
251
270
  *
252
271
  * @param {String} schemaName - Name of the schema.
253
272
  */
254
273
 
255
274
 
256
275
  deleteSchema(schemaName) {
276
+ // @note: deleting schema
257
277
  for (let [id, state] of this._serverStatesById.entries()) {
258
278
  if (state.schemaName === schemaName) {
259
279
  for (let [remoteId, attached] of state._attachedClients.entries()) {
@@ -264,9 +284,72 @@ class SharedStateManagerServer extends _SharedStateManagerClient.default {
264
284
 
265
285
  this._serverStatesById.delete(this.id);
266
286
  }
287
+ } // clear schema cache of all connected clients
288
+
289
+
290
+ for (let client of this._clientByNodeId.values()) {
291
+ client.transport.emit(`${_sharedStateUtils.DELETE_SCHEMA}`, schemaName);
267
292
  }
268
293
 
269
- this._schemas.delete(schemaName);
294
+ this._schemas.delete(schemaName); // delete registered hooks
295
+
296
+
297
+ this._hooksBySchemaName.delete(schemaName);
298
+ }
299
+ /**
300
+ * @callback server.SharedStateManagerServer~updateHook
301
+ *
302
+ * @param {Object} updates - Update object as given on a set callback, or
303
+ * result of the previous hook
304
+ * @param {Object} currentValues - Current values of the state.
305
+ * @param {Object} [context=null] - Optionnal context passed by the creator
306
+ * of the update.
307
+ * @return {Object} The "real" updates to be applied on the state.
308
+ */
309
+
310
+ /**
311
+ * Register a function for a given schema (e.g. will be applied on all states
312
+ * created from this schema) that will be executed before the update values
313
+ * are propagated. For example, this could be used to implement a preset system
314
+ * where all the values of the state are updated from e.g. some data stored in
315
+ * filesystem while the consumer of the state only want to update the preset name.
316
+ *
317
+ * The hook is associated to every state of its kind (i.e. schemaName) and
318
+ * executed on every update (call of `set`). Note that the hooks are executed
319
+ * server-side regarless the node on which `set` has been called and before
320
+ * the "actual" update of the state (e.g. before the call of `subscribe`).
321
+ *
322
+ * @example
323
+ * server.stateManager.registerSchema('hooked', schema);
324
+ * server.stateManager.registerUpdateHook('hooked', (updates, currentValues) => {
325
+ * return {
326
+ * ...updates
327
+ * numUpdates: currentValues.numUpdates + 1,
328
+ * };
329
+ * });
330
+ *
331
+ * const state = await server.stateManager.create('hooked');
332
+ *
333
+ * await state.set({ name: 'test' });
334
+ * state.getValues();
335
+ * // > { name: 'test', numUpdates: 1 };
336
+ *
337
+ * @param {String} schemaName - Kind of states on which applying the hook.
338
+ * @param {server.SharedStateManagerServer~updateHook} updateHook - Function
339
+ * called between the `set` call and the actual update.
340
+ */
341
+
342
+
343
+ registerUpdateHook(schemaName, updateHook) {
344
+ // throw error if schemaName has not been registered
345
+ if (!this._schemas.has(schemaName)) {
346
+ throw new Error(`[stateManager.registerUpdateHook] cannot register update hook for schema name "${schemaName}", schema name does not exists`);
347
+ }
348
+
349
+ const hooks = this._hooksBySchemaName.get(schemaName);
350
+
351
+ hooks.add(updateHook);
352
+ return () => hooks.delete(updateHook);
270
353
  }
271
354
 
272
355
  }