@soundworks/core 4.0.0-alpha.14 → 4.0.0-alpha.16

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,6 +1,7 @@
1
- import { isString, isFunction } from '@ircam/sc-utils';
1
+ import { isString, isFunction, isPlainObject } from '@ircam/sc-utils';
2
2
  import SharedState from './BaseSharedState.js';
3
3
  import SharedStateCollection from './BaseSharedStateCollection.js';
4
+ import PromiseStore from './PromiseStore.js';
4
5
  import {
5
6
  CREATE_REQUEST,
6
7
  CREATE_RESPONSE,
@@ -10,15 +11,14 @@ import {
10
11
  ATTACH_ERROR,
11
12
  OBSERVE_REQUEST,
12
13
  OBSERVE_RESPONSE,
14
+ OBSERVE_ERROR,
13
15
  OBSERVE_NOTIFICATION,
14
16
  UNOBSERVE_NOTIFICATION,
15
17
  DELETE_SCHEMA,
16
18
  } from './constants.js';
17
- import {
18
- storeRequestPromise,
19
- resolveRequest,
20
- rejectRequest,
21
- } from './promise-store.js';
19
+
20
+ export const kCreateCollectionController = Symbol('BaseStateManager::createCollectionController');
21
+ export const kAttachInCollection = Symbol('BaseStateManager::attachInCollection');
22
22
 
23
23
  /**
24
24
  * @private
@@ -32,10 +32,13 @@ class BaseStateManager {
32
32
  constructor(id, transport) {
33
33
  this.client = { id, transport };
34
34
 
35
- this._statesById = new Map();
36
- this._observeListeners = new Map(); // Map <callback, filterSchemaName>
37
- this._cachedSchemas = new Map();
38
- this._observeRequestCallbacks = new Map();
35
+ this._statesById = new Map(); // <id, state>
36
+ this._cachedSchemas = new Map(); // <shemaName, definition>
37
+
38
+ this._observeListeners = new Set(); // Set <[observedSchemaName, callback, options]>
39
+ this._observeRequestCallbacks = new Map(); // Map <reqId, [observedSchemaName, callback, options]>
40
+
41
+ this._promiseStore = new PromiseStore();
39
42
 
40
43
  // ---------------------------------------------
41
44
  // CREATE
@@ -53,11 +56,11 @@ class BaseStateManager {
53
56
  const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, true, this, initValues);
54
57
  this._statesById.set(state.id, state);
55
58
 
56
- resolveRequest(reqId, state);
59
+ this._promiseStore.resolve(reqId, state);
57
60
  });
58
61
 
59
62
  this.client.transport.addListener(CREATE_ERROR, (reqId, msg) => {
60
- rejectRequest(reqId, msg);
63
+ this._promiseStore.reject(reqId, msg);
61
64
  });
62
65
 
63
66
  // ---------------------------------------------
@@ -77,11 +80,11 @@ class BaseStateManager {
77
80
  const state = new SharedState(stateId, remoteId, schemaName, schema, this.client, false, this, currentValues);
78
81
  this._statesById.set(state.id, state);
79
82
 
80
- resolveRequest(reqId, state);
83
+ this._promiseStore.resolve(reqId, state);
81
84
  });
82
85
 
83
86
  this.client.transport.addListener(ATTACH_ERROR, (reqId, msg) => {
84
- rejectRequest(reqId, msg);
87
+ this._promiseStore.reject(reqId, msg);
85
88
  });
86
89
 
87
90
  // ---------------------------------------------
@@ -90,11 +93,18 @@ class BaseStateManager {
90
93
  this.client.transport.addListener(OBSERVE_RESPONSE, async (reqId, ...list) => {
91
94
  // retrieve the callback that have been stored in observe to make sure
92
95
  // we don't call another callback that may have been registered earlier.
93
- const [callback, filterSchemaName] = this._observeRequestCallbacks.get(reqId);
96
+ const observeInfos = this._observeRequestCallbacks.get(reqId);
97
+ const [observedSchemaName, callback, options] = observeInfos;
98
+
99
+ // move observeInfos from `_observeRequestCallbacks` to `_observeListeners`
100
+ // to guarantee order of execution, @see not in `.observe`
94
101
  this._observeRequestCallbacks.delete(reqId);
102
+ this._observeListeners.add(observeInfos);
95
103
 
96
104
  const promises = list.map(([schemaName, stateId, nodeId]) => {
97
- if (filterSchemaName === '*' || filterSchemaName === schemaName) {
105
+ const filter = this._filterObserve(observedSchemaName, schemaName, nodeId, options);
106
+
107
+ if (!filter) {
98
108
  return callback(schemaName, stateId, nodeId);
99
109
  } else {
100
110
  return Promise.resolve();
@@ -104,19 +114,29 @@ class BaseStateManager {
104
114
  await Promise.all(promises);
105
115
 
106
116
  const unsubscribe = () => {
107
- this._observeListeners.delete(callback);
108
- // no more listeners, we can stop receiving notification from the server
117
+ this._observeListeners.delete(observeInfos);
118
+
119
+ // no more listeners, we can stop receiving notifications from the server
109
120
  if (this._observeListeners.size === 0) {
110
121
  this.client.transport.emit(UNOBSERVE_NOTIFICATION);
111
122
  }
112
123
  };
113
124
 
114
- resolveRequest(reqId, unsubscribe);
125
+ this._promiseStore.resolve(reqId, unsubscribe);
126
+ });
127
+
128
+ // Observe error can occur if observed schema name does not exists
129
+ this.client.transport.addListener(OBSERVE_ERROR, (reqId, msg) => {
130
+ this._observeRequestCallbacks.delete(reqId);
131
+ this._promiseStore.reject(reqId, msg);
115
132
  });
116
133
 
117
134
  this.client.transport.addListener(OBSERVE_NOTIFICATION, (schemaName, stateId, nodeId) => {
118
- this._observeListeners.forEach((filterSchemaName, callback) => {
119
- if (filterSchemaName === '*' || filterSchemaName === schemaName) {
135
+ this._observeListeners.forEach(observeInfos => {
136
+ const [observedSchemaName, callback, options] = observeInfos;
137
+ const filter = this._filterObserve(observedSchemaName, schemaName, nodeId, options);
138
+
139
+ if (!filter) {
120
140
  callback(schemaName, stateId, nodeId);
121
141
  }
122
142
  });
@@ -130,6 +150,21 @@ class BaseStateManager {
130
150
  });
131
151
  }
132
152
 
153
+ /** @private */
154
+ _filterObserve(observedSchemaName, schemaName, creatorId, options) {
155
+ let filter = true;
156
+ // schema name filter filer
157
+ if (observedSchemaName === null || observedSchemaName === schemaName) {
158
+ filter = false;
159
+ }
160
+ // filter state created by client if excludeLocal is true
161
+ if (options.excludeLocal === true && creatorId === this.client.id) {
162
+ filter = true;
163
+ }
164
+
165
+ return filter;
166
+ }
167
+
133
168
  /**
134
169
  * Create a `SharedState` instance from a registered schema.
135
170
  *
@@ -144,12 +179,24 @@ class BaseStateManager {
144
179
  */
145
180
  async create(schemaName, initValues = {}) {
146
181
  return new Promise((resolve, reject) => {
147
- const reqId = storeRequestPromise(resolve, reject);
182
+ const reqId = this._promiseStore.add(resolve, reject, 'create-request');
148
183
  const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
149
184
  this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, initValues);
150
185
  });
151
186
  }
