@webex/plugin-meetings 3.11.0-webex-services-ready.1 → 3.12.0-mobius-socket.1

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.
Files changed (171) hide show
  1. package/dist/aiEnableRequest/index.js +184 -0
  2. package/dist/aiEnableRequest/index.js.map +1 -0
  3. package/dist/aiEnableRequest/utils.js +36 -0
  4. package/dist/aiEnableRequest/utils.js.map +1 -0
  5. package/dist/annotation/index.js +14 -5
  6. package/dist/annotation/index.js.map +1 -1
  7. package/dist/breakouts/breakout.js +1 -1
  8. package/dist/breakouts/index.js +1 -1
  9. package/dist/config.js +7 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.js +28 -6
  12. package/dist/constants.js.map +1 -1
  13. package/dist/hashTree/constants.js +3 -1
  14. package/dist/hashTree/constants.js.map +1 -1
  15. package/dist/hashTree/hashTree.js +18 -0
  16. package/dist/hashTree/hashTree.js.map +1 -1
  17. package/dist/hashTree/hashTreeParser.js +868 -419
  18. package/dist/hashTree/hashTreeParser.js.map +1 -1
  19. package/dist/hashTree/types.js +4 -2
  20. package/dist/hashTree/types.js.map +1 -1
  21. package/dist/hashTree/utils.js +10 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +11 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/constant.js +12 -0
  26. package/dist/interceptors/constant.js.map +1 -0
  27. package/dist/interceptors/dataChannelAuthToken.js +290 -0
  28. package/dist/interceptors/dataChannelAuthToken.js.map +1 -0
  29. package/dist/interceptors/index.js +7 -0
  30. package/dist/interceptors/index.js.map +1 -1
  31. package/dist/interceptors/utils.js +27 -0
  32. package/dist/interceptors/utils.js.map +1 -0
  33. package/dist/interpretation/index.js +2 -2
  34. package/dist/interpretation/index.js.map +1 -1
  35. package/dist/interpretation/siLanguage.js +1 -1
  36. package/dist/locus-info/controlsUtils.js +5 -3
  37. package/dist/locus-info/controlsUtils.js.map +1 -1
  38. package/dist/locus-info/index.js +522 -131
  39. package/dist/locus-info/index.js.map +1 -1
  40. package/dist/locus-info/selfUtils.js +1 -0
  41. package/dist/locus-info/selfUtils.js.map +1 -1
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/MediaConnectionAwaiter.js +57 -1
  44. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  45. package/dist/media/properties.js +4 -2
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +7 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1293 -929
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/request.js +50 -0
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/request.type.js.map +1 -1
  54. package/dist/meeting/util.js +133 -3
  55. package/dist/meeting/util.js.map +1 -1
  56. package/dist/meetings/index.js +117 -48
  57. package/dist/meetings/index.js.map +1 -1
  58. package/dist/member/index.js +10 -0
  59. package/dist/member/index.js.map +1 -1
  60. package/dist/member/util.js +10 -0
  61. package/dist/member/util.js.map +1 -1
  62. package/dist/metrics/constants.js +6 -1
  63. package/dist/metrics/constants.js.map +1 -1
  64. package/dist/multistream/mediaRequestManager.js +9 -60
  65. package/dist/multistream/mediaRequestManager.js.map +1 -1
  66. package/dist/multistream/remoteMediaManager.js +11 -0
  67. package/dist/multistream/remoteMediaManager.js.map +1 -1
  68. package/dist/multistream/sendSlotManager.js +116 -2
  69. package/dist/multistream/sendSlotManager.js.map +1 -1
  70. package/dist/reactions/reactions.type.js.map +1 -1
  71. package/dist/reconnection-manager/index.js +0 -1
  72. package/dist/reconnection-manager/index.js.map +1 -1
  73. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  74. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  75. package/dist/types/config.d.ts +4 -0
  76. package/dist/types/constants.d.ts +23 -1
  77. package/dist/types/hashTree/constants.d.ts +1 -0
  78. package/dist/types/hashTree/hashTree.d.ts +7 -0
  79. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  80. package/dist/types/hashTree/types.d.ts +3 -0
  81. package/dist/types/hashTree/utils.d.ts +6 -0
  82. package/dist/types/index.d.ts +1 -0
  83. package/dist/types/interceptors/constant.d.ts +5 -0
  84. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  85. package/dist/types/interceptors/index.d.ts +2 -1
  86. package/dist/types/interceptors/utils.d.ts +1 -0
  87. package/dist/types/locus-info/index.d.ts +60 -8
  88. package/dist/types/locus-info/types.d.ts +7 -0
  89. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  90. package/dist/types/media/properties.d.ts +2 -1
  91. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  92. package/dist/types/meeting/index.d.ts +72 -7
  93. package/dist/types/meeting/request.d.ts +16 -1
  94. package/dist/types/meeting/request.type.d.ts +5 -0
  95. package/dist/types/meeting/util.d.ts +31 -0
  96. package/dist/types/meetings/index.d.ts +4 -2
  97. package/dist/types/member/index.d.ts +1 -0
  98. package/dist/types/member/util.d.ts +5 -0
  99. package/dist/types/metrics/constants.d.ts +5 -0
  100. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  101. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  102. package/dist/types/reactions/reactions.type.d.ts +1 -0
  103. package/dist/types/webinar/utils.d.ts +6 -0
  104. package/dist/webinar/index.js +438 -163
  105. package/dist/webinar/index.js.map +1 -1
  106. package/dist/webinar/utils.js +25 -0
  107. package/dist/webinar/utils.js.map +1 -0
  108. package/package.json +24 -23
  109. package/src/aiEnableRequest/README.md +84 -0
  110. package/src/aiEnableRequest/index.ts +170 -0
  111. package/src/aiEnableRequest/utils.ts +25 -0
  112. package/src/annotation/index.ts +27 -7
  113. package/src/config.ts +4 -0
  114. package/src/constants.ts +29 -1
  115. package/src/hashTree/constants.ts +1 -0
  116. package/src/hashTree/hashTree.ts +17 -0
  117. package/src/hashTree/hashTreeParser.ts +761 -260
  118. package/src/hashTree/types.ts +4 -0
  119. package/src/hashTree/utils.ts +9 -0
  120. package/src/index.ts +8 -1
  121. package/src/interceptors/constant.ts +6 -0
  122. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  123. package/src/interceptors/index.ts +2 -1
  124. package/src/interceptors/utils.ts +16 -0
  125. package/src/interpretation/index.ts +2 -2
  126. package/src/locus-info/controlsUtils.ts +11 -0
  127. package/src/locus-info/index.ts +579 -113
  128. package/src/locus-info/selfUtils.ts +1 -0
  129. package/src/locus-info/types.ts +8 -0
  130. package/src/media/MediaConnectionAwaiter.ts +41 -1
  131. package/src/media/properties.ts +3 -1
  132. package/src/meeting/in-meeting-actions.ts +12 -0
  133. package/src/meeting/index.ts +372 -86
  134. package/src/meeting/request.ts +42 -0
  135. package/src/meeting/request.type.ts +6 -0
  136. package/src/meeting/util.ts +160 -2
  137. package/src/meetings/index.ts +157 -44
  138. package/src/member/index.ts +10 -0
  139. package/src/member/util.ts +12 -0
  140. package/src/metrics/constants.ts +6 -0
  141. package/src/multistream/mediaRequestManager.ts +4 -54
  142. package/src/multistream/remoteMediaManager.ts +13 -0
  143. package/src/multistream/sendSlotManager.ts +97 -3
  144. package/src/reactions/reactions.type.ts +1 -0
  145. package/src/reconnection-manager/index.ts +0 -1
  146. package/src/webinar/index.ts +265 -6
  147. package/src/webinar/utils.ts +16 -0
  148. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  149. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  150. package/test/unit/spec/annotation/index.ts +69 -7
  151. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  152. package/test/unit/spec/hashTree/hashTreeParser.ts +2321 -175
  153. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  154. package/test/unit/spec/interceptors/utils.ts +75 -0
  155. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  156. package/test/unit/spec/locus-info/index.js +1134 -55
  157. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  158. package/test/unit/spec/media/properties.ts +12 -3
  159. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  160. package/test/unit/spec/meeting/index.js +829 -121
  161. package/test/unit/spec/meeting/request.js +70 -0
  162. package/test/unit/spec/meeting/utils.js +438 -26
  163. package/test/unit/spec/meetings/index.js +653 -32
  164. package/test/unit/spec/member/index.js +28 -4
  165. package/test/unit/spec/member/util.js +65 -27
  166. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  167. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  168. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  169. package/test/unit/spec/reconnection-manager/index.js +4 -8
  170. package/test/unit/spec/webinar/index.ts +534 -37
  171. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -5,7 +5,7 @@ import {Enum, HTTP_VERBS} from '../constants';
5
5
  import {DataSetNames, EMPTY_HASH} from './constants';
6
6
  import {ObjectType, HtMeta, HashTreeObject} from './types';
7
7
  import {LocusDTO} from '../locus-info/types';
8
- import {deleteNestedObjectsWithHtMeta, isSelf} from './utils';
8
+ import {deleteNestedObjectsWithHtMeta, isMetadata} from './utils';
9
9
 
