@soundworks/core 4.0.0-alpha.2 → 4.0.0-alpha.20

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.
@@ -1,30 +1,14 @@
1
- import merge from 'lodash.merge';
1
+ import merge from 'lodash/merge.js';
2
2
 
3
3
  /**
4
4
  * @private
5
5
  */
6
6
  class BasePlugin {
7
7
  constructor(id) {
8
- /**
9
- * User defined ID of the plugin.
10
- *
11
- * @type {string}
12
- * @see {@link client.PluginManager#register}
13
- * @see {@link server.PluginManager#register}
14
- */
15
- this.id = id;
16
-
17
- /**
18
- * Type of the plugin, i.e. the ClassName.
19
- *
20
- * Usefull to do perform some logic based on certain types of plugins without
21
- * knowing under which `id` they have been registered. (e.g. creating some generic
22
- * views, etc.)
23
- *
24
- * @type {string}
25
- * @readonly
26
- */
27
- this.type = this.constructor.name;
8
+ /** @private */
9
+ this._id = id;
10
+ /** @private */
11
+ this._type = this.constructor.name;
28
12
 
29
13
  /**
30
14
  * Options of the plugin.
@@ -58,6 +42,32 @@ class BasePlugin {
58
42
  this._onStateChangeCallbacks = new Set();
59
43
  }
60
44
 
45
+ /**
46
+ * User defined ID of the plugin.
47
+ *
48
+ * @type {string}
49
+ * @readonly
50
+ * @see {@link client.PluginManager#register}
51
+ * @see {@link server.PluginManager#register}
52
+ */
53
+ get id() {
54
+ return this._id;
55
+ }
56
+
57
+ /**
58
+ * Type of the plugin, i.e. the ClassName.
59
+ *
60
+ * Usefull to do perform some logic based on certain types of plugins without
61
+ * knowing under which `id` they have been registered. (e.g. creating some generic
62
+ * views, etc.)
63
+ *
64
+ * @type {string}
65
+ * @readonly
66
+ */
67
+ get type() {
68
+ return this._type;
69
+ }
70
+
61
71
  /**
62
72
  * Start the plugin. This method is automatically called during the client or
63
73
  * server `init()` lifecyle step. After `start()` is fulfilled the plugin should
@@ -1,7 +1,6 @@
1
- import isPlainObject from 'is-plain-obj';
1
+ import { isPlainObject, isString } from '@ircam/sc-utils';
2
2
 
3
3
  import logger from './logger.js';
4
- import { isString } from './utils.js';
5
4
 
6
5
  /**
7
6
  * Shared functionnality between server-side and client-size plugin manager
@@ -14,7 +13,7 @@ class BasePluginManager {
14
13
  /** @private */
15
14
  this._node = node;
16
15
  /** @private */
17
- this._registeredPlugins = new Map();
16
+ this._dependencies = new Map();
18
17
  /** @private */
19
18
  this._instances = new Map();
20
19
  /** @private */
@@ -68,11 +67,29 @@ class BasePluginManager {
68
67
  throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.register" fourth optionnal argument should be an array`);
69
68
  }
70
69
 
71
- if (this._registeredPlugins.has(id)) {
72
- throw new Error(`[soundworks:PluginManager] Plugin "${id}" of type "${ctor.name}" already registered`);
70
+ if (this._instances.has(id)) {
71
+ throw new Error(`[soundworks:PluginManager] Plugin "${id}" already registered`);
73
72
  }
74
73
 
75
- this._registeredPlugins.set(id, { ctor, options, deps });
74
+ // we instanciate the plugin here, so that a plugin can register another one
75
+ // in its own constructor.
76
+ //
77
+ // the dependencies must be created first, so that the instance can call
78
+ // addDependency in its constructor
79
+ this._dependencies.set(id, deps);
80
+
81
+ const instance = new ctor(this._node, id, options);
82
+ this._instances.set(id, instance);
83
+ }
84
+
85
+ /**
86
+ * Manually add a dependency to a given plugin. Usefull to require a plugin
87
+ * within a plugin
88
+ *
89
+ */
90
+ addDependency(pluginId, dependencyId) {
91
+ const deps = this._dependencies.get(pluginId);
92
+ deps.push(dependencyId);
76
93
  }
77
94
 
78
95
  /**
@@ -80,7 +97,7 @@ class BasePluginManager {
80
97
  * @returns {string[]}
81
98
  */
82
99
  getRegisteredPlugins() {
83
- return Array.from(this._registeredPlugins.keys());
100
+ return Array.from(this._instances.keys());
84
101
  }
85
102
 
86
103
  /**
@@ -97,23 +114,21 @@ class BasePluginManager {
97
114
 
98
115
  this.status = 'inited';
99
116
  // instanciate all plugins
100
- for (let [id, { ctor, options }] of this._registeredPlugins.entries()) {
101
- const instance = new ctor(this._node, id, options);
102
- this._instances.set(id, instance);
117
+ for (let [_id, instance] of this._instances.entries()) {
103
118
  instance.onStateChange(_values => this._propagateStateChange(instance));
104
119
  }
105
120
 
106
121
  // propagate all 'idle' statuses before start
107
122
  this._propagateStateChange();
108
123
 
109
- const promises = Array.from(this._registeredPlugins.keys()).map(id => this.unsafeGet(id));
124
+ const promises = Array.from(this._instances.keys()).map(id => this.unsafeGet(id));
110
125
 
111
126
  try {
112
127
  await Promise.all(promises);
113
128
  this.status = 'started';
114
- } catch(err) {
129
+ } catch (err) {
115
130
  this.status = 'errored';
116
- throw new Error(err);
131
+ throw err; // throw initial error
117
132
  }
118
133
  }
119
134
 
@@ -126,8 +141,8 @@ class BasePluginManager {
126
141
 
127
142
  /**
128
143
  * Retrieve an fully started instance of a registered plugin, without checking
129
- * that the pluginManager is started. This is important for starting the plugin
130
- * manager itself.
144
+ * that the pluginManager has started. This is required for starting the plugin
145
+ * manager itself and to require a plugin from within another plugin
131
146
  *
132
147
  * @private
133
148
  */
@@ -136,7 +151,7 @@ class BasePluginManager {
136
151
  throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.get(name)" argument should be a string`);
137
152
  }
138
153
 
139
- if (!this._registeredPlugins.has(id)) {
154
+ if (!this._instances.has(id)) {
140
155
  throw new Error(`[soundworks:PluginManager] Cannot get plugin "${id}", plugin is not registered`);
141
156
  }
142
157
 
@@ -145,7 +160,7 @@ class BasePluginManager {
145
160
  // to dynamically register and launch plugins at runtime.
146
161
  //
147
162
  // if (!this._instances.has(id)) {
148
- // const { ctor, options } = this._registeredPlugins.get(id);
163
+ // const { ctor, options } = this._dependencies.get(id);
149
164
  // const instance = new ctor(this._node, id, options);
150
165
  // this._instances.set(id, instance);
151
166
  // }
@@ -153,7 +168,7 @@ class BasePluginManager {
153
168
  const instance = this._instances.get(id);
154
169
 
155
170
  // recursively get the dependency chain
156
- const { deps } = this._registeredPlugins.get(id);
171
+ const deps = this._dependencies.get(id);
157
172
  const promises = deps.map(id => this.unsafeGet(id));
158
173
 
159
174
  await Promise.all(promises);
@@ -170,7 +185,7 @@ class BasePluginManager {
170
185
  this._instanceStartPromises.set(id, startPromise);
171
186
 
172
187
  await startPromise;
173
- } catch(err) {
188
+ } catch (err) {
174
189
  errored = true;
175
190
  this._propagateStateChange(instance, 'errored');
176
191
  throw err;
@@ -1,4 +1,6 @@
1
+ import { isPlainObject } from '@ircam/sc-utils';
1
2
  import ParameterBag from './ParameterBag.js';
3
+ import PromiseStore from './PromiseStore.js';
2
4
  import {
3
5
  DELETE_REQUEST,
4
6
  DELETE_RESPONSE,
@@ -12,29 +14,41 @@ import {
12
14
  UPDATE_ABORT,
13
15
  UPDATE_NOTIFICATION,
14
16
  } from './constants.js';
15
- import {
16
- storeRequestPromise,
17
- resolveRequest,
18
- rejectRequest,
19
- } from './promise-store.js';
17
+
18
+ // @todo - replace isOwner by `type`:
19
+ // enum {
20
+ // 'owner'
21
+ // 'attached'
22
+ // 'collection-controlller'
23
+ // 'collection-attached'
24
+ // }
20
25
 
21
26
  class BaseSharedState {
22
27
  constructor(id, remoteId, schemaName, schema, client, isOwner, manager, initValues = {}) {
23
- this.id = id;
24
- this.remoteId = remoteId;
25
- this.schemaName = schemaName;
26
-
28
+ /** @private */
29
+ this._id = id;
30
+ /** @private */
31
+ this._remoteId = remoteId;
32
+ /** @private */
33
+ this._schemaName = schemaName;
27
34
  /** @private */
28
35
  this._isOwner = isOwner; // may be the server or any client
29
36
  /** @private */
30
37
  this._client = client;
31
38
  /** @private */
32
39
  this._manager = manager;
40
+ /**
41
+ * true is the state has been detached or deleted
42
+ * @private
43
+ */
44
+ this._detached = false;
45
+ /* @private */
46
+ this._promiseStore = new PromiseStore(this.constructor.name);
33
47
 
34
48
  try {
35
49
  /** @private */
36
50
  this._parameters = new ParameterBag(schema, initValues);
37
- } catch(err) {
51
+ } catch (err) {
38
52
  console.error(err.stack);
39
53
 
40
54
  throw new Error(`Error creating or attaching state "${schemaName}" w/ values:\n
@@ -51,13 +65,13 @@ ${JSON.stringify(initValues, null, 2)}`);
51
65
  // add listener for state updates
52
66
  this._client.transport.addListener(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
53
67
  const updated = await this._commit(updates, context, true, true);
54
- resolveRequest(reqId, updated);
68
+ this._promiseStore.resolve(reqId, updated);
55
69
  });
56
70
 
57
71
  // retrieve values but do not propagate to subscriptions
58
72
  this._client.transport.addListener(`${UPDATE_ABORT}-${this.id}-${this.remoteId}`, async (reqId, updates, context) => {
59
73
  const updated = await this._commit(updates, context, false, true);
60
- resolveRequest(reqId, updated);
74
+ this._promiseStore.resolve(reqId, updated);
61
75
  });
62
76
 
63
77
  this._client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`, async (updates, context) => {
@@ -87,70 +101,121 @@ ${JSON.stringify(initValues, null, 2)}`);
87
101
  });
88
102
 
89
103
  // ---------------------------------------------
90
- // DELETE initiated by creator, or schema deleted
104
+ // state has been deleted by its creator or the schema has been deleted
91
105
  // ---------------------------------------------
92
- this._client.transport.addListener(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`, () => {
106
+ this._client.transport.addListener(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`, async () => {
93
107
  this._manager._statesById.delete(this.id);
94
108
  this._clearTransport();
95
109
 
96
- this._onDetachCallbacks.forEach(callback => callback());
97
- this._onDeleteCallbacks.forEach(callback => callback());
110
+ for (let callback of this._onDetachCallbacks) {
111
+ try {
112
+ await callback();
113
+ } catch (err) {
114
+ console.error(err.message);
115
+ }
116
+ }
98
117
 
99
- this._clearDetach();
118
+ if (this._isOwner) {
119
+ for (let callback of this._onDeleteCallbacks) {
120
+ await callback();
121
+ }
122
+ }
123
+
124
+ this._onDetachCallbacks.clear();
125
+ this._onDeleteCallbacks.clear();
126
+ this._promiseStore.flush();
100
127
  });
101
128
 
102
129
 
103
130
  if (this._isOwner) {
104
131
  // ---------------------------------------------
105
- // DELETE (can only delete if creator)
132
+ // the creator has called `.delete()`
106
133
  // ---------------------------------------------
107
- this._client.transport.addListener(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`, (reqId) => {
134
+ this._client.transport.addListener(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId) => {
108
135
  this._manager._statesById.delete(this.id);
109
136
  this._clearTransport();
110
137
 
111
- this._onDetachCallbacks.forEach(callback => callback());
112
- this._onDeleteCallbacks.forEach(callback => callback());
138
+ for (let callback of this._onDetachCallbacks) {
139
+ await callback();
140
+ }
141
+
142
+ for (let callback of this._onDeleteCallbacks) {
143
+ await callback();
144
+ }
113
145
 
114
- this._clearDetach();
115
- resolveRequest(reqId, this);
146
+ this._onDetachCallbacks.clear();
147
+ this._onDeleteCallbacks.clear();
148
+ this._promiseStore.resolve(reqId, this);
149
+ this._promiseStore.flush();
116
150
  });
117
151
 
118
152
  this._client.transport.addListener(`${DELETE_ERROR}-${this.id}`, (reqId, msg) => {
119
- rejectRequest(reqId, msg);
153
+ this._promiseStore.reject(reqId, msg);
120
154
  });
121
155
 
122
156
  } else {
123
157
  // ---------------------------------------------
124
- // DETACH
158
+ // the attached node has called `.detach()`
125
159
  // ---------------------------------------------
126
- this._client.transport.addListener(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`, (reqId) => {
160
+ this._client.transport.addListener(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`, async (reqId) => {
127
161
  this._manager._statesById.delete(this.id);
128
162
  this._clearTransport();
129
163
 
130
- this._onDetachCallbacks.forEach(func => func());
164
+ for (let callback of this._onDetachCallbacks) {
165
+ await callback();
166
+ }
131
167
 
132
- this._clearDetach();
133
- resolveRequest(reqId, this);
168
+ this._onDetachCallbacks.clear();
169
+ this._onDeleteCallbacks.clear();
170
+ this._promiseStore.resolve(reqId, this);
171
+ this._promiseStore.flush();
134
172
  });
135
173
 
136
174
  // the state does not exists anymore in the server (should not happen)
137
175
  this._client.transport.addListener(`${DETACH_ERROR}-${this.id}`, (reqId, msg) => {
138
- rejectRequest(reqId, msg);
176
+ this._onDetachCallbacks.clear();
177
+ this._onDeleteCallbacks.clear();
178
+ this._promiseStore.reject(reqId, msg);
179
+ this._promiseStore.flush();
139
180
  });
140
-
141
181
  }
142
182
  }
143
183
 
144
- /** @private */
145
- _clearDetach() {
146
- this._onDetachCallbacks.clear();
147
- this._onDeleteCallbacks.clear();
148
-
149
- // Monkey patch detach so it throws if called twice. Doing nothing blocks
150
- // the process on a second `detach` call as the Promise never resolves
151
- this.detach = () => {
152
- throw new Error(`[stateManager] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
153
- };
184
+ /**
185
+ * Id of the state
186
+ * @type {Number}
187
+ * @readonly
188
+ */
189
+ get id() {
190
+ return this._id;
191
+ }
192
+
193
+ /**
194
+ * Unique id of the state for the current node
195
+ * @readonly
196
+ * @type {Number}
197
+ * @private
198
+ */
199
+ get remoteId() {
200
+ return this._remoteId;
201
+ }
202
+
203
+ /**
204
+ * Name of the schema
205
+ * @type {String}
206
+ * @readonly
207
+ */
208
+ get schemaName() {
209
+ return this._schemaName;
210
+ }
211
+
212
+ /**
213
+ * Indicates if the node is the owner of the state
214
+ * @type {Boolean}
215
+ * @readonly
216
+ */
217
+ get isOwner() {
218
+ return this._isOwner;
154
219
  }
155
220
 
156
221
  /** @private */
@@ -158,9 +223,16 @@ ${JSON.stringify(initValues, null, 2)}`);
158
223
  // remove listeners
159
224
  this._client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.id}-${this.remoteId}`);
160
225
  this._client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.id}-${this.remoteId}`);
161
- this._client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`);
226
+ this._client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.id}-${this.remoteId}`);
162
227
  this._client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.id}-${this.remoteId}`);
163
- this._client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`);
228
+
229
+ if (this._isOwner) {
230
+ this._client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.id}-${this.remoteId}`);
231
+ this._client.transport.removeAllListeners(`${DELETE_ERROR}-${this.id}-${this.remoteId}`);
232
+ } else {
233
+ this._client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.id}-${this.remoteId}`);
234
+ this._client.transport.removeAllListeners(`${DETACH_ERROR}-${this.id}-${this.remoteId}`);
235
+ }
164
236
  }
165
237
 
166
238
  /** @private */
@@ -255,16 +327,27 @@ ${JSON.stringify(initValues, null, 2)}`);
255
327
  * const updates = await state.set({ myParam: Math.random() });
256
328
  */
257
329
  async set(updates, context = null) {
330
+ if (this._detached) {
331
+ return;
332
+ }
333
+
334
+ if (!isPlainObject(updates)) {
335
+ throw new ReferenceError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as first parameter`);
336
+ }
337
+
338
+ if (context !== null && !isPlainObject(context)) {
339
+ throw new ReferenceError(`[SharedState] State "${this.schemaName}": state.set(updates[, context]) should receive an object as second parameter`);
340
+ }
341
+
258
342
  // handle immediate option
259
343
  const immediateNewValues = {};
260
344
  const immediateOldValues = {};
261
345
  let propagateNow = false;
262
346
 
263
347
  for (let name in updates) {
264
- // throw early (client-side and not only server-side) if parameter is undefined
265
- if (!this._parameters.has(name)) {
266
- throw new ReferenceError(`[stateManager] Cannot set value of undefined parameter "${name}"`);
267
- }
348
+ // try to coerce value early, so that eventual errors are triggered early
349
+ // on the node requesting the update
350
+ this._parameters.coerceValue(name, updates[name]);
268
351
 
269
352
  // @note: general idea...
270
353
  // if immediate=true
@@ -297,27 +380,47 @@ ${JSON.stringify(initValues, null, 2)}`);
297
380
 
298
381
  // go through server-side normal behavior
299
382
  return new Promise((resolve, reject) => {
300
- const reqId = storeRequestPromise(resolve, reject);
383
+ const reqId = this._promiseStore.add(resolve, reject, 'update-request');
301
384
  this._client.transport.emit(`${UPDATE_REQUEST}-${this.id}-${this.remoteId}`, reqId, updates, context);
302
385
  });
303
386
  }
304
387
 
305
388
  /**
306
- * Get the value of a paramter of the state.
389
+ * Get the value of a parameter of the state. If the parameter is of `any` type,
390
+ * a deep copy is returned.
307
391
  *
308
392
  * @param {string} name - Name of the param.
309
393
  * @throws Throws if `name` does not correspond to an existing field
310
394
  * of the state.
311
395
  * @return {mixed}
312
396
  * @example
313
- * const value = state.get('name');
397
+ * const value = state.get('paramName');
314
398
  */
315
399
  get(name) {
316
400
  return this._parameters.get(name);
317
401
  }
318
402
 
319
403
  /**
320
- * Get all the key / value pairs of the state.
404
+ * Similar to `get` but returns a reference to the underlying value in case of
405
+ * `any` type. May be usefull if the underlying value is big (e.g. sensors
406
+ * recordings, etc.) and deep cloning expensive. Be aware that if changes are
407
+ * made on the returned object, the state of your application will become
408
+ * inconsistent.
409
+ *
410
+ * @param {string} name - Name of the param.
411
+ * @throws Throws if `name` does not correspond to an existing field
412
+ * of the state.
413
+ * @return {mixed}
414
+ * @example
415
+ * const value = state.getUnsafe('paramName');
416
+ */
417
+ getUnsafe(name) {
418
+ return this._parameters.getUnsafe(name);
419
+ }
420
+
421
+ /**
422
+ * Get all the key / value pairs of the state. If a parameter is of `any`
423
+ * type, a deep copy is made.
321
424
  *
322
425
  * @return {object}
323
426
  * @example
@@ -328,12 +431,29 @@ ${JSON.stringify(initValues, null, 2)}`);
328
431
  }
329
432
 
330
433
  /**
331
- * Get the schema from which the state has been created.
434
+ * Get all the key / value pairs of the state. If a parameter is of `any`
435
+ * type, a deep copy is made.
436
+ * Similar to `getValues` but returns a reference to the underlying value in
437
+ * case of `any` type. May be usefull if the underlying value is big (e.g.
438
+ * sensors recordings, etc.) and deep cloning expensive. Be aware that if
439
+ * changes are made on the returned object, the state of your application will
440
+ * become inconsistent.
441
+ *
442
+ * @return {object}
443
+ * @example
444
+ * const values = state.getValues();
445
+ */
446
+ getValuesUnsafe() {
447
+ return this._parameters.getValuesUnsafe();
448
+ }
449
+
450
+ /**
451
+ * Definition of schema from which the state has been created.
332
452
  *
333
- * @param {string} [name=null] - If given, returns only the definition corresponding
334
- * to the given param name.
453
+ * @param {string} [name=null] - If given, returns only the definition
454
+ * corresponding to the given param name.
335
455
  * @throws Throws if `name` does not correspond to an existing field
336
- * of the state.
456
+ * of the schema.
337
457
  * @return {object}
338
458
  * @example
339
459
  * const schema = state.getSchema();
@@ -367,7 +487,7 @@ ${JSON.stringify(initValues, null, 2)}`);
367
487
 
368
488
  /**
369
489
  * Detach from the state. If the client is the creator of the state, the state
370
- * is deleted and all attached nodes get notified
490
+ * is deleted and all attached nodes get notified.
371
491
  *
372
492
  * @example
373
493
  * const state = await client.state.attach('globals');
@@ -375,16 +495,21 @@ ${JSON.stringify(initValues, null, 2)}`);
375
495
  * await state.detach();
376
496
  */
377
497
  async detach() {
498
+ if (this._detached) {
499
+ throw new Error(`[SharedState] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`);
500
+ }
501
+
502
+ this._detached = true; // mark detached early
378
503
  this._onUpdateCallbacks.clear();
379
504
 
380
505
  if (this._isOwner) {
381
506
  return new Promise((resolve, reject) => {
382
- const reqId = storeRequestPromise(resolve, reject);
507
+ const reqId = this._promiseStore.add(resolve, reject, 'delete-request');
383
508
  this._client.transport.emit(`${DELETE_REQUEST}-${this.id}-${this.remoteId}`, reqId);
384
509
  });
385
510
  } else {
386
511
  return new Promise((resolve, reject) => {
387
- const reqId = storeRequestPromise(resolve, reject);
512
+ const reqId = this._promiseStore.add(resolve, reject, 'detach-request');
388
513
  this._client.transport.emit(`${DETACH_REQUEST}-${this.id}-${this.remoteId}`, reqId);
389
514
  });
390
515
  }
@@ -392,9 +517,11 @@ ${JSON.stringify(initValues, null, 2)}`);
392
517
 
393
518
  /**
394
519
  * Delete the state. Only the creator/owner of the state can use this method.
395
- * All nodes attached to the state will be deteched, triggering the `onDetach`
396
- * callback. The creator of the state will also have its `onDelete` callback
397
- * triggered.
520
+ *
521
+ * All nodes attached to the state will be detached, triggering any registered
522
+ * `onDetach` callbacks. The creator of the state will also have its own `onDelete`
523
+ * callback triggered. The local `onDeatch` and `onDelete` callbacks will be
524
+ * executed *before* the returned Promise resolves
398
525
  *
399
526
  * @throws Throws if the method is called by a node which is not the owner of
400
527
  * the state.
@@ -405,16 +532,20 @@ ${JSON.stringify(initValues, null, 2)}`);
405
532
  */
406
533
  async delete() {
407
534
  if (this._isOwner) {
535
+ if (this._detached) {
536
+ throw new Error(`[SharedState] State "${this.schemaName} (${this.id})" already deleted, cannot delete twice`);
537
+ }
538
+
408
539
  return this.detach();
409
540
  } else {
410
- 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`);
541
+ throw new Error(`[SharedState] Cannot delete state "${this.schemaName}", only the owner of the state (i.e. the node that created it) can delete the state. Use "detach" instead.`);
411
542
  }
412
543
  }
413
544
 
414
545
  /**
415
546
  * Subscribe to state updates.
416
547
  *
417
- * @param {client.SharedState~onUpdateCallback|client.SharedState~onUpdateCallback} callback
548
+ * @param {client.SharedState~onUpdateCallback|server.SharedState~onUpdateCallback} callback
418
549
  * Callback to execute when an update is applied on the state.
419
550
  * @param {Boolean} [executeListener=false] - Execute the callback immediately
420
551
  * with current state values. (`oldValues` will be set to `{}`, and `context` to `null`)
@@ -447,10 +578,11 @@ ${JSON.stringify(initValues, null, 2)}`);
447
578
  }
448
579
 
449
580
  /**
450
- * Register a function to execute when detaching from the state
581
+ * Register a function to execute when detaching from the state. The function
582
+ * will be executed before the `detach` promise resolves.
451
583
  *
452
- * @param {Function} callback - callback to execute when detaching from the state.
453
- * wether the client as called `detach`, or the state has been deleted by its
584
+ * @param {Function} callback - Callback to execute when detaching from the state.
585
+ * Whether the client as called `detach`, or the state has been deleted by its
454
586
  * creator.
455
587
  */
456
588
  onDetach(callback) {
@@ -460,9 +592,10 @@ ${JSON.stringify(initValues, null, 2)}`);
460
592
 
461
593
  /**
462
594
  * Register a function to execute when the state is deleted. Only called if the
463
- * node was the creator of the state. Is called after `onDetach`
595
+ * node was the creator of the state. Is called after `onDetach` and executed
596
+ * before the `delete` Promise resolves.
464
597
  *
465
- * @param {Function} callback - callback to execute when the state is deleted.
598
+ * @param {Function} callback - Callback to execute when the state is deleted.
466
599
  */
467
600
  onDelete(callback) {
468
601
  this._onDeleteCallbacks.add(callback);