152
187
 
188
+
189
+ /**
190
+ * @private
191
+ */
192
+ async [kCreateCollectionController](schemaName) {
193
+ return new Promise((resolve, reject) => {
194
+ const reqId = this._promiseStore.add(resolve, reject, 'create-collection-controller-request');
195
+ const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
196
+ this.client.transport.emit(CREATE_REQUEST, reqId, schemaName, requireSchema, {}, true);
197
+ });
198
+ }
199
+
153
200
  /**
154
201
  * Attach to an existing `SharedState` instance.
155
202
  *
@@ -166,13 +213,20 @@ class BaseStateManager {
166
213
  */
167
214
  async attach(schemaName, stateId = null) {
168
215
  return new Promise((resolve, reject) => {
169
- // @todo - add a timeout
170
- const reqId = storeRequestPromise(resolve, reject);
216
+ const reqId = this._promiseStore.add(resolve, reject, 'attach-request');
171
217
  const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
172
218
  this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema);
173
219
  });
174
220
  }
175
221
 
222
+ async [kAttachInCollection](schemaName, stateId) {
223
+ return new Promise((resolve, reject) => {
224
+ const reqId = this._promiseStore.add(resolve, reject, 'attach-in-collection-request');
225
+ const requireSchema = this._cachedSchemas.has(schemaName) ? false : true;
226
+ this.client.transport.emit(ATTACH_REQUEST, reqId, schemaName, stateId, requireSchema, true);
227
+ });
228
+ }
229
+
176
230
  /**
177
231
  * Observe all the `SharedState` instances that are created on the network.
178
232
  * This can be usefull for clients with some controller role that might want to track
@@ -180,20 +234,31 @@ class BaseStateManager {
180
234
  * control over them from a single point.
181
235
  *
182
236
  * Notes:
183
- * - The states that are created by the same node are not propagated through
184
- * the observe callback.
185
- * - The order of execution is not guaranted, i.e. an state attached in the
186
- * `observe` callback could be created before the `async create` method resolves.
237
+ * - The order of execution is not guaranted between nodes, i.e. an state attached
238
+ * in the `observe` callback could be created before the `async create` method resolves.
239
+ * - Filtering, i.e. `observedSchemaName` and `options.excludeLocal` are handled
240
+ * on the node side, the server just notify all state creation activity and
241
+ * the node executes the given callbacks according to the different filter rules.
242
+ * Such strategy allows to share the observe notifications between all observers.
243
+ *
244
+ * Alternative signatures:
245
+ * - .observe(callback)
246
+ * - .observe(schemaName, callback)
247
+ * - .observe(callback, options)
248
+ * - .observe(schemaName, callback, options)
187
249
  *
188
- * @param {String} [schemaName] - optionnal schema name to filter the observed
250
+ * @param {string} [schemaName] - optionnal schema name to filter the observed
189
251
  * states.
190
252
  * @param {server.StateManager~ObserveCallback|client.StateManager~ObserveCallback}
191
253
  * callback - Function to be called when a new state is created on the network.
254
+ * @param {object} options - Options.
255
+ * @param {boolean} [options.excludeLocal = false] - If set to true, exclude states
256
+ * created locallly, i.e. by the same node, from the collection.
192
257
  * @returns {Promise<Function>} - Returns a Promise that resolves when the given
193
258
  * callback as been executed on each existing states. The promise value is a
194
259
  * function which allows to stop observing the states on the network.
195
260
  * @example
196
- * client.stateManager.observe(async (schemaName, stateId, nodeId) => {
261
+ * client.stateManager.observe(async (schemaName, stateId) => {
197
262
  * if (schemaName === 'something') {
198
263
  * const state = await this.client.stateManager.attach(schemaName, stateId);
199
264
  * console.log(state.getValues());
@@ -204,50 +269,128 @@ class BaseStateManager {
204
269
  // handle this way and the network overhead is very low for observe notifications:
205
270
  // i.e. schemaName, stateId, nodeId
206
271
  async observe(...args) {
207
- let filterSchemaName;
272
+
273
+ const defaultOptions = {
274
+ excludeLocal: false,
275
+ };
276
+
277
+ let observedSchemaName;
208
278
  let callback;
279
+ let options;
209
280
 
210
- if (args.length === 1) {
211
- filterSchemaName = '*';
212
- callback = args[0];
281
+ switch (args.length) {
282
+ case 1: {
283
+ // variation: .observe(callback)
284
+ if (!isFunction(args[0])) {
285
+ throw new TypeError(`[stateManager] Invalid arguments, argument 1 should be a function"`);
286
+ }
213
287
 
214
- if (!isFunction(callback)) {
215
- throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
288
+ observedSchemaName = null;
289
+ callback = args[0];
290
+ options = defaultOptions;
291
+ break;
216
292
  }
217
- } else if (args.length === 2) {
218
- filterSchemaName = args[0];
219
- callback = args[1];
293
+ case 2: {
294
+ // variation: .observe(schemaName, callback)
295
+ if (isString(args[0])) {
296
+ if (!isFunction(args[1])) {
297
+ throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be a function"`);
298
+ }
299
+
300
+ observedSchemaName = args[0];
301
+ callback = args[1];
302
+ options = defaultOptions;
303
+
304
+ // variation: .observe(callback, options) API
305
+ } else if (isFunction(args[0])) {
306
+ if (!isPlainObject(args[1])) {
307
+ throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be an object"`);
308
+ }
309
+
310
+ observedSchemaName = null;
311
+ callback = args[0];
312
+ options = Object.assign(defaultOptions, args[1]);
313
+
314
+ } else {
315
+ throw new Error(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`);
316
+ }
220
317
 
221
- if (!isString(filterSchemaName) || !isFunction(callback)) {
222
- throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
318
+ break;
319
+ }
320
+ case 3: {
321
+ // variation: .observe(schemaName, callback, options)
322
+ if (!isString(args[0])) {
323
+ throw new TypeError(`[stateManager] Invalid arguments, argument 1 should be a string"`);
324
+ }
325
+
326
+ if (!isFunction(args[1])) {
327
+ throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be a function"`);
328
+ }
329
+
330
+ if (!isPlainObject(args[2])) {
331
+ throw new TypeError(`[stateManager] Invalid arguments, argument 2 should be an object"`);
332
+ }
333
+
334
+ observedSchemaName = args[0];
335
+ callback = args[1];
336
+ options = Object.assign(defaultOptions, args[2]);
337
+
338
+ break;
339
+ }
340
+ // throw in all other cases
341
+ default: {
342
+ throw new Error(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`);
223
343
  }
224
- } else {
225
- throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
226
344
  }
227
345
 
228
346
  // resend request to get updated list of states
229
347
  return new Promise((resolve, reject) => {
230
- const reqId = storeRequestPromise(resolve, reject);
348
+ const reqId = this._promiseStore.add(resolve, reject, 'observe-request');
231
349
  // store the callback for execution on the response. the returned Promise
232
350
  // is fullfiled once callback has been executed with each existing states
233
- this._observeRequestCallbacks.set(reqId, [callback, filterSchemaName]);
234
- // store the callback for execution on subsequent notifications
235
- this._observeListeners.set(callback, filterSchemaName);
236
-
237
- this.client.transport.emit(OBSERVE_REQUEST, reqId);
351
+ const observeInfos = [observedSchemaName, callback, options];
352
+ this._observeRequestCallbacks.set(reqId, observeInfos);
353
+
354
+ // NOTE: do not store in `_observeListeners` yet as it can produce race
355
+ // conditions, e.g.:
356
+ // ```
357
+ // await client.stateManager.observe(async (schemaName, stateId, nodeId) => {});
358
+ // // client now receives OBSERVE_NOTIFICATIONS
359
+ // await otherClient.stateManager.create('a');
360
+ // // second observer added in between
361
+ // client.stateManager.observe(async (schemaName, stateId, nodeId) => {});
362
+ // ````
363
+ // OBSERVE_NOTIFICATION is received before the OBSERVE_RESPONSE, then the
364
+ // second observer is called twice:
365
+ // - OBSERVE_RESPONSE 1 []
366
+ // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ]
367
+ // - OBSERVE_NOTIFICATION [ 'a', 1, 0 ] // this should not happen
368
+ // - OBSERVE_RESPONSE 1 [ [ 'a', 1, 0 ] ]
369
+ //
370
+ // cf. unit test `observe should properly behave in race condition`
371
+
372
+ this.client.transport.emit(OBSERVE_REQUEST, reqId, observedSchemaName);
238
373
  });
239
374
  }
240
375
 
241
376
  /**
242
- * Returns a collection of all the states created from the schema name. Except
243
- * the ones created by the current node.
377
+ * Returns a collection of all the states created from the schema name.
244
378
  *
245
379
  * @param {string} schemaName - Name of the schema.
380
+ * @param {object} options - Options.
381
+ * @param {boolean} [options.excludeLocal = false] - If set to true, exclude states
382
+ * created locallly, i.e. by the same node, from the collection.
246
383
  * @returns {server.SharedStateCollection|client.SharedStateCollection}
247
384
  */