10
10
  export interface DataSet {
11
11
  url: string;
@@ -29,11 +29,24 @@ export interface HashTreeMessage {
29
29
  locusStateElements?: Array<HashTreeObject>;
30
30
  locusSessionId?: string;
31
31
  locusUrl: string;
32
+ heartbeatIntervalMs?: number;
33
+ }
34
+
35
+ export interface VisibleDataSetInfo {
36
+ name: string;
37
+ url: string;
38
+ dataChannelUrl?: string;
39
+ }
40
+
41
+ export interface Metadata {
42
+ htMeta: HtMeta;
43
+ visibleDataSets: VisibleDataSetInfo[];
32
44
  }
33
45
 
34
46
  interface InternalDataSet extends DataSet {
35
47
  hashTree?: HashTree; // set only for visible data sets
36
48
  timer?: ReturnType<typeof setTimeout>;
49
+ heartbeatWatchdogTimer?: ReturnType<typeof setTimeout>;
37
50
  }
38
51
 
39
52
  type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
@@ -49,11 +62,29 @@ export type LocusInfoUpdateCallback = (
49
62
  data?: {updatedObjects: HashTreeObject[]}
50
63
  ) => void;
51
64
 
65
+ interface LeafInfo {
66
+ type: ObjectType;
67
+ id: number;
68
+ version: number;
69
+ data?: any;
70
+ }
71
+
52
72
  /**
53
73
  * This error is thrown if we receive information that the meeting has ended while we're processing some hash messages.
54
74
  * It's handled internally by HashTreeParser and results in MEETING_ENDED being sent up.
55
75
  */
56
- class MeetingEndedError extends Error {}
76
+ export class MeetingEndedError extends Error {}
77
+
78
+ /* Currently Locus always sends Metadata objects only in the "self" dataset.
79
+ * If this ever changes, update all the code that relies on this constant.
80
+ */
81
+ const MetadataDataSetName = DataSetNames.SELF;
82
+
83
+ const PossibleSentinelMessageDataSetNames = [
84
+ DataSetNames.MAIN,
85
+ DataSetNames.SELF,
86
+ DataSetNames.UNJOINED,
87
+ ];
57
88
 
58
89
  /**
59
90
  * Parses hash tree eventing locus data
@@ -63,8 +94,11 @@ class HashTreeParser {
63
94
  visibleDataSetsUrl: string; // url from which we can get info about all data sets
64
95
  webexRequest: WebexRequestMethod;
65
96
  locusInfoUpdateCallback: LocusInfoUpdateCallback;
66
- visibleDataSets: string[];
97
+ visibleDataSets: VisibleDataSetInfo[];
67
98
  debugId: string;
99
+ heartbeatIntervalMs?: number;
100
+ private excludedDataSets: string[];
101
+ state: 'active' | 'stopped';
68
102
 
69
103
  /**
70
104
  * Constructor for HashTreeParser
@@ -76,29 +110,42 @@ class HashTreeParser {
76
110
  dataSets: Array<DataSet>;
77
111
  locus: any;
78
112
  };
113
+ metadata: Metadata | null;
79
114
  webexRequest: WebexRequestMethod;
80
115
  locusInfoUpdateCallback: LocusInfoUpdateCallback;
81
116
  debugId: string;
117
+ excludedDataSets?: string[];
82
118
  }) {
83
119
  const {dataSets, locus} = options.initialLocus; // extract dataSets from initialLocus
84
120
 
85
121
  this.debugId = options.debugId;
86
122
  this.webexRequest = options.webexRequest;
87
123
  this.locusInfoUpdateCallback = options.locusInfoUpdateCallback;
88
- this.visibleDataSets = locus?.self?.visibleDataSets || [];
124
+ this.excludedDataSets = options.excludedDataSets || [];
125
+ this.visibleDataSetsUrl = locus?.links?.resources?.visibleDataSets?.url;
126
+ this.setVisibleDataSets(options.metadata?.visibleDataSets || [], dataSets);
127
+
128
+ this.state = 'active';
89
129
 
90
- if (this.visibleDataSets.length === 0) {
130
+ if (options.metadata?.visibleDataSets?.length === 0) {
91
131
  LoggerProxy.logger.warn(
92
- `HashTreeParser#constructor --> ${this.debugId} No visibleDataSets found in locus.self`
132
+ `HashTreeParser#constructor --> ${this.debugId} No visibleDataSets found in Metadata`
93
133
  );
94
134
  }
95
135
  // object mapping dataset names to arrays of leaf data
96
136
  const leafData = this.analyzeLocusHtMeta(locus);
97
137
 
138
+ if (options.metadata) {
139
+ // add also the metadata that's outside of locus object itself
140
+ this.analyzeMetadata(leafData, options.metadata);
141
+ }
142
+
98
143
  LoggerProxy.logger.info(
99
- `HashTreeParser#constructor --> creating HashTreeParser for datasets: ${JSON.stringify(
144
+ `HashTreeParser#constructor --> ${
145
+ this.debugId
146
+ } creating HashTreeParser for datasets: ${JSON.stringify(
100
147
  dataSets.map((ds) => ds.name)
101
- )}`
148
+ )} with visible datasets: ${JSON.stringify(this.visibleDataSets.map((vds) => vds.name))}`
102
149
  );
103
150
 
104
151
  for (const dataSet of dataSets) {
@@ -106,46 +153,105 @@ class HashTreeParser {
106
153
 
107
154
  this.dataSets[name] = {
108
155
  ...dataSet,
109
- hashTree: this.visibleDataSets.includes(name)
156
+ hashTree: this.isVisibleDataSet(name)
110
157
  ? new HashTree(leafData[name] || [], leafCount)
111
158
  : undefined,
112
159
  };
113
160
  }
114
161
  }
115
162
 
163
+ /**
164
+ * Sets the visible data sets list for the HashTreeParser. This method should be called only at the start, to initialize
165
+ * the visible data sets, before any message processsing, so for example from the constructor or when resuming the parser.
166
+ *
167
+ * @param {Array<VisibleDataSetInfo>} visibleDataSets - The visible data sets to set
168
+ * @param {Array<DataSet>} dataSets - The "dataSets" list from Locus (yes, Locus sends visibleDataSets and dataSets as separate lists and they can differ)
169
+ * @returns {void}
170
+ */
171
+ private setVisibleDataSets(visibleDataSets: VisibleDataSetInfo[], dataSets: Array<DataSet>) {
172
+ this.visibleDataSets = cloneDeep(visibleDataSets).filter(
173
+ (vds) =>
174
+ // exclude data sets we will never care about
175
+ !this.isExcludedDataSet(vds.name) &&
176
+ // and make sure that visibleDataSets list is consistent with dataSets list
177
+ dataSets.some((ds) => ds.name === vds.name)
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Checks if the given data set name is in the list of visible data sets
183
+ * @param {string} dataSetName data set name to check
184
+ * @returns {Boolean} True if the data set is visible, false otherwise
185
+ */
186
+ private isVisibleDataSet(dataSetName: string): boolean {
187
+ return this.visibleDataSets.some((vds) => vds.name === dataSetName);
188
+ }
189
+
190
+ /**
191
+ * Checks if the given data set name is in the excluded list
192
+ * @param {string} dataSetName data set name to check
193
+ * @returns {boolean} True if the data set is excluded, false otherwise
194
+ */
195
+ private isExcludedDataSet(dataSetName: string): boolean {
196
+ return this.excludedDataSets.some((name) => name === dataSetName);
197
+ }
198
+
199
+ /**
200
+ * Adds a data set to the visible data sets list, unless it is in the excluded list.
201
+ * @param {VisibleDataSetInfo} dataSetInfo data set info to add
202
+ * @returns {boolean} True if the data set was added, false if it was excluded
203
+ */
204
+ private addToVisibleDataSetsList(dataSetInfo: VisibleDataSetInfo): boolean {
205
+ if (this.isExcludedDataSet(dataSetInfo.name)) {
206
+ LoggerProxy.logger.info(
207
+ `HashTreeParser#addToVisibleDataSetsList --> ${this.debugId} Data set "${dataSetInfo.name}" is in the excluded list, ignoring`
208
+ );
209
+
210
+ return false;
211
+ }
212
+
213
+ this.visibleDataSets.push(dataSetInfo);
214
+
215
+ return true;
216
+ }
217
+
116
218
  /**
117
219
  * Initializes a new visible data set by creating a hash tree for it, adding it to all the internal structures,
118
220
  * and sending an initial sync request to Locus with empty leaf data - that will trigger Locus to gives us all the data
119
221
  * from that dataset (in the response or via messages).
120
222
  *
121
- * @param {DataSet} dataSet The new data set to be added
223
+ * @param {VisibleDataSetInfo} visibleDataSetInfo Information about the new visible data set
224
+ * @param {DataSet} dataSetInfo The new data set to be added
122
225
  * @returns {Promise}
123
226
  */
124
227
  private initializeNewVisibleDataSet(
125
- dataSet: DataSet
228
+ visibleDataSetInfo: VisibleDataSetInfo,
229
+ dataSetInfo: DataSet
126
230
  ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
127
- if (this.visibleDataSets.includes(dataSet.name)) {
231
+ if (this.isVisibleDataSet(dataSetInfo.name)) {
128
232
  LoggerProxy.logger.info(
129
- `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Data set "${dataSet.name}" already exists, skipping init`
233
+ `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Data set "${dataSetInfo.name}" already exists, skipping init`
130
234
  );
131
235
 
132
236
  return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
133
237
  }
134
238
 
135
239
  LoggerProxy.logger.info(
136
- `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Adding visible data set "${dataSet.name}"`
240
+ `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Adding visible data set "${dataSetInfo.name}"`
137
241
  );
138
242
 
139
- this.visibleDataSets.push(dataSet.name);
243
+ if (!this.addToVisibleDataSetsList(visibleDataSetInfo)) {
244
+ return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
245
+ }
140
246
 
141
- const hashTree = new HashTree([], dataSet.leafCount);
247
+ const hashTree = new HashTree([], dataSetInfo.leafCount);
142
248
 
143
- this.dataSets[dataSet.name] = {
144
- ...dataSet,
249
+ this.dataSets[dataSetInfo.name] = {
250
+ ...dataSetInfo,
145
251
  hashTree,
146
252
  };
147
253
 
148
- return this.sendInitializationSyncRequestToLocus(dataSet.name, 'new visible data set');
254
+ return this.sendInitializationSyncRequestToLocus(dataSetInfo.name, 'new visible data set');
149
255
  }
150
256
 
151
257
  /**
@@ -178,10 +284,13 @@ class HashTreeParser {
178
284
  return this.sendSyncRequestToLocus(this.dataSets[datasetName], emptyLeavesData).then(
179
285
  (syncResponse) => {
180
286
  if (syncResponse) {
181
- return this.parseMessage(
182
- syncResponse,
183
- `via empty leaves /sync API call for ${debugText}`
184
- );
287
+ return {
288
+ updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
289
+ updatedObjects: this.parseMessage(
290
+ syncResponse,
291
+ `via empty leaves /sync API call for ${debugText}`
292
+ ),
293
+ };
185
294
  }
186
295
 
187
296
  return {updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []};
@@ -190,18 +299,31 @@ class HashTreeParser {
190
299
  }
191
300
 
192
301
  /**
193
- * Queries Locus for information about all the data sets
302
+ * Queries Locus for all up-to-date information about all visible data sets
194
303
  *
195
- * @param {string} url - url from which we can get info about all data sets
196
304
  * @returns {Promise}
197
305
  */
198
- private getAllDataSetsMetadata(url) {
306
+ private getAllVisibleDataSetsFromLocus() {
307
+ if (!this.visibleDataSetsUrl) {
308
+ LoggerProxy.logger.warn(
309
+ `HashTreeParser#getAllVisibleDataSetsFromLocus --> ${this.debugId} No visibleDataSetsUrl, cannot get data sets information`
310
+ );
311
+
312
+ return Promise.resolve([]);
313
+ }
314
+
199
315
  return this.webexRequest({
200
316
  method: HTTP_VERBS.GET,
201
- uri: url,
202
- }).then((response) => {
203
- return response.body.dataSets as Array<DataSet>;
204
- });
317
+ uri: this.visibleDataSetsUrl,
318
+ })
319
+ .then((response) => {
320
+ return response.body.dataSets as Array<DataSet>;
321
+ })
322
+ .catch((error) => {
323
+ this.checkForSentinelHttpResponse(error);
324
+
325
+ throw error;
326
+ });
205
327
  }
206
328
 
207
329
  /**
@@ -211,12 +333,14 @@ class HashTreeParser {
211
333
  * @returns {Promise}
212
334
  */
213
335
  async initializeFromMessage(message: HashTreeMessage) {
336
+ this.visibleDataSetsUrl = message.visibleDataSetsUrl;
337
+
214
338
  LoggerProxy.logger.info(
215
- `HashTreeParser#initializeFromMessage --> ${this.debugId} visibleDataSetsUrl=${message.visibleDataSetsUrl}`
339
+ `HashTreeParser#initializeFromMessage --> ${this.debugId} visibleDataSetsUrl=${this.visibleDataSetsUrl}`
216
340
  );
217
- const dataSets = await this.getAllDataSetsMetadata(message.visibleDataSetsUrl);
341
+ const visibleDataSets = await this.getAllVisibleDataSetsFromLocus();
218
342
 
219
- await this.initializeDataSets(dataSets, 'initialization from message');
343
+ await this.initializeDataSets(visibleDataSets, 'initialization from message');
220
344
  }
221
345
 
222
346
  /**
@@ -236,28 +360,32 @@ class HashTreeParser {
236
360
 
237
361
  return;
238
362
  }
363
+ this.visibleDataSetsUrl = locus.links.resources.visibleDataSets.url;
239
364
 
240
365
  LoggerProxy.logger.info(
241
- `HashTreeParser#initializeFromGetLociResponse --> ${this.debugId} visibleDataSets url: ${locus.links.resources.visibleDataSets.url}`
366
+ `HashTreeParser#initializeFromGetLociResponse --> ${this.debugId} visibleDataSets url: ${this.visibleDataSetsUrl}`
242
367
  );
243
368
 
244
- const dataSets = await this.getAllDataSetsMetadata(locus.links.resources.visibleDataSets.url);
369
+ const visibleDataSets = await this.getAllVisibleDataSetsFromLocus();
245
370
 
246
- await this.initializeDataSets(dataSets, 'initialization from GET /loci response');
371
+ await this.initializeDataSets(visibleDataSets, 'initialization from GET /loci response');
247
372
  }
248
373
 
249
374
  /**
250
375
  * Initializes data sets by doing an initialization sync on each visible data set that doesn't have a hash tree yet.
251
376
  *
252
- * @param {DataSet[]} dataSets Array of DataSet objects to initialize
377
+ * @param {DataSet[]} visibleDataSets Array of visible DataSet objects to initialize
253
378
  * @param {string} debugText Text to include in logs for debugging purposes
254
379
  * @returns {Promise}
255
380
  */
256
- private async initializeDataSets(dataSets: Array<DataSet>, debugText: string) {
381
+ private async initializeDataSets(visibleDataSets: Array<DataSet>, debugText: string) {
382
+ if (this.state === 'stopped') {
383
+ return;
384
+ }
257
385
  const updatedObjects: HashTreeObject[] = [];
258
386
 
259
- for (const dataSet of dataSets) {
260
- const {name, leafCount} = dataSet;
387
+ for (const dataSet of visibleDataSets) {
388
+ const {name, leafCount, url} = dataSet;
261
389
 
262
390
  if (!this.dataSets[name]) {
263
391
  LoggerProxy.logger.info(
@@ -273,7 +401,20 @@ class HashTreeParser {
273
401
  );
274
402
  }
275
403
 
276
- if (this.visibleDataSets.includes(name) && !this.dataSets[name].hashTree) {
404
+ if (!this.isVisibleDataSet(name)) {
405
+ if (
406
+ !this.addToVisibleDataSetsList({
407
+ name,
408
+ url,
409
+ })
410
+ ) {
411
+ // dataset is excluded, skip it
412
+ // eslint-disable-next-line no-continue
413
+ continue;
414
+ }
415
+ }
416
+
417
+ if (!this.dataSets[name].hashTree) {
277
418
  LoggerProxy.logger.info(
278
419
  `HashTreeParser#initializeDataSets --> ${this.debugId} creating hash tree for visible dataset "${name}" (${debugText})`
279
420
  );
@@ -282,15 +423,6 @@ class HashTreeParser {
282
423
  // eslint-disable-next-line no-await-in-loop
283
424
  const data = await this.sendInitializationSyncRequestToLocus(name, debugText);
284
425
 
285
- if (data.updateType === LocusInfoUpdateType.MEETING_ENDED) {
286
- LoggerProxy.logger.warn(
287
- `HashTreeParser#initializeDataSets --> ${this.debugId} meeting ended while initializing new visible data set "${name}"`
288
- );
289
-
290
- // throw an error, it will be caught higher up and the meeting will be destroyed
291
- throw new MeetingEndedError();
292
- }
293
-
294
426
  if (data.updateType === LocusInfoUpdateType.OBJECTS_UPDATED) {
295
427
  updatedObjects.push(...(data.updatedObjects || []));
296
428
  }
@@ -316,12 +448,9 @@ class HashTreeParser {
316
448
  private analyzeLocusHtMeta(locus: any, options?: {copyData?: boolean}) {
317
449
  const {copyData = false} = options || {};
318
450
  // object mapping dataset names to arrays of leaf data
319
- const leafInfo: Record<
320
- string,
321
- Array<{type: ObjectType; id: number; version: number; data?: any}>
322
- > = {};
451
+ const leafInfo: Record<string, Array<LeafInfo>> = {};
323
452
 
324
- const findAndStoreMetaData = (currentLocusPart: any) => {
453
+ const findAndStoreMetaData = (currentLocusPart: any, currentLocusPartName: string) => {
325
454
  if (typeof currentLocusPart !== 'object' || currentLocusPart === null) {
326
455
  return;
327
456
  }
@@ -329,17 +458,25 @@ class HashTreeParser {
329
458
  if (currentLocusPart.htMeta && currentLocusPart.htMeta.dataSetNames) {
330
459
  const {type, id, version} = currentLocusPart.htMeta.elementId;
331
460
  const {dataSetNames} = currentLocusPart.htMeta;
332
- const newLeafInfo: {type: ObjectType; id: number; version: number; data?: any} = {
461
+ const newLeafInfo: LeafInfo = {
333
462
  type,
334
463
  id,
335
464
  version,
336
465
  };
337
466
 
338
467
  if (copyData) {
339
- newLeafInfo.data = cloneDeep(currentLocusPart);
468
+ if ((type as string).toLowerCase() === ObjectType.control) {
469
+ // control entries require special handling, because they are signalled by Locus
470
+ // differently when coming in messages vs API responses
471
+ newLeafInfo.data = {
472
+ [currentLocusPartName]: cloneDeep(currentLocusPart),
473
+ };
474
+ } else {
475
+ newLeafInfo.data = cloneDeep(currentLocusPart);
340
476
 
341
- // remove any nested other objects that have their own htMeta
342
- deleteNestedObjectsWithHtMeta(newLeafInfo.data);
477
+ // remove any nested other objects that have their own htMeta
478
+ deleteNestedObjectsWithHtMeta(newLeafInfo.data);
479
+ }
343
480
  }
344
481
 
345
482
  for (const dataSetName of dataSetNames) {
@@ -351,45 +488,80 @@ class HashTreeParser {
351
488
  }
352
489
 
353
490
  if (Array.isArray(currentLocusPart)) {
354
- for (const item of currentLocusPart) {
355
- findAndStoreMetaData(item);
491
+ for (const [index, item] of currentLocusPart.entries()) {
492
+ findAndStoreMetaData(item, index.toString());
356
493
  }
357
494
  } else {
358
495
  for (const key of Object.keys(currentLocusPart)) {
359
496
  if (Object.prototype.hasOwnProperty.call(currentLocusPart, key)) {
360
- findAndStoreMetaData(currentLocusPart[key]);
497
+ findAndStoreMetaData(currentLocusPart[key], key);
361
498
  }
362
499
  }
363
500
  }
364
501
  };
365
502
 
366
- findAndStoreMetaData(locus);
503
+ findAndStoreMetaData(locus, 'locus');
367
504
 
368
505
  return leafInfo;
369
506
  }
370
507
 
371
508
  /**
372
- * Checks if the provided hash tree message indicates the end of the meeting and that there won't be any more updates.
509
+ * Analyzes the Metadata object that is sent outside of Locus object, and appends its data to passed in leafInfo
510
+ * structure.
373
511
  *
374
- * @param {HashTreeMessage} message - The hash tree message to check
375
- * @returns {boolean} - Returns true if the message indicates the end of the meeting, false otherwise
512
+ * @param {Record<string, LeafInfo[]>} leafInfo the structure to which the Metadata info will be appended
513
+ * @param {Metadata} metadata Metadata object
514
+ * @returns {void}
376
515
  */
377
- private isEndMessage(message: HashTreeMessage) {
378
- const mainDataSet = message.dataSets.find(
379
- (dataSet) => dataSet.name.toLowerCase() === DataSetNames.MAIN
380
- );
516
+ private analyzeMetadata(leafInfo: Record<string, LeafInfo[]>, metadata: Metadata) {
517
+ const {htMeta} = metadata;
381
518
 
382
519
  if (
383
- mainDataSet &&
384
- mainDataSet.leafCount === 1 &&
385
- mainDataSet.root === EMPTY_HASH &&
386
- this.dataSets[DataSetNames.MAIN].version < mainDataSet.version
520
+ htMeta?.dataSetNames?.length === 1 &&
521
+ htMeta.dataSetNames[0].toLowerCase() === MetadataDataSetName
387
522
  ) {
388
- // this is a special way for Locus to indicate that this meeting has ended
389
- return true;
523
+ const {type, id, version} = metadata.htMeta.elementId;
524
+
525
+ const dataSetName = htMeta.dataSetNames[0];
526
+
527
+ if (!leafInfo[dataSetName]) {
528
+ leafInfo[dataSetName] = [];
529
+ }
530
+
531
+ leafInfo[dataSetName].push({
532
+ type,
533
+ id,
534
+ version,
535
+ });
536
+ } else {
537
+ throw new Error(
538
+ `${this.debugId} Metadata htMeta has unexpected dataSetNames: ${
539
+ htMeta && htMeta.dataSetNames.join(',')
540
+ }`
541
+ );
390
542
  }
543
+ }
544
+
545
+ /**
546
+ * Checks if the provided hash tree message indicates the end of the meeting and that there won't be any more updates.
547
+ *
548
+ * @param {HashTreeMessage} message - The hash tree message to check
549
+ * @returns {boolean} - Returns true if the message indicates the end of the meeting, false otherwise
550
+ */
551
+ private isEndMessage(message: HashTreeMessage) {
552
+ return message.dataSets.some((dataSet) => {
553
+ if (
554
+ dataSet.leafCount === 1 &&
555
+ dataSet.root === EMPTY_HASH &&
556
+ (!this.dataSets[dataSet.name] || this.dataSets[dataSet.name].version < dataSet.version) &&
557
+ PossibleSentinelMessageDataSetNames.includes(dataSet.name.toLowerCase())
558
+ ) {
559
+ // this is a special way for Locus to indicate that this meeting has ended
560
+ return true;
561
+ }
391
562
 
392
- return false;
563
+ return false;
564
+ });
393
565
  }
394
566
 
395
567
  /**
@@ -420,6 +592,90 @@ class HashTreeParser {
420
592
  });
421
593
  }
422
594
 
595
+ /**
596
+ * Asynchronously initializes new visible data sets
597
+ *
598
+ * @param {VisibleDataSetInfo[]} dataSetsRequiringInitialization list of datasets to initialize
599
+ * @returns {void}
600
+ */
601
+ private queueInitForNewVisibleDataSets(dataSetsRequiringInitialization: VisibleDataSetInfo[]) {
602
+ LoggerProxy.logger.info(
603
+ `HashTreeParser#queueInitForNewVisibleDataSets --> ${
604
+ this.debugId
605
+ } queuing initialization of new visible datasets: ${dataSetsRequiringInitialization
606
+ .map((ds) => ds.name)
607
+ .join(', ')}`
608
+ );
609
+ queueMicrotask(() => {
610
+ this.initializeNewVisibleDataSets(dataSetsRequiringInitialization).catch((error) => {
611
+ if (error instanceof MeetingEndedError) {
612
+ this.callLocusInfoUpdateCallback({
613
+ updateType: LocusInfoUpdateType.MEETING_ENDED,
614
+ });
615
+ } else {
616
+ LoggerProxy.logger.warn(
617
+ `HashTreeParser#queueInitForNewVisibleDataSets --> ${
618
+ this.debugId
619
+ } error while initializing new visible datasets: ${dataSetsRequiringInitialization
620
+ .map((ds) => ds.name)
621
+ .join(', ')}: `,
622
+ error
623
+ );
624
+ }
625
+ });
626
+ });
627
+ }
628
+
629
+ /**
630
+ * Handles updates to Metadata object that we receive from Locus via other means than messages. Right now
631
+ * that means only in the API response alongside locus object.
632
+ *
633
+ * @param {Metadata} metadata received in Locus update other than a message (for example in an API response)
634
+ * @param {HashTreeObject[]} updatedObjects a list of updated hash tree objects to which any updates resulting from new Metadata will be added
635
+ * @returns {void}
636
+ */
637
+ handleMetadataUpdate(metadata: Metadata, updatedObjects: HashTreeObject[]): void {
638
+ let dataSetsRequiringInitialization: VisibleDataSetInfo[] = [];
639
+
640
+ // current assumption based on Locus docs is that Metadata object lives always in "self" data set
641
+ const hashTree = this.dataSets[MetadataDataSetName]?.hashTree;
642
+
643
+ if (!hashTree) {
644
+ LoggerProxy.logger.warn(
645
+ `HashTreeParser#handleLocusUpdate --> ${this.debugId} received Metadata object but no hash tree for "${MetadataDataSetName}" data set exists`
646
+ );
647
+ } else {
648
+ const metadataUpdated = hashTree.putItem(metadata.htMeta.elementId);
649
+
650
+ if (metadataUpdated) {
651
+ // metadata in Locus API response is in a slightly different format than the objects in messages, so need to adapt it
652
+ const metadataObject: HashTreeObject = {
653
+ htMeta: metadata.htMeta,
654
+ data: metadata,
655
+ };
656
+
657
+ updatedObjects.push(metadataObject);
658
+
659
+ const {changeDetected, removedDataSets, addedDataSets} = this.checkForVisibleDataSetChanges(
660
+ [metadataObject]
661
+ );
662
+
663
+ if (changeDetected) {
664
+ dataSetsRequiringInitialization = this.processVisibleDataSetChanges(
665
+ removedDataSets,
666
+ addedDataSets,
667
+ updatedObjects
668
+ );
669
+ }
670
+
671
+ if (dataSetsRequiringInitialization.length > 0) {
672
+ // there are some data sets that we need to initialize asynchronously
673
+ this.queueInitForNewVisibleDataSets(dataSetsRequiringInitialization);
674
+ }
675
+ }
676
+ }
677
+ }
678
+
423
679
  /**
424
680
  * This method should be called when we receive a partial locus DTO that contains dataSets and htMeta information
425
681
  * It updates the hash trees with the new leaf data based on the received Locus
@@ -427,22 +683,32 @@ class HashTreeParser {
427
683
  * @param {Object} update - The locus update containing data sets and locus information
428
684
  * @returns {void}
429
685
  */
430
- handleLocusUpdate(update: {dataSets?: Array<DataSet>; locus: any}): void {
431
- const {dataSets, locus} = update;
686
+ handleLocusUpdate(update: {dataSets?: Array<DataSet>; locus: any; metadata?: Metadata}): void {
687
+ if (this.state === 'stopped') {
688
+ return;
689
+ }
690
+
691
+ const {dataSets, locus, metadata} = update;
432
692
 
433
693
  if (!dataSets) {
434
- LoggerProxy.logger.warn(
694
+ LoggerProxy.logger.info(
435
695
  `HashTreeParser#handleLocusUpdate --> ${this.debugId} received hash tree update without dataSets`
436
696
  );
437
- }
438
- for (const dataSet of dataSets) {
439
- this.updateDataSetInfo(dataSet);
697
+ } else {
698
+ for (const dataSet of dataSets) {
699
+ this.updateDataSetInfo(dataSet);
700
+ }
440
701
  }
441
702
  const updatedObjects: HashTreeObject[] = [];
442
703
 
443
704
  // first, analyze the locus object to extract the hash tree objects' htMeta and data from it
444
705
  const leafInfo = this.analyzeLocusHtMeta(locus, {copyData: true});
445
706
 
707
+ // if we got metadata, process it (currently that means only potential visible data set list changes)
708
+ if (metadata) {
709
+ this.handleMetadataUpdate(metadata, updatedObjects);
710
+ }
711
+
446
712
  // then process the data in hash trees, if it is a new version, then add it to updatedObjects
447
713
  Object.keys(leafInfo).forEach((dataSetName) => {
448
714
  if (this.dataSets[dataSetName]) {
@@ -477,7 +743,7 @@ class HashTreeParser {
477
743
  );
478
744
  }
479
745
  } else {
480
- LoggerProxy.logger.warn(
746
+ LoggerProxy.logger.info(
481
747
  `HashTreeParser#handleLocusUpdate --> ${this.debugId} received leaf data for unknown data set "${dataSetName}", ignoring`
482
748
  );
483
749
  }
@@ -493,9 +759,6 @@ class HashTreeParser {
493
759
  updatedObjects,
494
760
  });
495
761
  }
496
-
497
- // todo: once Locus design on how visible data sets will be communicated in subsequent API responses is confirmed,
498
- // we'll need to check here if visible data sets have changed and update this.visibleDataSets, remove/create hash trees etc
499
762
  }
500
763
 
501
764
  /**
@@ -511,7 +774,7 @@ class HashTreeParser {
511
774
  };
512
775
 
513
776
  LoggerProxy.logger.info(
514
- `HashTreeParser#handleMessage --> ${this.debugId} created entry for "${receivedDataSet.name}" dataset: version=${receivedDataSet.version}, root=${receivedDataSet.root}`
777
+ `HashTreeParser#updateDataSetInfo --> ${this.debugId} created entry for "${receivedDataSet.name}" dataset: version=${receivedDataSet.version}, root=${receivedDataSet.root}`
515
778
  );
516
779
 
517
780
  return;
@@ -526,7 +789,7 @@ class HashTreeParser {
526
789
  exponent: receivedDataSet.backoff.exponent,
527
790
  };
528
791
  LoggerProxy.logger.info(
529
- `HashTreeParser#handleMessage --> ${this.debugId} updated "${receivedDataSet.name}" to version=${receivedDataSet.version}, root=${receivedDataSet.root}`
792
+ `HashTreeParser#updateDataSetInfo --> ${this.debugId} updated "${receivedDataSet.name}" dataset to version=${receivedDataSet.version}, root=${receivedDataSet.root}`
530
793
  );
531
794
  }
532
795
  }
@@ -537,25 +800,30 @@ class HashTreeParser {
537
800
  * @returns {Object} An object containing the removed and added visible data sets.
538
801
  */
539
802
  private checkForVisibleDataSetChanges(updatedObjects: HashTreeObject[]) {
540
- let removedDataSets: string[] = [];
541
- let addedDataSets: string[] = [];
803
+ let removedDataSets: VisibleDataSetInfo[] = [];
804
+ let addedDataSets: VisibleDataSetInfo[] = [];
542
805
 
543
- // visibleDataSets can only be changed by self object updates
806
+ // visibleDataSets can only be changed by Metadata object updates
544
807
  updatedObjects.forEach((object) => {
545
- // todo: in the future visibleDataSets will be in "Metadata" object, not in "self"
546
- if (isSelf(object) && object.data?.visibleDataSets) {
547
- const newVisibleDataSets = object.data.visibleDataSets;
808
+ if (isMetadata(object) && object.data?.visibleDataSets) {
809
+ const newVisibleDataSets = object.data.visibleDataSets.filter(
810
+ (vds) => !this.isExcludedDataSet(vds.name)
811
+ );
548
812
 
549
- removedDataSets = this.visibleDataSets.filter((ds) => !newVisibleDataSets.includes(ds));
550
- addedDataSets = newVisibleDataSets.filter((ds) => !this.visibleDataSets.includes(ds));
813
+ removedDataSets = this.visibleDataSets.filter(
814
+ (ds) => !newVisibleDataSets.some((nvs) => nvs.name === ds.name)
815
+ );
816
+ addedDataSets = newVisibleDataSets.filter((nvs) =>
817
+ this.visibleDataSets.every((ds) => ds.name !== nvs.name)
818
+ );
551
819
 
552
820
  if (removedDataSets.length > 0 || addedDataSets.length > 0) {
553
821
  LoggerProxy.logger.info(
554
822
  `HashTreeParser#checkForVisibleDataSetChanges --> ${
555
823
  this.debugId
556
- } visible data sets change: removed: ${removedDataSets.join(
557
- ', '
558
- )}, added: ${addedDataSets.join(', ')}`
824
+ } visible data sets change: removed: ${removedDataSets
825
+ .map((ds) => ds.name)
826
+ .join(', ')}, added: ${addedDataSets.map((ds) => ds.name).join(', ')}`
559
827
  );
560
828
  }
561
829
  }
@@ -577,11 +845,15 @@ class HashTreeParser {
577
845
  private deleteHashTree(dataSetName: string) {
578
846
  this.dataSets[dataSetName].hashTree = undefined;
579
847
 
580
- // we also need to stop the timer as there is no hash tree anymore to sync
848
+ // we also need to stop the timers as there is no hash tree anymore to sync
581
849
  if (this.dataSets[dataSetName].timer) {
582
850
  clearTimeout(this.dataSets[dataSetName].timer);
583
851
  this.dataSets[dataSetName].timer = undefined;
584
852
  }
853
+ if (this.dataSets[dataSetName].heartbeatWatchdogTimer) {
854
+ clearTimeout(this.dataSets[dataSetName].heartbeatWatchdogTimer);
855
+ this.dataSets[dataSetName].heartbeatWatchdogTimer = undefined;
856
+ }
585
857
  }
586
858
 
587
859
  /**
@@ -593,49 +865,51 @@ class HashTreeParser {
593
865
  * visible data sets and they require async initialization, the names of these data sets
594
866
  * are returned in an array.
595
867
  *
596
- * @param {string[]} removedDataSets - The list of removed data sets.
597
- * @param {string[]} addedDataSets - The list of added data sets.
868
+ * @param {VisibleDataSetInfo[]} removedDataSets - The list of removed data sets.
869
+ * @param {VisibleDataSetInfo[]} addedDataSets - The list of added data sets.
598
870
  * @param {HashTreeObject[]} updatedObjects - The list of updated hash tree objects to which changes will be added.
599
- * @returns {string[]} names of data sets that couldn't be initialized synchronously
871
+ * @returns {VisibleDataSetInfo[]} list of data sets that couldn't be initialized synchronously
600
872
  */
601
873
  private processVisibleDataSetChanges(
602
- removedDataSets: string[],
603
- addedDataSets: string[],
874
+ removedDataSets: VisibleDataSetInfo[],
875
+ addedDataSets: VisibleDataSetInfo[],
604
876
  updatedObjects: HashTreeObject[]
605
- ): string[] {
606
- const dataSetsRequiringInitialization = [];
877
+ ): VisibleDataSetInfo[] {
878
+ const dataSetsRequiringInitialization: VisibleDataSetInfo[] = [];
607
879
 
608
880
  // if a visible data set was removed, we need to tell our client that all objects from it are removed
609
881
  const removedObjects: HashTreeObject[] = [];
610
882
 
611
883
  removedDataSets.forEach((ds) => {
612
- if (this.dataSets[ds]?.hashTree) {
613
- for (let i = 0; i < this.dataSets[ds].hashTree.numLeaves; i += 1) {
884
+ if (this.dataSets[ds.name]?.hashTree) {
885
+ for (let i = 0; i < this.dataSets[ds.name].hashTree.numLeaves; i += 1) {
614
886
  removedObjects.push(
615
- ...this.dataSets[ds].hashTree.getLeafData(i).map((elementId) => ({
887
+ ...this.dataSets[ds.name].hashTree.getLeafData(i).map((elementId) => ({
616
888
  htMeta: {
617
889
  elementId,
618
- dataSetNames: [ds],
890
+ dataSetNames: [ds.name],
619
891
  },
620
892
  data: null,
621
893
  }))
622
894
  );
623
895
  }
624
896
 
625
- this.deleteHashTree(ds);
897
+ this.deleteHashTree(ds.name);
626
898
  }
627
899
  });
628
- this.visibleDataSets = this.visibleDataSets.filter((vds) => !removedDataSets.includes(vds));
900
+ this.visibleDataSets = this.visibleDataSets.filter(
901
+ (vds) => !removedDataSets.some((rds) => rds.name === vds.name)
902
+ );
629
903
  updatedObjects.push(...removedObjects);
630
904
 
631
905
  // now setup the new visible data sets
632
906
  for (const ds of addedDataSets) {
633
- const dataSetInfo = this.dataSets[ds];
907
+ const dataSetInfo = this.dataSets[ds.name];
634
908
 
635
909
  if (dataSetInfo) {
636
- if (this.visibleDataSets.includes(dataSetInfo.name)) {
910
+ if (this.isVisibleDataSet(dataSetInfo.name)) {
637
911
  LoggerProxy.logger.info(
638
- `HashTreeParser#processVisibleDataSetChanges --> ${this.debugId} Data set "${ds}" is already visible, skipping`
912
+ `HashTreeParser#processVisibleDataSetChanges --> ${this.debugId} Data set "${ds.name}" is already visible, skipping`
639
913
  );
640
914
 
641
915
  // eslint-disable-next-line no-continue
@@ -643,10 +917,13 @@ class HashTreeParser {
643
917
  }
644
918
 
645
919
  LoggerProxy.logger.info(
646
- `HashTreeParser#processVisibleDataSetChanges --> ${this.debugId} Adding visible data set "${ds}"`
920
+ `HashTreeParser#processVisibleDataSetChanges --> ${this.debugId} Adding visible data set "${ds.name}"`
647
921
  );
648
922
 
649
- this.visibleDataSets.push(ds);
923
+ if (!this.addToVisibleDataSetsList(ds)) {
924
+ // eslint-disable-next-line no-continue
925
+ continue;
926
+ }
650
927
 
651
928
  const hashTree = new HashTree([], dataSetInfo.leafCount);
652
929
 
@@ -654,9 +931,13 @@ class HashTreeParser {
654
931
  ...dataSetInfo,
655
932
  hashTree,
656
933
  };
934
+
935
+ // this call is needed here for the edge case where we receive a message with new visible data sets
936
+ // and there are no objects belonging to these data sets in the message but we already have the info about them in this.dataSets
937
+ this.runSyncAlgorithm(this.dataSets[dataSetInfo.name]);
657
938
  } else {
658
939
  LoggerProxy.logger.info(
659
- `HashTreeParser#processVisibleDataSetChanges --> ${this.debugId} visible data set "${ds}" added but no info about it in our dataSets structures`
940
+ `HashTreeParser#processVisibleDataSetChanges --> ${this.debugId} visible data set "${ds.name}" added but no info about it in our dataSets structures`
660
941
  );
661
942
  // todo: add a metric here
662
943
  dataSetsRequiringInitialization.push(ds);
@@ -670,32 +951,31 @@ class HashTreeParser {
670
951
  * Adds entries to the passed in updateObjects array
671
952
  * for the changes that result from adding and removing visible data sets.
672
953
  *
673
- * @param {HashTreeMessage} message - The hash tree message that triggered the visible data set changes.
674
- * @param {string[]} addedDataSets - The list of added data sets.
954
+ * @param {VisibleDataSetInfo[]} addedDataSets - The list of added data sets.
675
955
  * @returns {Promise<void>}
676
956
  */
677
- private async initializeNewVisibleDataSets(
678
- message: HashTreeMessage,
679
- addedDataSets: string[]
680
- ): Promise<void> {
681
- const allDataSets = await this.getAllDataSetsMetadata(message.visibleDataSetsUrl);
957
+ private async initializeNewVisibleDataSets(addedDataSets: VisibleDataSetInfo[]): Promise<void> {
958
+ if (this.state === 'stopped') {
959
+ return;
960
+ }
961
+ const allDataSets = await this.getAllVisibleDataSetsFromLocus();
682
962
 
683
963
  for (const ds of addedDataSets) {
684
- const dataSetInfo = allDataSets.find((d) => d.name === ds);
964
+ const dataSetInfo = allDataSets.find((d) => d.name === ds.name);
685
965
 
686
966
  LoggerProxy.logger.info(
687
- `HashTreeParser#initializeNewVisibleDataSets --> ${this.debugId} initializing data set "${ds}"`
967
+ `HashTreeParser#initializeNewVisibleDataSets --> ${this.debugId} initializing data set "${ds.name}"`
688
968
  );
689
969
 
690
970
  if (!dataSetInfo) {
691
971
  LoggerProxy.logger.warn(
692
- `HashTreeParser#handleHashTreeMessage --> ${this.debugId} missing info about data set "${ds}" in Locus response from visibleDataSetsUrl`
972
+ `HashTreeParser#initializeNewVisibleDataSets --> ${this.debugId} missing info about data set "${ds.name}" in Locus response from visibleDataSetsUrl`
693
973
  );
694
974
  } else {
695
975
  // we're awaiting in a loop, because in practice there will be only one new data set at a time,
696
976
  // so no point in trying to parallelize this
697
977
  // eslint-disable-next-line no-await-in-loop
698
- const updates = await this.initializeNewVisibleDataSet(dataSetInfo);
978
+ const updates = await this.initializeNewVisibleDataSet(ds, dataSetInfo);
699
979
 
700
980
  this.callLocusInfoUpdateCallback(updates);
701
981
  }
@@ -707,12 +987,13 @@ class HashTreeParser {
707
987
  *
708
988
  * @param {HashTreeMessage} message - The hash tree message containing data sets and objects to be processed
709
989
  * @param {string} [debugText] - Optional debug text to include in logs
710
- * @returns {Promise}
990
+ * @returns {HashTreeObject[]} list of hash tree objects that were updated as a result of processing the message
711
991
  */
712
- private async parseMessage(
713
- message: HashTreeMessage,
714
- debugText?: string
715
- ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
992
+ private parseMessage(message: HashTreeMessage, debugText?: string): HashTreeObject[] {
993
+ if (this.state === 'stopped') {
994
+ return [];
995
+ }
996
+
716
997
  const {dataSets, visibleDataSetsUrl} = message;
717
998
 
718
999
  LoggerProxy.logger.info(
@@ -730,48 +1011,37 @@ class HashTreeParser {
730
1011
  this.visibleDataSetsUrl = visibleDataSetsUrl;
731
1012
  dataSets.forEach((dataSet) => this.updateDataSetInfo(dataSet));
732
1013
 
733
- if (this.isEndMessage(message)) {
734
- LoggerProxy.logger.info(
735
- `HashTreeParser#parseMessage --> ${this.debugId} received END message`
736
- );
737
- this.stopAllTimers();
738
-
739
- return {updateType: LocusInfoUpdateType.MEETING_ENDED};
740
- }
741
-
742
- let isRosterDropped = false;
743
1014
  const updatedObjects: HashTreeObject[] = [];
744
1015
 
745
1016
  // when we detect new visible datasets, it may be that the metadata about them is not
746
1017
  // available in the message, they will require separate async initialization
747
1018
  let dataSetsRequiringInitialization = [];
748
1019
 
749
- // first find out if there are any visible data set changes - they're signalled in SELF object updates
750
- const selfUpdates = (message.locusStateElements || []).filter((object) =>
751
- // todo: SPARK-744859 once Locus supports it, we will filter for "Metadata" type here instead of "self"
752
- isSelf(object)
1020
+ // first find out if there are any visible data set changes - they're signalled in Metadata object updates
1021
+ const metadataUpdates = (message.locusStateElements || []).filter((object) =>
1022
+ isMetadata(object)
753
1023
  );
754
1024
 
755
- if (selfUpdates.length > 0) {
756
- const updatedSelfObjects = [];
1025
+ if (metadataUpdates.length > 0) {
1026
+ const updatedMetadataObjects = [];
757
1027
 
758
- selfUpdates.forEach((object) => {
1028
+ metadataUpdates.forEach((object) => {
759
1029
  // todo: once Locus supports it, we will use the "view" field here instead of dataSetNames
760
1030
  for (const dataSetName of object.htMeta.dataSetNames) {
761
1031
  const hashTree = this.dataSets[dataSetName]?.hashTree;
762
1032
 
763
1033
  if (hashTree && object.data) {
764
1034
  if (hashTree.putItem(object.htMeta.elementId)) {
765
- updatedSelfObjects.push(object);
1035
+ updatedMetadataObjects.push(object);
766
1036
  }
767
1037
  }
768
1038
  }
769
1039
  });
770
1040
 
771
- updatedObjects.push(...updatedSelfObjects);
1041
+ updatedObjects.push(...updatedMetadataObjects);
772
1042
 
773
1043
  const {changeDetected, removedDataSets, addedDataSets} =
774
- this.checkForVisibleDataSetChanges(updatedSelfObjects);
1044
+ this.checkForVisibleDataSetChanges(updatedMetadataObjects);
775
1045
 
776
1046
  if (changeDetected) {
777
1047
  dataSetsRequiringInitialization = this.processVisibleDataSetChanges(
@@ -782,64 +1052,49 @@ class HashTreeParser {
782
1052
  }
783
1053
  }
784
1054
 
785
- // by this point we now have this.dataSets setup for data sets from this message
786
- // and hash trees created for the new visible data sets,
787
- // so we can now process all the updates from the message
788
- dataSets.forEach((dataSet) => {
789
- if (this.dataSets[dataSet.name]) {
790
- const {hashTree} = this.dataSets[dataSet.name];
791
-
792
- if (hashTree) {
793
- const locusStateElementsForThisSet = message.locusStateElements.filter((object) =>
794
- object.htMeta.dataSetNames.includes(dataSet.name)
795
- );
796
-
797
- const appliedChangesList = hashTree.updateItems(
798
- locusStateElementsForThisSet.map((object) =>
799
- object.data
800
- ? {operation: 'update', item: object.htMeta.elementId}
801
- : {operation: 'remove', item: object.htMeta.elementId}
802
- )
803
- );
804
-
805
- zip(appliedChangesList, locusStateElementsForThisSet).forEach(
806
- ([changeApplied, object]) => {
807
- if (changeApplied) {
808
- if (isSelf(object) && !object.data) {
809
- isRosterDropped = true;
1055
+ if (message.locusStateElements?.length > 0) {
1056
+ // by this point we now have this.dataSets setup for data sets from this message
1057
+ // and hash trees created for the new visible data sets,
1058
+ // so we can now process all the updates from the message
1059
+ dataSets.forEach((dataSet) => {
1060
+ if (this.dataSets[dataSet.name]) {
1061
+ const {hashTree} = this.dataSets[dataSet.name];
1062
+
1063
+ if (hashTree) {
1064
+ const locusStateElementsForThisSet = message.locusStateElements.filter((object) =>
1065
+ object.htMeta.dataSetNames.includes(dataSet.name)
1066
+ );
1067
+
1068
+ const appliedChangesList = hashTree.updateItems(
1069
+ locusStateElementsForThisSet.map((object) =>
1070
+ object.data
1071
+ ? {operation: 'update', item: object.htMeta.elementId}
1072
+ : {operation: 'remove', item: object.htMeta.elementId}
1073
+ )
1074
+ );
1075
+
1076
+ zip(appliedChangesList, locusStateElementsForThisSet).forEach(
1077
+ ([changeApplied, object]) => {
1078
+ if (changeApplied) {
1079
+ // add to updatedObjects so that our locus DTO will get updated with the new object
1080
+ updatedObjects.push(object);
810
1081
  }
811
- // add to updatedObjects so that our locus DTO will get updated with the new object
812
- updatedObjects.push(object);
813
1082
  }
814
- }
815
- );
816
- } else {
817
- LoggerProxy.logger.info(
818
- `Locus-info:index#parseMessage --> ${this.debugId} unexpected (not visible) dataSet ${dataSet.name} received in hash tree message`
819
- );
1083
+ );
1084
+ } else {
1085
+ LoggerProxy.logger.info(
1086
+ `Locus-info:index#parseMessage --> ${this.debugId} unexpected (not visible) dataSet ${dataSet.name} received in hash tree message`
1087
+ );
1088
+ }
820
1089
  }
821
- }
822
1090
 
823
- if (!isRosterDropped) {
824
1091
  this.runSyncAlgorithm(dataSet);
825
- }
826
- });
827
-
828
- if (isRosterDropped) {
829
- LoggerProxy.logger.info(
830
- `HashTreeParser#parseMessage --> ${this.debugId} detected roster drop`
831
- );
832
- this.stopAllTimers();
833
-
834
- // in case of roster drop we don't care about other updates
835
- return {updateType: LocusInfoUpdateType.MEETING_ENDED};
1092
+ });
836
1093
  }
837
1094
 
838
1095
  if (dataSetsRequiringInitialization.length > 0) {
839
1096
  // there are some data sets that we need to initialize asynchronously
840
- queueMicrotask(() => {
841
- this.initializeNewVisibleDataSets(message, dataSetsRequiringInitialization);
842
- });
1097
+ this.queueInitForNewVisibleDataSets(dataSetsRequiringInitialization);
843
1098
  }
844
1099
 
845
1100
  if (updatedObjects.length === 0) {
@@ -848,7 +1103,7 @@ class HashTreeParser {
848
1103
  );
849
1104
  }
850
1105
 
851
- return {updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects};
1106
+ return updatedObjects;
852
1107
  }
853
1108
 
854
1109
  /**
@@ -858,13 +1113,32 @@ class HashTreeParser {
858
1113
  * @param {string} [debugText] - Optional debug text to include in logs
859
1114
  * @returns {void}
860
1115
  */
861
- async handleMessage(message: HashTreeMessage, debugText?: string): Promise<void> {
862
- if (message.locusStateElements === undefined) {
1116
+ handleMessage(message: HashTreeMessage, debugText?: string) {
1117
+ if (this.state === 'stopped') {
1118
+ return;
1119
+ }
1120
+
1121
+ if (message.heartbeatIntervalMs) {
1122
+ this.heartbeatIntervalMs = message.heartbeatIntervalMs;
1123
+ }
1124
+ if (this.isEndMessage(message)) {
1125
+ LoggerProxy.logger.info(
1126
+ `HashTreeParser#handleMessage --> ${this.debugId} received sentinel END MEETING message`
1127
+ );
1128
+ this.stopAllTimers();
1129
+
1130
+ this.callLocusInfoUpdateCallback({updateType: LocusInfoUpdateType.MEETING_ENDED});
1131
+ } else if (message.locusStateElements === undefined) {
863
1132
  this.handleRootHashHeartBeatMessage(message);
1133
+ this.resetHeartbeatWatchdogs(message.dataSets);
864
1134
  } else {
865
- const updates = await this.parseMessage(message, debugText);
1135
+ const updatedObjects = this.parseMessage(message, debugText);
866
1136
 
867
- this.callLocusInfoUpdateCallback(updates);
1137
+ this.resetHeartbeatWatchdogs(message.dataSets);
1138
+ this.callLocusInfoUpdateCallback({
1139
+ updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
1140
+ updatedObjects,
1141
+ });
868
1142
  }
869
1143
  }
870
1144
 
@@ -878,9 +1152,55 @@ class HashTreeParser {
878
1152
  updateType: LocusInfoUpdateType;
879
1153
  updatedObjects?: HashTreeObject[];
880
1154
  }) {
1155
+ if (this.state === 'stopped') {
1156
+ return;
1157
+ }
1158
+
881
1159
  const {updateType, updatedObjects} = updates;
882
1160
 
883
- if (updateType !== LocusInfoUpdateType.OBJECTS_UPDATED || updatedObjects?.length > 0) {
1161
+ if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updatedObjects?.length > 0) {
1162
+ // Filter out updates for objects that already have a higher version in their datasets,
1163
+ // or removals for objects that still exist in any of their datasets
1164
+ const filteredUpdates = updatedObjects.filter((object) => {
1165
+ const {elementId} = object.htMeta;
1166
+ const {type, id, version} = elementId;
1167
+
1168
+ // Check all datasets
1169
+ for (const dataSetName of Object.keys(this.dataSets)) {
1170
+ const dataSet = this.dataSets[dataSetName];
1171
+
1172
+ // only visible datasets have hash trees set
1173
+ if (dataSet?.hashTree) {
1174
+ const existingVersion = dataSet.hashTree.getItemVersion(id, type);
1175
+ if (existingVersion !== undefined) {
1176
+ if (object.data) {
1177
+ // For updates: filter out if any dataset has a higher version
1178
+ if (existingVersion > version) {
1179
+ LoggerProxy.logger.info(
1180
+ `HashTreeParser#callLocusInfoUpdateCallback --> ${this.debugId} Filtering out update for ${type}:${id} v${version} because dataset "${dataSetName}" has v${existingVersion}`
1181
+ );
1182
+
1183
+ return false;
1184
+ }
1185
+ } else if (existingVersion >= version) {
1186
+ // For removals: filter out if the object still exists in any dataset
1187
+ LoggerProxy.logger.info(
1188
+ `HashTreeParser#callLocusInfoUpdateCallback --> ${this.debugId} Filtering out removal for ${type}:${id} v${version} because dataset "${dataSetName}" still has v${existingVersion}`
1189
+ );
1190
+
1191
+ return false;
1192
+ }
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ return true;
1198
+ });
1199
+
1200
+ if (filteredUpdates.length > 0) {
1201
+ this.locusInfoUpdateCallback(updateType, {updatedObjects: filteredUpdates});
1202
+ }
1203
+ } else if (updateType !== LocusInfoUpdateType.OBJECTS_UPDATED) {
884
1204
  this.locusInfoUpdateCallback(updateType, {updatedObjects});
885
1205
  }
886
1206
  }
@@ -899,6 +1219,88 @@ class HashTreeParser {
899
1219
  return Math.round(randomValue ** exponent * maxMs);
900
1220
  }
901
1221
 
1222
+ /**
1223
+ * Performs a sync for the given data set.
1224
+ *
1225
+ * @param {InternalDataSet} dataSet - The data set to sync
1226
+ * @param {string} rootHash - Our current root hash for this data set
1227
+ * @param {string} reason - The reason for the sync (used for logging)
1228
+ * @returns {Promise<void>}
1229
+ */
1230
+ private async performSync(
1231
+ dataSet: InternalDataSet,
1232
+ rootHash: string,
1233
+ reason: string
1234
+ ): Promise<void> {
1235
+ if (!dataSet.hashTree) {
1236
+ return;
1237
+ }
1238
+
1239
+ try {
1240
+ LoggerProxy.logger.info(
1241
+ `HashTreeParser#performSync --> ${this.debugId} ${reason}, syncing data set "${dataSet.name}"`
1242
+ );
1243
+
1244
+ const mismatchedLeavesData: Record<number, LeafDataItem[]> = {};
1245
+
1246
+ if (dataSet.leafCount !== 1) {
1247
+ let receivedHashes;
1248
+
1249
+ try {
1250
+ // request hashes from sender
1251
+ const {hashes, dataSet: latestDataSetInfo} = await this.getHashesFromLocus(
1252
+ dataSet.name,
1253
+ rootHash
1254
+ );
1255
+
1256
+ receivedHashes = hashes;
1257
+
1258
+ dataSet.hashTree.resize(latestDataSetInfo.leafCount);
1259
+ } catch (error) {
1260
+ if (error.statusCode === 409) {
1261
+ // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1262
+ LoggerProxy.logger.info(
1263
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
1264
+ );
1265
+
1266
+ return;
1267
+ }
1268
+ throw error;
1269
+ }
1270
+
1271
+ // identify mismatched leaves
1272
+ const mismatchedLeaveIndexes = dataSet.hashTree.diffHashes(receivedHashes);
1273
+
1274
+ mismatchedLeaveIndexes.forEach((index) => {
1275
+ mismatchedLeavesData[index] = dataSet.hashTree.getLeafData(index);
1276
+ });
1277
+ } else {
1278
+ mismatchedLeavesData[0] = dataSet.hashTree.getLeafData(0);
1279
+ }
1280
+ // request sync for mismatched leaves
1281
+ if (Object.keys(mismatchedLeavesData).length > 0) {
1282
+ const syncResponse = await this.sendSyncRequestToLocus(dataSet, mismatchedLeavesData);
1283
+
1284
+ // sync API may return nothing (in that case data will arrive via messages)
1285
+ // or it may return a response in the same format as messages
1286
+ if (syncResponse) {
1287
+ this.handleMessage(syncResponse, 'via sync API');
1288
+ }
1289
+ }
1290
+ } catch (error) {
1291
+ if (error instanceof MeetingEndedError) {
1292
+ this.callLocusInfoUpdateCallback({
1293
+ updateType: LocusInfoUpdateType.MEETING_ENDED,
1294
+ });
1295
+ } else {
1296
+ LoggerProxy.logger.warn(
1297
+ `HashTreeParser#performSync --> ${this.debugId} error during sync for data set "${dataSet.name}":`,
1298
+ error
1299
+ );
1300
+ }
1301
+ }
1302
+ }
1303
+
902
1304
  /**
903
1305
  * Runs the sync algorithm for the given data set.
904
1306
  *
@@ -957,55 +1359,11 @@ class HashTreeParser {
957
1359
  const rootHash = dataSet.hashTree.getRootHash();
958
1360
 
959
1361
  if (dataSet.root !== rootHash) {
960
- LoggerProxy.logger.info(
961
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} Root hash mismatch: received=${dataSet.root}, ours=${rootHash}, syncing data set "${dataSet.name}"`
1362
+ await this.performSync(
1363
+ dataSet,
1364
+ rootHash,
1365
+ `Root hash mismatch: received=${dataSet.root}, ours=${rootHash}`
962
1366
  );
963
-
964
- const mismatchedLeavesData: Record<number, LeafDataItem[]> = {};
965
-
966
- if (dataSet.leafCount !== 1) {
967
- let receivedHashes;
968
-
969
- try {
970
- // request hashes from sender
971
- const {hashes, dataSet: latestDataSetInfo} = await this.getHashesFromLocus(
972
- dataSet.name
973
- );
974
-
975
- receivedHashes = hashes;
976
-
977
- dataSet.hashTree.resize(latestDataSetInfo.leafCount);
978
- } catch (error) {
979
- if (error.statusCode === 409) {
980
- // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
981
- LoggerProxy.logger.info(
982
- `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
983
- );
984
-
985
- return;
986
- }
987
- throw error;
988
- }
989
-
990
- // identify mismatched leaves
991
- const mismatchedLeaveIndexes = dataSet.hashTree.diffHashes(receivedHashes);
992
-
993
- mismatchedLeaveIndexes.forEach((index) => {
994
- mismatchedLeavesData[index] = dataSet.hashTree.getLeafData(index);
995
- });
996
- } else {
997
- mismatchedLeavesData[0] = dataSet.hashTree.getLeafData(0);
998
- }
999
- // request sync for mismatched leaves
1000
- if (Object.keys(mismatchedLeavesData).length > 0) {
1001
- const syncResponse = await this.sendSyncRequestToLocus(dataSet, mismatchedLeavesData);
1002
-
1003
- // sync API may return nothing (in that case data will arrive via messages)
1004
- // or it may return a response in the same format as messages
1005
- if (syncResponse) {
1006
- this.handleMessage(syncResponse, 'via sync API');
1007
- }
1008
- }
1009
1367
  } else {
1010
1368
  LoggerProxy.logger.info(
1011
1369
  `HashTreeParser#runSyncAlgorithm --> ${this.debugId} "${dataSet.name}" root hash matching: ${rootHash}, version=${dataSet.version}`
@@ -1019,6 +1377,52 @@ class HashTreeParser {
1019
1377
  }
1020
1378
  }
1021
1379
 
1380
+ /**
1381
+ * Resets the heartbeat watchdog timers for the specified data sets. Each data set has its own
1382
+ * watchdog timer that monitors whether heartbeats are being received within the expected interval.
1383
+ * If a heartbeat is not received for a specific data set within heartbeatIntervalMs plus
1384
+ * a backoff-calculated time, the sync algorithm is initiated for that data set
1385
+ *
1386
+ * @param {Array<DataSet>} receivedDataSets - The data sets from the received message for which watchdog timers should be reset
1387
+ * @returns {void}
1388
+ */
1389
+ private resetHeartbeatWatchdogs(receivedDataSets: Array<DataSet>): void {
1390
+ if (!this.heartbeatIntervalMs) {
1391
+ return;
1392
+ }
1393
+
1394
+ for (const receivedDataSet of receivedDataSets) {
1395
+ const dataSet = this.dataSets[receivedDataSet.name];
1396
+
1397
+ if (!dataSet?.hashTree) {
1398
+ // eslint-disable-next-line no-continue
1399
+ continue;
1400
+ }
1401
+
1402
+ if (dataSet.heartbeatWatchdogTimer) {
1403
+ clearTimeout(dataSet.heartbeatWatchdogTimer);
1404
+ dataSet.heartbeatWatchdogTimer = undefined;
1405
+ }
1406
+
1407
+ const backoffTime = this.getWeightedBackoffTime(dataSet.backoff);
1408
+ const delay = this.heartbeatIntervalMs + backoffTime;
1409
+
1410
+ dataSet.heartbeatWatchdogTimer = setTimeout(async () => {
1411
+ dataSet.heartbeatWatchdogTimer = undefined;
1412
+
1413
+ LoggerProxy.logger.warn(
1414
+ `HashTreeParser#resetHeartbeatWatchdogs --> ${this.debugId} Heartbeat watchdog fired for data set "${dataSet.name}" - no heartbeat received within expected interval, initiating sync`
1415
+ );
1416
+
1417
+ await this.performSync(
1418
+ dataSet,
1419
+ dataSet.hashTree.getRootHash(),
1420
+ `heartbeat watchdog expired`
1421
+ );
1422
+ }, delay);
1423
+ }
1424
+ }
1425
+
1022
1426
  /**
1023
1427
  * Stops all timers for the data sets to prevent any further sync attempts.
1024
1428
  * @returns {void}
@@ -1029,15 +1433,100 @@ class HashTreeParser {
1029
1433
  clearTimeout(dataSet.timer);
1030
1434
  dataSet.timer = undefined;
1031
1435
  }
1436
+ if (dataSet.heartbeatWatchdogTimer) {
1437
+ clearTimeout(dataSet.heartbeatWatchdogTimer);
1438
+ dataSet.heartbeatWatchdogTimer = undefined;
1439
+ }
1440
+ });
1441
+ }
1442
+
1443
+ /**
1444
+ * Stops the HashTreeParser, preventing it from processing any further messages and clearing all timers.
1445
+ * It also clears all the hash trees, so if the parser is resumed later, it will need to do a sync
1446
+ * to be up-to-date.
1447
+ * @returns {void}
1448
+ */
1449
+ public stop() {
1450
+ LoggerProxy.logger.info(
1451
+ `HashTreeParser#stop --> ${this.debugId} Stopping HashTreeParser, clearing timers and hash trees`
1452
+ );
1453
+ this.stopAllTimers();
1454
+ Object.values(this.dataSets).forEach((dataSet) => {
1455
+ dataSet.hashTree = undefined;
1032
1456
  });
1457
+ this.visibleDataSets = [];
1458
+ this.state = 'stopped';
1459
+ }
1460
+
1461
+ /**
1462
+ * Resumes the HashTreeParser that was previously stopped.
1463
+ * @param {HashTreeMessage} message - The message to resume with, it must contain metadata with visible data sets info
1464
+ * @returns {void}
1465
+ */
1466
+ public resume(message: HashTreeMessage) {
1467
+ // check that message contains metadata with visible data sets - this is essential to be able to resume
1468
+ const metadataObject = message.locusStateElements?.find((el) => isMetadata(el));
1469
+
1470
+ if (!metadataObject?.data?.visibleDataSets) {
1471
+ LoggerProxy.logger.warn(
1472
+ `HashTreeParser#resume --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1473
+ );
1474
+
1475
+ return;
1476
+ }
1477
+ this.setVisibleDataSets(
1478
+ metadataObject.data.visibleDataSets as VisibleDataSetInfo[],
1479
+ message.dataSets
1480
+ );
1481
+
1482
+ this.dataSets = {};
1483
+
1484
+ for (const dataSet of message.dataSets) {
1485
+ const {name, leafCount} = dataSet;
1486
+
1487
+ this.dataSets[name] = {
1488
+ ...dataSet,
1489
+ hashTree: this.isVisibleDataSet(name) ? new HashTree([], leafCount) : undefined,
1490
+ };
1491
+ }
1492
+ LoggerProxy.logger.info(
1493
+ `HashTreeParser#resume --> ${
1494
+ this.debugId
1495
+ } Resuming HashTreeParser with data sets: ${Object.keys(this.dataSets).join(
1496
+ ', '
1497
+ )}, visible data sets: ${this.visibleDataSets.map((ds) => ds.name).join(', ')}`
1498
+ );
1499
+ this.state = 'active';
1500
+
1501
+ this.handleMessage(message, 'on resume');
1502
+ }
1503
+
1504
+ private checkForSentinelHttpResponse(error: any, dataSetName?: string) {
1505
+ const isValidDataSetForSentinel =
1506
+ dataSetName === undefined ||
1507
+ PossibleSentinelMessageDataSetNames.includes(dataSetName.toLowerCase());
1508
+
1509
+ if (
1510
+ ((error.statusCode === 409 && error.body?.errorCode === 2403004) ||
1511
+ error.statusCode === 404) &&
1512
+ isValidDataSetForSentinel
1513
+ ) {
1514
+ LoggerProxy.logger.info(
1515
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode} for data set "${dataSetName}", indicating that the meeting has ended`
1516
+ );
1517
+ this.stopAllTimers();
1518
+
1519
+ throw new MeetingEndedError();
1520
+ }
1033
1521
  }
1034
1522
 
1035
1523
  /**
1036
1524
  * Gets the current hashes from the locus for a specific data set.
1037
1525
  * @param {string} dataSetName
1526
+ * @param {string} currentRootHash
1038
1527
  * @returns {string[]}
1039
1528
  */
1040
- private getHashesFromLocus(dataSetName: string) {
1529
+ private getHashesFromLocus(dataSetName: string, currentRootHash: string) {
1041
1530
  LoggerProxy.logger.info(
1042
1531
  `HashTreeParser#getHashesFromLocus --> ${this.debugId} Requesting hashes for data set "${dataSetName}"`
1043
1532
  );
@@ -1049,6 +1538,9 @@ class HashTreeParser {
1049
1538
  return this.webexRequest({
1050
1539
  method: HTTP_VERBS.GET,
1051
1540
  uri: url,
1541
+ qs: {
1542
+ rootHash: currentRootHash,
1543
+ },
1052
1544
  })
1053
1545
  .then((response) => {
1054
1546
  const hashes = response.body?.hashes as string[] | undefined;
@@ -1078,6 +1570,8 @@ class HashTreeParser {
1078
1570
  `HashTreeParser#getHashesFromLocus --> ${this.debugId} Error ${error.statusCode} fetching hashes for data set "${dataSetName}":`,
1079
1571
  error
1080
1572
  );
1573
+ this.checkForSentinelHttpResponse(error, dataSet.name);
1574
+
1081
1575
  throw error;
1082
1576
  });
1083
1577
  }
@@ -1110,9 +1604,14 @@ class HashTreeParser {
1110
1604
  });
1111
1605
  });
1112
1606
 
1607
+ const ourCurrentRootHash = dataSet.hashTree ? dataSet.hashTree.getRootHash() : EMPTY_HASH;
1608
+
1113
1609
  return this.webexRequest({
1114
1610
  method: HTTP_VERBS.POST,
1115
1611
  uri: url,
1612
+ qs: {
1613
+ rootHash: ourCurrentRootHash,
1614
+ },
1116
1615
  body,
1117
1616
  })
1118
1617
  .then((resp) => {
@@ -1135,6 +1634,8 @@ class HashTreeParser {
1135
1634
  `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Error ${error.statusCode} sending sync request for data set "${dataSet.name}":`,
1136
1635
  error
1137
1636
  );
1637
+ this.checkForSentinelHttpResponse(error, dataSet.name);
1638
+
1138
1639
  throw error;
1139
1640
  });
1140
1641
  }