@webex/plugin-meetings 3.12.0-next.3 → 3.12.0-next.31

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 (90) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +1 -1
  3. package/dist/breakouts/breakout.js +1 -1
  4. package/dist/breakouts/index.js +1 -1
  5. package/dist/constants.js +3 -1
  6. package/dist/constants.js.map +1 -1
  7. package/dist/controls-options-manager/constants.js +11 -1
  8. package/dist/controls-options-manager/constants.js.map +1 -1
  9. package/dist/controls-options-manager/index.js +23 -21
  10. package/dist/controls-options-manager/index.js.map +1 -1
  11. package/dist/controls-options-manager/util.js +91 -0
  12. package/dist/controls-options-manager/util.js.map +1 -1
  13. package/dist/hashTree/constants.js +10 -1
  14. package/dist/hashTree/constants.js.map +1 -1
  15. package/dist/hashTree/hashTreeParser.js +550 -346
  16. package/dist/hashTree/hashTreeParser.js.map +1 -1
  17. package/dist/hashTree/utils.js +22 -0
  18. package/dist/hashTree/utils.js.map +1 -1
  19. package/dist/interceptors/locusRetry.js +23 -8
  20. package/dist/interceptors/locusRetry.js.map +1 -1
  21. package/dist/interpretation/index.js +1 -1
  22. package/dist/interpretation/siLanguage.js +1 -1
  23. package/dist/locus-info/index.js +222 -61
  24. package/dist/locus-info/index.js.map +1 -1
  25. package/dist/meeting/index.js +372 -292
  26. package/dist/meeting/index.js.map +1 -1
  27. package/dist/meeting/util.js +1 -0
  28. package/dist/meeting/util.js.map +1 -1
  29. package/dist/meetings/index.js +146 -62
  30. package/dist/meetings/index.js.map +1 -1
  31. package/dist/meetings/util.js +39 -5
  32. package/dist/meetings/util.js.map +1 -1
  33. package/dist/member/index.js +10 -0
  34. package/dist/member/index.js.map +1 -1
  35. package/dist/member/types.js.map +1 -1
  36. package/dist/member/util.js +3 -0
  37. package/dist/member/util.js.map +1 -1
  38. package/dist/metrics/constants.js +5 -1
  39. package/dist/metrics/constants.js.map +1 -1
  40. package/dist/multistream/sendSlotManager.js +116 -2
  41. package/dist/multistream/sendSlotManager.js.map +1 -1
  42. package/dist/types/constants.d.ts +1 -0
  43. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  44. package/dist/types/hashTree/constants.d.ts +1 -0
  45. package/dist/types/hashTree/hashTreeParser.d.ts +53 -15
  46. package/dist/types/hashTree/utils.d.ts +11 -0
  47. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  48. package/dist/types/locus-info/index.d.ts +38 -5
  49. package/dist/types/meeting/index.d.ts +11 -0
  50. package/dist/types/member/index.d.ts +1 -0
  51. package/dist/types/member/types.d.ts +1 -0
  52. package/dist/types/member/util.d.ts +1 -0
  53. package/dist/types/metrics/constants.d.ts +4 -0
  54. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  55. package/dist/webinar/index.js +301 -226
  56. package/dist/webinar/index.js.map +1 -1
  57. package/package.json +16 -16
  58. package/src/constants.ts +1 -0
  59. package/src/controls-options-manager/constants.ts +14 -1
  60. package/src/controls-options-manager/index.ts +26 -19
  61. package/src/controls-options-manager/util.ts +81 -1
  62. package/src/hashTree/constants.ts +9 -0
  63. package/src/hashTree/hashTreeParser.ts +273 -154
  64. package/src/hashTree/utils.ts +17 -0
  65. package/src/interceptors/locusRetry.ts +25 -4
  66. package/src/locus-info/index.ts +233 -79
  67. package/src/meeting/index.ts +98 -11
  68. package/src/meeting/util.ts +1 -0
  69. package/src/meetings/index.ts +58 -34
  70. package/src/meetings/util.ts +44 -1
  71. package/src/member/index.ts +10 -0
  72. package/src/member/types.ts +1 -0
  73. package/src/member/util.ts +3 -0
  74. package/src/metrics/constants.ts +5 -0
  75. package/src/multistream/sendSlotManager.ts +97 -3
  76. package/src/webinar/index.ts +75 -1
  77. package/test/unit/spec/controls-options-manager/index.js +114 -6
  78. package/test/unit/spec/controls-options-manager/util.js +165 -0
  79. package/test/unit/spec/hashTree/hashTreeParser.ts +839 -37
  80. package/test/unit/spec/hashTree/utils.ts +88 -1
  81. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  82. package/test/unit/spec/locus-info/index.js +262 -64
  83. package/test/unit/spec/meeting/index.js +54 -36
  84. package/test/unit/spec/meeting/utils.js +4 -0
  85. package/test/unit/spec/meetings/index.js +190 -8
  86. package/test/unit/spec/meetings/utils.js +124 -0
  87. package/test/unit/spec/member/index.js +7 -0
  88. package/test/unit/spec/member/util.js +24 -0
  89. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  90. package/test/unit/spec/webinar/index.ts +60 -0