248
- async getCollection(schemaName) {
249
- const collection = new SharedStateCollection(this, schemaName);
250
- await collection._init();
385
+ async getCollection(schemaName, options) {
386
+ const collection = new SharedStateCollection(this, schemaName, options);
387
+
388
+ try {
389
+ await collection._init();
390
+ } catch (err) {
391
+ console.log(err.message)
392
+ throw new Error(`Cannot create collection, schema "${schemaName}" does not exists`);
393
+ }
251
394
 
252
395
  return collection;
253
396
  }
@@ -1,4 +1,4 @@
1
- import cloneDeep from 'lodash.clonedeep';
1
+ import cloneDeep from 'lodash/cloneDeep.js';
2
2
  import equal from 'fast-deep-equal';
3
3
 
4
4
  export const sharedOptions = {
@@ -394,30 +394,34 @@ class ParameterBag {
394
394
  getSchema(name = null) {
395
395
  if (name === null) {
396
396
  return this._schema;
397
- } else {
398
- if (!this.has(name)) {
399
- throw new ReferenceError(`[SharedState] Cannot get schema description of undefined parameter "${name}"`);
400
- }
397
+ }
401
398
 
402
- return this._schema[name];
399
+ if (!this.has(name)) {
400
+ throw new ReferenceError(`[SharedState] Cannot get schema description of undefined parameter "${name}"`);
403
401
  }
402
+
403
+ return this._schema[name];
404
404
  }
405
405
 
406
406
  // return the default value, if initValue has been given, return init values
407
407
  getInitValues() {
408
408
  const initValues = {};
409
+
409
410
  for (let [name, def] of Object.entries(this._schema)) {
410
411
  initValues[name] = def.initValue;
411
412
  }
413
+
412
414
  return initValues;
413
415
  }
414
416
 
415
417
  // return the default value, if initValue has been given, return init values
416
418
  getDefaults() {
417
419
  const defaults = {};
420
+
418
421
  for (let [name, def] of Object.entries(this._schema)) {
419
- defaults[name] = def.defaults;
422
+ defaults[name] = def.default;
420
423
  }
424
+
421
425
  return defaults;
422
426
  }
423
427
  }
@@ -0,0 +1,41 @@
1
+ import { idGenerator } from '@ircam/sc-utils';
2
+
3
+ const generateRequestId = idGenerator();
4
+
5
+ export default class PromiseStore {
6
+ constructor(name) {
7
+ this.name = name;
8
+ this.store = new Map();
9
+ }
10
+
11
+ add(resolve, reject, type) {
12
+ const reqId = generateRequestId.next().value;
13
+ this.store.set(reqId, { resolve, reject, type });
14
+
15
+ return reqId;
16
+ }
17
+
18
+ resolve(reqId, data) {
19
+ const { resolve } = this.store.get(reqId);
20
+ this.store.delete(reqId);
21
+
22
+ resolve(data);
23
+ }
24
+
25
+ reject(reqId, msg) {
26
+ const { reject } = this.store.get(reqId);
27
+ this.store.delete(reqId);
28
+
29
+ reject(new Error(msg));
30
+ }
31
+
32
+ // reject all pendeing request
33
+ flush() {
34
+ for (let [_reqId, entry] of this.store) {
35
+ const { reject, type } = entry;
36
+ reject(new Error(`[${this.name}] Discard promise "${type}", cannot resolve`));
37
+ }
38
+
39
+ this.store.clear();
40
+ }
41
+ }
@@ -13,13 +13,14 @@ import {
13
13
 
14
14
  /**
15
15
  * The "real" state, this instance is kept private by the server.StateManager.
16
- * It cannot be accessed without a SharedState proxy
16
+ * It can only be accessed through a SharedState proxy.
17
17
  * @private
18
18
  */
19
19
  class SharedStatePrivate {
20
20
  constructor(id, schemaName, schema, manager, initValues = {}) {
21
21
  this.id = id;
22
22
  this.schemaName = schemaName;
23
+ this.isCollectionController = false;
23
24
 
24
25
  this._manager = manager;
25
26
  this._parameters = new ParameterBag(schema, initValues);
@@ -29,10 +30,16 @@ class SharedStatePrivate {
29
30
  this._creatorId = null;
30
31
  }
31
32
 
32
- _attachClient(remoteId, client, isOwner = false) {
33
- this._attachedClients.set(remoteId, client);
33
+ _attachClient(remoteId, client, isOwner = false, inCollection = false) {
34
+
35
+ // isOwner && inCollection -> collection controller
36
+ // !isOwner && inCollection -> attached in collection
37
+
38
+ const clientInfos = { client, isOwner, inCollection };
39
+ this._attachedClients.set(remoteId, clientInfos);
34
40
 
35
41
  if (isOwner) {
42
+ this.isCollectionController = inCollection;
36
43
  this._creatorRemoteId = remoteId;
37
44
  this._creatorId = client.id;
38
45
  }
@@ -89,48 +96,98 @@ class SharedStatePrivate {
89
96
  }
90
97
 
91
98
  if (hasUpdates) {
92
- // send response to requester
93
- // client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates);
94
-
95
- // @note: we propagate server-side last, because as the server transport
96
- // is synchronous it can break ordering if a subscription function makes
97
- // itself an update in reaction to an update, therefore network messages
98
- // order would be broken,
99
-
100
- // we need to handle cases where:
101
- // client state (client.id: 2) sends a request
102
- // server attached state (client.id: -1) spot a problem and overrides the value
103
- // we want the remote client (id: 2) to receive in the right order:
104
- // * 1. the value it requested,
105
- // * 2. the value overriden by the server-side attached state (id: -1)
106
-
107
- // this problem could be solved properly with a reducer system:
108
- // if (dirty) {
109
- // -> call (async) reducer
110
- // -> get values from reducer
111
- //. -> dispatch to everybody
112
- // }
113
-
114
- for (let [peerRemoteId, peer] of this._attachedClients.entries()) {
115
- // propagate notification to all other attached clients except server
116
- if (remoteId !== peerRemoteId && peer.id !== -1) {
117
- peer.transport.emit(`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
99
+ // Collection Controller logic
100
+ if (isOwner && inCollection) {
101
+ // notify the requester back
102
+ // no need to check for attached clients as it is not observable
103
+ client.transport.emit(
104
+ `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
105
+ reqId, filteredUpdates, context
106
+ );
107
+
108
+ // loop through all private states
109
+ for (let [stateId, state] of this._manager._sharedStatePrivateById) {
110
+ // pick all states with same schema name and not this
111
+ if (state !== this && this.schemaName === state.schemaName) {
112
+ // notify all attached clients except those who belong to a collection
113
+ // i.e. !isOwner && inCollection, they will be notified by their own
114
+ // collection controller
115
+ for (let [remoteId, clientInfos] of state._attachedClients) {
116
+ const { client, isOwner, inCollection } = clientInfos;
117
+
118
+ if (!clientInfos.isOwner && clientInfos.inCollection) {
119
+ continue;
120
+ } else {
121
+ const peer = clientInfos.client;
122
+
123
+ peer.transport.emit(
124
+ `${UPDATE_NOTIFICATION}-${state.id}-${remoteId}`,
125
+ filteredUpdates, context
126
+ );
127
+ }
128
+ }
129
+ }
118
130
  }
119
- }
131
+ } else {
120
132
 
121
- if (client.id !== -1) {
122
- client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
123
- }
133
+ // Normal case
134
+
135
+ // We need to handle cases where:
136
+ // - client state (client.id: 2) sends a request
137
+ // - server attached state (client.id: -1) spot a problem and overrides the value
138
+ // We want the remote client (id: 2) to receive in the right order:
139
+ // * 1. the value it requested,
140
+ // * 2. the value overriden by the server-side attached state (id: -1)
141
+ //
142
+ // such problem is now better solved with the the upateHook system, none
143
+ // nonetheway we don't want to introduce inconsistencies here
144
+ //
145
+ // Then we propagate server-side last, because as the server transport
146
+ // is synchronous it can break ordering if a subscription function makes
147
+ // itself an update in reaction to an update. Propagating to server last
148
+ // alllows to maintain network messages order consistent.
149
+
150
+ // @note - remoteId correspond to unique remote state id
151
+
152
+ // propagate RESPONSE to the client that originates the request if not the server
153
+ if (client.id !== -1) {
154
+ client.transport.emit(
155
+ `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
156
+ reqId, filteredUpdates, context
157
+ );
158
+ }
124
159
 
125
- for (let [peerRemoteId, peer] of this._attachedClients.entries()) {
126
- // propagate notification to server
127
- if (remoteId !== peerRemoteId && peer.id === -1) {
128
- peer.transport.emit(`${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`, filteredUpdates, context);
160
+ // propagate NOTIFICATION to all other attached clients except server
161
+ for (let [peerRemoteId, clientInfos] of this._attachedClients) {
162
+ const peer = clientInfos.client;
163
+
164
+ if (remoteId !== peerRemoteId && peer.id !== -1) {
165
+ peer.transport.emit(
166
+ `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
167
+ filteredUpdates, context
168
+ );
169
+ }
129
170
  }
130
- }
131
171
 
132
- if (client.id === -1) {
133
- client.transport.emit(`${UPDATE_RESPONSE}-${this.id}-${remoteId}`, reqId, filteredUpdates, context);
172
+ // propagate RESPONSE to server if it is the requester
173
+ if (client.id === -1) {
174
+ client.transport.emit(
175
+ `${UPDATE_RESPONSE}-${this.id}-${remoteId}`,
176
+ reqId, filteredUpdates, context
177
+ );
178
+ }
179
+
180
+ // propagate NOTIFICATION other attached state that belongs to server
181
+ for (let [peerRemoteId, clientInfos] of this._attachedClients) {
182
+ const peer = clientInfos.client;
183
+
184
+ if (remoteId !== peerRemoteId && peer.id === -1) {
185
+ peer.transport.emit(
186
+ `${UPDATE_NOTIFICATION}-${this.id}-${peerRemoteId}`,
187
+ filteredUpdates, context
188
+ );
189
+ }
190
+ }
134
191
  }
135
192
  } else {
136
193
  // propagate back to the requester that the update has been aborted
@@ -152,14 +209,15 @@ class SharedStatePrivate {
152
209
  if (isOwner) {
153
210
  // delete only if creator
154
211
  client.transport.addListener(`${DELETE_REQUEST}-${this.id}-${remoteId}`, (reqId) => {
155
- this._manager._serverStatesById.delete(this.id);
212
+ this._manager._sharedStatePrivateById.delete(this.id);
156
213
  // --------------------------------------------------------------------
157
214
  // WARNING - MAKE SURE WE DON'T HAVE PROBLEM W/ THAT
158
215
  // --------------------------------------------------------------------
159
216
  // @todo - propagate server-side last, because if a subscription function sends a
160
217
  // message to a client, network messages order are kept coherent
161
218
  // this._subscriptions.forEach(func => func(updated));
162
- for (let [remoteId, attached] of this._attachedClients.entries()) {
219
+ for (let [remoteId, clientInfos] of this._attachedClients) {
220
+ const attached = clientInfos.client;
163
221
  this._detachClient(remoteId, attached);
164
222
 
165
223
  if (remoteId === this._creatorRemoteId) {