@@ -2,10 +2,10 @@ import {cloneDeep, isEmpty, zip} from 'lodash';
2
2
  import HashTree, {LeafDataItem} from './hashTree';
3
3
  import LoggerProxy from '../common/logs/logger-proxy';
4
4
  import {Enum, HTTP_VERBS} from '../constants';
5
- import {DataSetNames, EMPTY_HASH} from './constants';
5
+ import {DataSetNames, DATA_SET_INIT_PRIORITY, EMPTY_HASH} from './constants';
6
6
  import {ObjectType, HtMeta, HashTreeObject} from './types';
7
7
  import {LocusDTO} from '../locus-info/types';
8
- import {deleteNestedObjectsWithHtMeta, isMetadata} from './utils';
8
+ import {deleteNestedObjectsWithHtMeta, isMetadata, sortByInitPriority} from './utils';
9
9
 
10
10
  export interface DataSet {
11
11
  url: string;
@@ -57,10 +57,15 @@ export const LocusInfoUpdateType = {
57
57
  } as const;
58
58
 
59
59
  export type LocusInfoUpdateType = Enum<typeof LocusInfoUpdateType>;
60
- export type LocusInfoUpdateCallback = (
61
- updateType: LocusInfoUpdateType,
62
- data?: {updatedObjects: HashTreeObject[]}
63
- ) => void;
60
+ export type LocusInfoUpdate =
61
+ | {
62
+ updateType: typeof LocusInfoUpdateType.OBJECTS_UPDATED;
63
+ updatedObjects: HashTreeObject[];
64
+ }
65
+ | {
66
+ updateType: typeof LocusInfoUpdateType.MEETING_ENDED;
67
+ };
68
+ export type LocusInfoUpdateCallback = (update: LocusInfoUpdate) => void;
64
69
 
65
70
  interface LeafInfo {
66
71
  type: ObjectType;
@@ -99,6 +104,10 @@ class HashTreeParser {
99
104
  heartbeatIntervalMs?: number;
100
105
  private excludedDataSets: string[];
101
106
  state: 'active' | 'stopped';
107
+ private syncQueue: Array<{dataSetName: string; reason: string; isInitialization?: boolean}> = [];
108
+ private isSyncInProgress = false;
109
+ private isSyncAllInProgress = false;
110
+ private syncQueueProcessingPromise: Promise<void> = Promise.resolve();
102
111
 
103
112
  /**
104
113
  * Constructor for HashTreeParser
@@ -224,16 +233,16 @@ class HashTreeParser {
224
233
  * @param {DataSet} dataSetInfo The new data set to be added
225
234
  * @returns {Promise}
226
235
  */
227
- private initializeNewVisibleDataSet(
236
+ private async initializeNewVisibleDataSet(
228
237
  visibleDataSetInfo: VisibleDataSetInfo,
229
238
  dataSetInfo: DataSet
230
- ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
239
+ ): Promise<void> {
231
240
  if (this.isVisibleDataSet(dataSetInfo.name)) {
232
241
  LoggerProxy.logger.info(
233
242
  `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Data set "${dataSetInfo.name}" already exists, skipping init`
234
243
  );
235
244
 
236
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
245
+ return;
237
246
  }
238
247
 
239
248
  LoggerProxy.logger.info(
@@ -241,7 +250,7 @@ class HashTreeParser {
241
250
  );
242
251
 
243
252
  if (!this.addToVisibleDataSetsList(visibleDataSetInfo)) {
244
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
253
+ return;
245
254
  }
246
255
 
247
256
  const hashTree = new HashTree([], dataSetInfo.leafCount);
@@ -251,51 +260,8 @@ class HashTreeParser {
251
260
  hashTree,
252
261
  };
253
262
 
254
- return this.sendInitializationSyncRequestToLocus(dataSetInfo.name, 'new visible data set');
255
- }
256
-
257
- /**
258
- * Sends a special sync request to Locus with all leaves empty - this is a way to get all the data for a given dataset.
259
- *
260
- * @param {string} datasetName - name of the dataset for which to send the request
261
- * @param {string} debugText - text to include in logs
262
- * @returns {Promise}
263
- */
264
- private sendInitializationSyncRequestToLocus(
265
- datasetName: string,
266
- debugText: string
267
- ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
268
- const dataset = this.dataSets[datasetName];
269
-
270
- if (!dataset) {
271
- LoggerProxy.logger.warn(
272
- `HashTreeParser#sendInitializationSyncRequestToLocus --> ${this.debugId} No data set found for ${datasetName}, cannot send the request for leaf data`
273
- );
274
-
275
- return Promise.resolve(null);
276
- }
277
-
278
- const emptyLeavesData = new Array(dataset.leafCount).fill([]);
279
-
280
- LoggerProxy.logger.info(
281
- `HashTreeParser#sendInitializationSyncRequestToLocus --> ${this.debugId} Sending initial sync request to Locus for data set "${datasetName}" with empty leaf data`
282
- );
283
-
284
- return this.sendSyncRequestToLocus(this.dataSets[datasetName], emptyLeavesData).then(
285
- (syncResponse) => {
286
- if (syncResponse) {
287
- return {
288
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
289
- updatedObjects: this.parseMessage(
290
- syncResponse,
291
- `via empty leaves /sync API call for ${debugText}`
292
- ),
293
- };
294
- }
295
-
296
- return {updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []};
297
- }
298
- );
263
+ this.enqueueSyncForDataset(dataSetInfo.name, 'new visible data set initialization', true);
264
+ await this.syncQueueProcessingPromise;
299
265
  }
300
266
 
301
267
  /**
@@ -382,9 +348,8 @@ class HashTreeParser {
382
348
  if (this.state === 'stopped') {
383
349
  return;
384
350
  }
385
- const updatedObjects: HashTreeObject[] = [];
386
351
 
387
- for (const dataSet of visibleDataSets) {
352
+ for (const dataSet of sortByInitPriority(visibleDataSets, DATA_SET_INIT_PRIORITY)) {
388
353
  const {name, leafCount, url} = dataSet;
389
354
 
390
355
  if (!this.dataSets[name]) {
@@ -420,19 +385,12 @@ class HashTreeParser {
420
385
  );
421
386
  this.dataSets[name].hashTree = new HashTree([], leafCount);
422
387
 
423
- // eslint-disable-next-line no-await-in-loop
424
- const data = await this.sendInitializationSyncRequestToLocus(name, debugText);
425
-
426
- if (data.updateType === LocusInfoUpdateType.OBJECTS_UPDATED) {
427
- updatedObjects.push(...(data.updatedObjects || []));
428
- }
388
+ this.enqueueSyncForDataset(name, `initialization sync for ${debugText}`, true);
429
389
  }
430
390
  }
431
391
 
432
- this.callLocusInfoUpdateCallback({
433
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
434
- updatedObjects,
435
- });
392
+ // wait for all enqueued initialization syncs to complete
393
+ await this.syncQueueProcessingPromise;
436
394
  }
437
395
 
438
396
  /**
@@ -450,7 +408,7 @@ class HashTreeParser {
450
408
  // object mapping dataset names to arrays of leaf data
451
409
  const leafInfo: Record<string, Array<LeafInfo>> = {};
452
410
 
453
- const findAndStoreMetaData = (currentLocusPart: any) => {
411
+ const findAndStoreMetaData = (currentLocusPart: any, currentLocusPartName: string) => {
454
412
  if (typeof currentLocusPart !== 'object' || currentLocusPart === null) {
455
413
  return;
456
414
  }
@@ -465,10 +423,18 @@ class HashTreeParser {
465
423
  };
466
424
 
467
425
  if (copyData) {
468
- newLeafInfo.data = cloneDeep(currentLocusPart);
426
+ if ((type as string).toLowerCase() === ObjectType.control) {
427
+ // control entries require special handling, because they are signalled by Locus
428
+ // differently when coming in messages vs API responses
429
+ newLeafInfo.data = {
430
+ [currentLocusPartName]: cloneDeep(currentLocusPart),
431
+ };
432
+ } else {
433
+ newLeafInfo.data = cloneDeep(currentLocusPart);
469
434
 
470
- // remove any nested other objects that have their own htMeta
471
- deleteNestedObjectsWithHtMeta(newLeafInfo.data);
435
+ // remove any nested other objects that have their own htMeta
436
+ deleteNestedObjectsWithHtMeta(newLeafInfo.data);
437
+ }
472
438
  }
473
439
 
474
440
  for (const dataSetName of dataSetNames) {
@@ -480,19 +446,19 @@ class HashTreeParser {
480
446
  }
481
447
 
482
448
  if (Array.isArray(currentLocusPart)) {
483
- for (const item of currentLocusPart) {
484
- findAndStoreMetaData(item);
449
+ for (const [index, item] of currentLocusPart.entries()) {
450
+ findAndStoreMetaData(item, index.toString());
485
451
  }
486
452
  } else {
487
453
  for (const key of Object.keys(currentLocusPart)) {
488
454
  if (Object.prototype.hasOwnProperty.call(currentLocusPart, key)) {
489
- findAndStoreMetaData(currentLocusPart[key]);
455
+ findAndStoreMetaData(currentLocusPart[key], key);
490
456
  }
491
457
  }
492
458
  }
493
459
  };
494
460
 
495
- findAndStoreMetaData(locus);
461
+ findAndStoreMetaData(locus, 'locus');
496
462
 
497
463
  return leafInfo;
498
464
  }
@@ -683,6 +649,7 @@ class HashTreeParser {
683
649
  const {dataSets, locus, metadata} = update;
684
650
 
685
651
  if (!dataSets) {
652
+ // this happens for example when we handle GET /loci response
686
653
  LoggerProxy.logger.info(
687
654
  `HashTreeParser#handleLocusUpdate --> ${this.debugId} received hash tree update without dataSets`
688
655
  );
@@ -952,7 +919,7 @@ class HashTreeParser {
952
919
  }
953
920
  const allDataSets = await this.getAllVisibleDataSetsFromLocus();
954
921
 
955
- for (const ds of addedDataSets) {
922
+ for (const ds of sortByInitPriority(addedDataSets, DATA_SET_INIT_PRIORITY)) {
956
923
  const dataSetInfo = allDataSets.find((d) => d.name === ds.name);
957
924
 
958
925
  LoggerProxy.logger.info(
@@ -964,12 +931,8 @@ class HashTreeParser {
964
931
  `HashTreeParser#initializeNewVisibleDataSets --> ${this.debugId} missing info about data set "${ds.name}" in Locus response from visibleDataSetsUrl`
965
932
  );
966
933
  } else {
967
- // we're awaiting in a loop, because in practice there will be only one new data set at a time,
968
- // so no point in trying to parallelize this
969
934
  // eslint-disable-next-line no-await-in-loop
970
- const updates = await this.initializeNewVisibleDataSet(ds, dataSetInfo);
971
-
972
- this.callLocusInfoUpdateCallback(updates);
935
+ await this.initializeNewVisibleDataSet(ds, dataSetInfo);
973
936
  }
974
937
  }
975
938
  }
@@ -1007,7 +970,7 @@ class HashTreeParser {
1007
970
 
1008
971
  // when we detect new visible datasets, it may be that the metadata about them is not
1009
972
  // available in the message, they will require separate async initialization
1010
- let dataSetsRequiringInitialization = [];
973
+ let dataSetsRequiringInitialization: VisibleDataSetInfo[] = [];
1011
974
 
1012
975
  // first find out if there are any visible data set changes - they're signalled in Metadata object updates
1013
976
  const metadataUpdates = (message.locusStateElements || []).filter((object) =>
@@ -1015,7 +978,7 @@ class HashTreeParser {
1015
978
  );
1016
979
 
1017
980
  if (metadataUpdates.length > 0) {
1018
- const updatedMetadataObjects = [];
981
+ const updatedMetadataObjects: HashTreeObject[] = [];
1019
982
 
1020
983
  metadataUpdates.forEach((object) => {
1021
984
  // todo: once Locus supports it, we will use the "view" field here instead of dataSetNames
@@ -1044,7 +1007,7 @@ class HashTreeParser {
1044
1007
  }
1045
1008
  }
1046
1009
 
1047
- if (message.locusStateElements?.length > 0) {
1010
+ if (message.locusStateElements && message.locusStateElements.length > 0) {
1048
1011
  // by this point we now have this.dataSets setup for data sets from this message
1049
1012
  // and hash trees created for the new visible data sets,
1050
1013
  // so we can now process all the updates from the message
@@ -1140,20 +1103,17 @@ class HashTreeParser {
1140
1103
  * @param {Object} updates parsed from a Locus message
1141
1104
  * @returns {void}
1142
1105
  */
1143
- private callLocusInfoUpdateCallback(updates: {
1144
- updateType: LocusInfoUpdateType;
1145
- updatedObjects?: HashTreeObject[];
1146
- }) {
1106
+ private callLocusInfoUpdateCallback(updates: LocusInfoUpdate) {
1147
1107
  if (this.state === 'stopped') {
1148
1108
  return;
1149
1109
  }
1150
1110
 
1151
- const {updateType, updatedObjects} = updates;
1111
+ const {updateType} = updates;
1152
1112
 
1153
- if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updatedObjects?.length > 0) {
1113
+ if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updates.updatedObjects?.length > 0) {
1154
1114
  // Filter out updates for objects that already have a higher version in their datasets,
1155
1115
  // or removals for objects that still exist in any of their datasets
1156
- const filteredUpdates = updatedObjects.filter((object) => {
1116
+ const filteredUpdates = updates.updatedObjects.filter((object) => {
1157
1117
  const {elementId} = object.htMeta;
1158
1118
  const {type, id, version} = elementId;
1159
1119
 
@@ -1190,10 +1150,10 @@ class HashTreeParser {
1190
1150
  });
1191
1151
 
1192
1152
  if (filteredUpdates.length > 0) {
1193
- this.locusInfoUpdateCallback(updateType, {updatedObjects: filteredUpdates});
1153
+ this.locusInfoUpdateCallback({updateType, updatedObjects: filteredUpdates});
1194
1154
  }
1195
1155
  } else if (updateType !== LocusInfoUpdateType.OBJECTS_UPDATED) {
1196
- this.locusInfoUpdateCallback(updateType, {updatedObjects});
1156
+ this.locusInfoUpdateCallback({updateType});
1197
1157
  }
1198
1158
  }
1199
1159
 
@@ -1215,69 +1175,82 @@ class HashTreeParser {
1215
1175
  * Performs a sync for the given data set.
1216
1176
  *
1217
1177
  * @param {InternalDataSet} dataSet - The data set to sync
1218
- * @param {string} rootHash - Our current root hash for this data set
1219
1178
  * @param {string} reason - The reason for the sync (used for logging)
1179
+ * @param {boolean} [isInitialization] - Whether this is an initialization sync (sends empty leaves data instead of comparing hashes)
1220
1180
  * @returns {Promise<void>}
1221
1181
  */
1222
1182
  private async performSync(
1223
1183
  dataSet: InternalDataSet,
1224
- rootHash: string,
1225
- reason: string
1184
+ reason: string,
1185
+ isInitialization?: boolean
1226
1186
  ): Promise<void> {
1227
1187
  if (!dataSet.hashTree) {
1228
1188
  return;
1229
1189
  }
1230
1190
 
1191
+ const {hashTree} = dataSet;
1192
+ const rootHash = hashTree.getRootHash();
1193
+
1231
1194
  try {
1232
1195
  LoggerProxy.logger.info(
1233
1196
  `HashTreeParser#performSync --> ${this.debugId} ${reason}, syncing data set "${dataSet.name}"`
1234
1197
  );
1235
1198
 
1236
- const mismatchedLeavesData: Record<number, LeafDataItem[]> = {};
1199
+ let leavesData: Record<number, LeafDataItem[]> = {};
1237
1200
 
1238
- if (dataSet.leafCount !== 1) {
1239
- let receivedHashes;
1201
+ if (!isInitialization) {
1202
+ if (dataSet.leafCount !== 1) {
1203
+ let receivedHashes;
1240
1204
 
1241
- try {
1242
- // request hashes from sender
1243
- const {hashes, dataSet: latestDataSetInfo} = await this.getHashesFromLocus(
1244
- dataSet.name,
1245
- rootHash
1246
- );
1205
+ try {
1206
+ // request hashes from sender
1207
+ const hashesResult = await this.getHashesFromLocus(dataSet.name, rootHash);
1247
1208
 
1248
- receivedHashes = hashes;
1209
+ if (!hashesResult) {
1210
+ // hashes match, no sync needed
1211
+ return;
1212
+ }
1249
1213
 
1250
- dataSet.hashTree.resize(latestDataSetInfo.leafCount);
1251
- } catch (error) {
1252
- if (error.statusCode === 409) {
1253
- // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1254
- LoggerProxy.logger.info(
1255
- `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
1256
- );
1214
+ receivedHashes = hashesResult.hashes;
1215
+
1216
+ hashTree.resize(hashesResult.dataSet.leafCount);
1217
+ } catch (error: any) {
1218
+ if (error?.statusCode === 409) {
1219
+ // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1220
+ LoggerProxy.logger.info(
1221
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
1222
+ );
1257
1223
 
1258
- return;
1224
+ return;
1225
+ }
1226
+ throw error;
1259
1227
  }
1260
- throw error;
1261
- }
1262
1228
 
1263
- // identify mismatched leaves
1264
- const mismatchedLeaveIndexes = dataSet.hashTree.diffHashes(receivedHashes);
1229
+ // identify mismatched leaves
1230
+ const mismatchedLeaveIndexes = hashTree.diffHashes(receivedHashes);
1265
1231
 
1266
- mismatchedLeaveIndexes.forEach((index) => {
1267
- mismatchedLeavesData[index] = dataSet.hashTree.getLeafData(index);
1268
- });
1269
- } else {
1270
- mismatchedLeavesData[0] = dataSet.hashTree.getLeafData(0);
1232
+ mismatchedLeaveIndexes.forEach((index) => {
1233
+ leavesData[index] = hashTree.getLeafData(index);
1234
+ });
1235
+ } else {
1236
+ leavesData = {0: hashTree.getLeafData(0)};
1237
+ }
1271
1238
  }
1272
1239
  // request sync for mismatched leaves
1273
- if (Object.keys(mismatchedLeavesData).length > 0) {
1274
- const syncResponse = await this.sendSyncRequestToLocus(dataSet, mismatchedLeavesData);
1240
+ let syncResponse: HashTreeMessage | null = null;
1275
1241
 
1276
- // sync API may return nothing (in that case data will arrive via messages)
1277
- // or it may return a response in the same format as messages
1278
- if (syncResponse) {
1279
- this.handleMessage(syncResponse, 'via sync API');
1280
- }
1242
+ if (isInitialization) {
1243
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {isInitialization: true});
1244
+ } else if (Object.keys(leavesData).length > 0) {
1245
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {
1246
+ mismatchedLeavesData: leavesData,
1247
+ });
1248
+ }
1249
+
1250
+ // sync API may return nothing (in that case data will arrive via messages)
1251
+ // or it may return a response in the same format as messages
1252
+ if (syncResponse) {
1253
+ this.handleMessage(syncResponse, 'via sync API');
1281
1254
  }
1282
1255
  } catch (error) {
1283
1256
  if (error instanceof MeetingEndedError) {
@@ -1293,6 +1266,105 @@ class HashTreeParser {
1293
1266
  }
1294
1267
  }
1295
1268
 
1269
+ /**
1270
+ * Enqueues a sync for the given data set. If the data set is already in the queue, the request is ignored.
1271
+ * This ensures that all syncs are executed sequentially and no more than 1 sync runs at a time.
1272
+ *
1273
+ * @param {string} dataSetName - The name of the data set to sync
1274
+ * @param {string} reason - The reason for the sync (used for logging)
1275
+ * @param {boolean} [isInitialization=false] - Whether this is an initialization sync (uses empty leaves data instead of hash comparison)
1276
+ * @returns {void}
1277
+ */
1278
+ private enqueueSyncForDataset(
1279
+ dataSetName: string,
1280
+ reason: string,
1281
+ isInitialization = false
1282
+ ): void {
1283
+ if (this.state === 'stopped') return;
1284
+
1285
+ const existingEntry = this.syncQueue.find((entry) => entry.dataSetName === dataSetName);
1286
+
1287
+ if (existingEntry) {
1288
+ if (isInitialization) {
1289
+ existingEntry.isInitialization = true;
1290
+ }
1291
+ LoggerProxy.logger.info(
1292
+ `HashTreeParser#enqueueSyncForDataset --> ${this.debugId} data set "${dataSetName}" already in sync queue, skipping`
1293
+ );
1294
+
1295
+ return;
1296
+ }
1297
+
1298
+ this.syncQueue.push({dataSetName, reason, isInitialization});
1299
+
1300
+ if (!this.isSyncInProgress) {
1301
+ this.syncQueueProcessingPromise = this.processSyncQueue();
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Processes the sync queue sequentially. Only one instance of this method runs at a time.
1307
+ *
1308
+ * @returns {Promise<void>}
1309
+ */
1310
+ private async processSyncQueue(): Promise<void> {
1311
+ if (this.isSyncInProgress) return;
1312
+
1313
+ this.isSyncInProgress = true;
1314
+ try {
1315
+ while (this.syncQueue.length > 0 && this.state !== 'stopped') {
1316
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1317
+ const {dataSetName, reason, isInitialization} = this.syncQueue.shift()!;
1318
+ const dataSet = this.dataSets[dataSetName];
1319
+
1320
+ if (!dataSet?.hashTree) {
1321
+ // eslint-disable-next-line no-continue
1322
+ continue;
1323
+ }
1324
+
1325
+ // eslint-disable-next-line no-await-in-loop
1326
+ await this.performSync(dataSet, reason, isInitialization);
1327
+ }
1328
+ } finally {
1329
+ this.isSyncInProgress = false;
1330
+ }
1331
+ }
1332
+
1333
+ /**
1334
+ * Syncs all data sets that have hash trees, one by one in sequence, using the priority order
1335
+ * provided by sortByInitPriority(). Does nothing if the parser is stopped or if a syncAllDatasets
1336
+ * call is already in progress.
1337
+ *
1338
+ * @returns {Promise<void>}
1339
+ */
1340
+ public async syncAllDatasets(): Promise<void> {
1341
+ if (this.state === 'stopped') return;
1342
+ if (this.isSyncAllInProgress) return;
1343
+
1344
+ this.isSyncAllInProgress = true;
1345
+ try {
1346
+ const dataSetsWithHashTrees = Object.values(this.dataSets)
1347
+ .filter((dataSet) => dataSet?.hashTree)
1348
+ .map((dataSet) => ({name: dataSet.name}));
1349
+
1350
+ const sorted = sortByInitPriority(dataSetsWithHashTrees, DATA_SET_INIT_PRIORITY);
1351
+
1352
+ LoggerProxy.logger.info(
1353
+ `HashTreeParser#syncAllDatasets --> ${this.debugId} syncing datasets: ${sorted
1354
+ .map((ds) => ds.name)
1355
+ .join(', ')}`
1356
+ );
1357
+
1358
+ for (const ds of sorted) {
1359
+ this.enqueueSyncForDataset(ds.name, 'syncAllDatasets');
1360
+ }
1361
+
1362
+ await this.syncQueueProcessingPromise;
1363
+ } finally {
1364
+ this.isSyncAllInProgress = false;
1365
+ }
1366
+ }
1367
+
1296
1368
  /**
1297
1369
  * Runs the sync algorithm for the given data set.
1298
1370
  *
@@ -1337,7 +1409,7 @@ class HashTreeParser {
1337
1409
  `HashTreeParser#runSyncAlgorithm --> ${this.debugId} setting "${dataSet.name}" sync timer for ${delay}`
1338
1410
  );
1339
1411
 
1340
- dataSet.timer = setTimeout(async () => {
1412
+ dataSet.timer = setTimeout(() => {
1341
1413
  dataSet.timer = undefined;
1342
1414
 
1343
1415
  if (!dataSet.hashTree) {
@@ -1351,9 +1423,8 @@ class HashTreeParser {
1351
1423
  const rootHash = dataSet.hashTree.getRootHash();
1352
1424
 
1353
1425
  if (dataSet.root !== rootHash) {
1354
- await this.performSync(
1355
- dataSet,
1356
- rootHash,
1426
+ this.enqueueSyncForDataset(
1427
+ dataSet.name,
1357
1428
  `Root hash mismatch: received=${dataSet.root}, ours=${rootHash}`
1358
1429
  );
1359
1430
  } else {
@@ -1399,18 +1470,14 @@ class HashTreeParser {
1399
1470
  const backoffTime = this.getWeightedBackoffTime(dataSet.backoff);
1400
1471
  const delay = this.heartbeatIntervalMs + backoffTime;
1401
1472
 
1402
- dataSet.heartbeatWatchdogTimer = setTimeout(async () => {
1473
+ dataSet.heartbeatWatchdogTimer = setTimeout(() => {
1403
1474
  dataSet.heartbeatWatchdogTimer = undefined;
1404
1475
 
1405
1476
  LoggerProxy.logger.warn(
1406
1477
  `HashTreeParser#resetHeartbeatWatchdogs --> ${this.debugId} Heartbeat watchdog fired for data set "${dataSet.name}" - no heartbeat received within expected interval, initiating sync`
1407
1478
  );
1408
1479
 
1409
- await this.performSync(
1410
- dataSet,
1411
- dataSet.hashTree.getRootHash(),
1412
- `heartbeat watchdog expired`
1413
- );
1480
+ this.enqueueSyncForDataset(dataSet.name, `heartbeat watchdog expired`);
1414
1481
  }, delay);
1415
1482
  }
1416
1483
  }
@@ -1443,6 +1510,7 @@ class HashTreeParser {
1443
1510
  `HashTreeParser#stop --> ${this.debugId} Stopping HashTreeParser, clearing timers and hash trees`
1444
1511
  );
1445
1512
  this.stopAllTimers();
1513
+ this.syncQueue = [];
1446
1514
  Object.values(this.dataSets).forEach((dataSet) => {
1447
1515
  dataSet.hashTree = undefined;
1448
1516
  });
@@ -1451,17 +1519,27 @@ class HashTreeParser {
1451
1519
  }
1452
1520
 
1453
1521
  /**
1454
- * Resumes the HashTreeParser that was previously stopped.
1522
+ * Cleans up the HashTreeParser, stopping all timers and clearing all internal state.
1523
+ * After calling this, the parser should not be used anymore.
1524
+ * @returns {void}
1525
+ */
1526
+ public cleanUp() {
1527
+ this.stop();
1528
+ this.dataSets = {};
1529
+ }
1530
+
1531
+ /**
1532
+ * Resumes the HashTreeParser that was previously stopped, using a hash tree message.
1455
1533
  * @param {HashTreeMessage} message - The message to resume with, it must contain metadata with visible data sets info
1456
1534
  * @returns {void}
1457
1535
  */
1458
- public resume(message: HashTreeMessage) {
1536
+ public resumeFromMessage(message: HashTreeMessage) {
1459
1537
  // check that message contains metadata with visible data sets - this is essential to be able to resume
1460
1538
  const metadataObject = message.locusStateElements?.find((el) => isMetadata(el));
1461
1539
 
1462
1540
  if (!metadataObject?.data?.visibleDataSets) {
1463
1541
  LoggerProxy.logger.warn(
1464
- `HashTreeParser#resume --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1542
+ `HashTreeParser#resumeFromMessage --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1465
1543
  );
1466
1544
 
1467
1545
  return;
@@ -1482,7 +1560,7 @@ class HashTreeParser {
1482
1560
  };
1483
1561
  }
1484
1562
  LoggerProxy.logger.info(
1485
- `HashTreeParser#resume --> ${
1563
+ `HashTreeParser#resumeFromMessage --> ${
1486
1564
  this.debugId
1487
1565
  } Resuming HashTreeParser with data sets: ${Object.keys(this.dataSets).join(
1488
1566
  ', '
@@ -1493,6 +1571,24 @@ class HashTreeParser {
1493
1571
  this.handleMessage(message, 'on resume');
1494
1572
  }
1495
1573
 
1574
+ /**
1575
+ * Resumes the HashTreeParser that was previously stopped, using a Locus API response.
1576
+ * Unlike resumeFromMessage(), this does not require metadata/dataSets in the input,
1577
+ * as it fetches all necessary information from Locus via initializeFromGetLociResponse.
1578
+ * @param {LocusDTO} locus - locus object from an API response
1579
+ * @returns {Promise}
1580
+ */
1581
+ public async resumeFromApiResponse(locus: LocusDTO) {
1582
+ this.state = 'active';
1583
+ this.dataSets = {};
1584
+
1585
+ LoggerProxy.logger.info(
1586
+ `HashTreeParser#resumeFromApiResponse --> ${this.debugId} Resuming HashTreeParser from API response`
1587
+ );
1588
+
1589
+ await this.initializeFromGetLociResponse(locus);
1590
+ }
1591
+
1496
1592
  private checkForSentinelHttpResponse(error: any, dataSetName?: string) {
1497
1593
  const isValidDataSetForSentinel =
1498
1594
  dataSetName === undefined ||
@@ -1516,7 +1612,7 @@ class HashTreeParser {
1516
1612
  * Gets the current hashes from the locus for a specific data set.
1517
1613
  * @param {string} dataSetName
1518
1614
  * @param {string} currentRootHash
1519
- * @returns {string[]}
1615
+ * @returns {Object|null} An object containing the hashes and leaf count, or null if the hashes match and no sync is needed
1520
1616
  */
1521
1617
  private getHashesFromLocus(dataSetName: string, currentRootHash: string) {
1522
1618
  LoggerProxy.logger.info(
@@ -1535,6 +1631,15 @@ class HashTreeParser {
1535
1631
  },
1536
1632
  })
1537
1633
  .then((response) => {
1634
+ if (!response.body || isEmpty(response.body)) {
1635
+ // 204 with empty body means our hashes match Locus, no sync needed
1636
+ LoggerProxy.logger.info(
1637
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got ${response.statusCode} with empty body for data set "${dataSetName}", hashes match - no sync needed`
1638
+ );
1639
+
1640
+ return null;
1641
+ }
1642
+
1538
1643
  const hashes = response.body?.hashes as string[] | undefined;
1539
1644
  const dataSetFromResponse = response.body?.dataSet;
1540
1645
 
@@ -1572,29 +1677,43 @@ class HashTreeParser {
1572
1677
  * Sends a sync request to Locus for the specified data set.
1573
1678
  *
1574
1679
  * @param {InternalDataSet} dataSet The data set to sync.
1575
- * @param {Record<number, LeafDataItem[]>} mismatchedLeavesData The mismatched leaves data to include in the sync request.
1680
+ * @param {Object} options Either `{ isInitialization: true }` for init syncs (uses leafCount=1 with empty leaf data) or `{ mismatchedLeavesData }` for normal syncs.
1576
1681
  * @returns {Promise<HashTreeMessage|null>}
1577
1682
  */
1578
1683
  private sendSyncRequestToLocus(
1579
1684
  dataSet: InternalDataSet,
1580
- mismatchedLeavesData: Record<number, LeafDataItem[]>
1685
+ options: {isInitialization: true} | {mismatchedLeavesData: Record<number, LeafDataItem[]>}
1581
1686
  ): Promise<HashTreeMessage | null> {
1582
1687
  LoggerProxy.logger.info(
1583
1688
  `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Sending sync request for data set "${dataSet.name}"`
1584
1689
  );
1585
1690
 
1691
+ const isInitialization = 'isInitialization' in options;
1692
+
1586
1693
  const url = `${dataSet.url}/sync`;
1587
- const body = {
1588
- leafCount: dataSet.leafCount,
1694
+ const body: {
1695
+ leafCount: number;
1696
+ leafDataEntries: {leafIndex: number; elementIds: LeafDataItem[]}[];
1697
+ } = {
1698
+ leafCount: isInitialization ? 1 : dataSet.leafCount,
1589
1699
  leafDataEntries: [],
1590
1700
  };
1591
1701
 
1592
- Object.keys(mismatchedLeavesData).forEach((index) => {
1593
- body.leafDataEntries.push({
1594
- leafIndex: parseInt(index, 10),
1595
- elementIds: mismatchedLeavesData[index],
1702
+ if (isInitialization) {
1703
+ // initialization sync: Locus requires leafCount=1 with a single empty leaf
1704
+ body.leafDataEntries.push({leafIndex: 0, elementIds: []});
1705
+ } else {
1706
+ const {mismatchedLeavesData} = options;
1707
+
1708
+ Object.keys(mismatchedLeavesData).forEach((index) => {
1709
+ const leafIndex = parseInt(index, 10);
1710
+
1711
+ body.leafDataEntries.push({
1712
+ leafIndex,
1713
+ elementIds: mismatchedLeavesData[leafIndex],
1714
+ });
1596
1715
  });
1597
- });
1716
+ }
1598
1717
 
1599
1718
  const ourCurrentRootHash = dataSet.hashTree ? dataSet.hashTree.getRootHash() : EMPTY_HASH;
1600
1719