@webex/plugin-meetings 3.12.0-next.5 → 3.12.0-next.51

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 (136) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +6 -2
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +1 -1
  7. package/dist/config.js +1 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/constants.js +6 -3
  10. package/dist/constants.js.map +1 -1
  11. package/dist/controls-options-manager/constants.js +11 -1
  12. package/dist/controls-options-manager/constants.js.map +1 -1
  13. package/dist/controls-options-manager/index.js +38 -24
  14. package/dist/controls-options-manager/index.js.map +1 -1
  15. package/dist/controls-options-manager/util.js +91 -0
  16. package/dist/controls-options-manager/util.js.map +1 -1
  17. package/dist/hashTree/constants.js +10 -1
  18. package/dist/hashTree/constants.js.map +1 -1
  19. package/dist/hashTree/hashTreeParser.js +646 -371
  20. package/dist/hashTree/hashTreeParser.js.map +1 -1
  21. package/dist/hashTree/utils.js +22 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +7 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/locusRetry.js +23 -8
  26. package/dist/interceptors/locusRetry.js.map +1 -1
  27. package/dist/interpretation/index.js +10 -1
  28. package/dist/interpretation/index.js.map +1 -1
  29. package/dist/interpretation/siLanguage.js +1 -1
  30. package/dist/locus-info/controlsUtils.js +4 -1
  31. package/dist/locus-info/controlsUtils.js.map +1 -1
  32. package/dist/locus-info/index.js +289 -86
  33. package/dist/locus-info/index.js.map +1 -1
  34. package/dist/locus-info/types.js +19 -0
  35. package/dist/locus-info/types.js.map +1 -1
  36. package/dist/media/properties.js +1 -0
  37. package/dist/media/properties.js.map +1 -1
  38. package/dist/meeting/in-meeting-actions.js +3 -1
  39. package/dist/meeting/in-meeting-actions.js.map +1 -1
  40. package/dist/meeting/index.js +842 -521
  41. package/dist/meeting/index.js.map +1 -1
  42. package/dist/meeting/util.js +19 -2
  43. package/dist/meeting/util.js.map +1 -1
  44. package/dist/meetings/index.js +205 -77
  45. package/dist/meetings/index.js.map +1 -1
  46. package/dist/meetings/meetings.types.js +6 -1
  47. package/dist/meetings/meetings.types.js.map +1 -1
  48. package/dist/meetings/request.js +39 -0
  49. package/dist/meetings/request.js.map +1 -1
  50. package/dist/meetings/util.js +67 -5
  51. package/dist/meetings/util.js.map +1 -1
  52. package/dist/member/index.js +10 -0
  53. package/dist/member/index.js.map +1 -1
  54. package/dist/member/types.js.map +1 -1
  55. package/dist/member/util.js +3 -0
  56. package/dist/member/util.js.map +1 -1
  57. package/dist/metrics/constants.js +2 -1
  58. package/dist/metrics/constants.js.map +1 -1
  59. package/dist/recording-controller/index.js +1 -3
  60. package/dist/recording-controller/index.js.map +1 -1
  61. package/dist/types/config.d.ts +1 -0
  62. package/dist/types/constants.d.ts +2 -0
  63. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  64. package/dist/types/controls-options-manager/index.d.ts +10 -0
  65. package/dist/types/hashTree/constants.d.ts +1 -0
  66. package/dist/types/hashTree/hashTreeParser.d.ts +83 -16
  67. package/dist/types/hashTree/utils.d.ts +11 -0
  68. package/dist/types/index.d.ts +2 -0
  69. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  70. package/dist/types/locus-info/index.d.ts +46 -6
  71. package/dist/types/locus-info/types.d.ts +21 -1
  72. package/dist/types/media/properties.d.ts +1 -0
  73. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  74. package/dist/types/meeting/index.d.ts +70 -1
  75. package/dist/types/meeting/util.d.ts +8 -0
  76. package/dist/types/meetings/index.d.ts +20 -2
  77. package/dist/types/meetings/meetings.types.d.ts +15 -0
  78. package/dist/types/meetings/request.d.ts +14 -0
  79. package/dist/types/member/index.d.ts +1 -0
  80. package/dist/types/member/types.d.ts +1 -0
  81. package/dist/types/member/util.d.ts +1 -0
  82. package/dist/types/metrics/constants.d.ts +1 -0
  83. package/dist/webinar/index.js +361 -235
  84. package/dist/webinar/index.js.map +1 -1
  85. package/package.json +22 -22
  86. package/src/aiEnableRequest/index.ts +16 -0
  87. package/src/breakouts/breakout.ts +2 -1
  88. package/src/config.ts +1 -0
  89. package/src/constants.ts +5 -1
  90. package/src/controls-options-manager/constants.ts +14 -1
  91. package/src/controls-options-manager/index.ts +47 -24
  92. package/src/controls-options-manager/util.ts +81 -1
  93. package/src/hashTree/constants.ts +9 -0
  94. package/src/hashTree/hashTreeParser.ts +362 -174
  95. package/src/hashTree/utils.ts +17 -0
  96. package/src/index.ts +5 -0
  97. package/src/interceptors/locusRetry.ts +25 -4
  98. package/src/interpretation/index.ts +25 -8
  99. package/src/locus-info/controlsUtils.ts +3 -1
  100. package/src/locus-info/index.ts +291 -93
  101. package/src/locus-info/types.ts +25 -1
  102. package/src/media/properties.ts +1 -0
  103. package/src/meeting/in-meeting-actions.ts +4 -0
  104. package/src/meeting/index.ts +315 -26
  105. package/src/meeting/util.ts +20 -2
  106. package/src/meetings/index.ts +109 -43
  107. package/src/meetings/meetings.types.ts +19 -0
  108. package/src/meetings/request.ts +43 -0
  109. package/src/meetings/util.ts +80 -1
  110. package/src/member/index.ts +10 -0
  111. package/src/member/types.ts +1 -0
  112. package/src/member/util.ts +3 -0
  113. package/src/metrics/constants.ts +1 -0
  114. package/src/recording-controller/index.ts +1 -2
  115. package/src/webinar/index.ts +162 -21
  116. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  117. package/test/unit/spec/breakouts/breakout.ts +7 -3
  118. package/test/unit/spec/controls-options-manager/index.js +140 -29
  119. package/test/unit/spec/controls-options-manager/util.js +165 -0
  120. package/test/unit/spec/hashTree/hashTreeParser.ts +1341 -140
  121. package/test/unit/spec/hashTree/utils.ts +88 -1
  122. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  123. package/test/unit/spec/interpretation/index.ts +26 -4
  124. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  125. package/test/unit/spec/locus-info/index.js +475 -81
  126. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  127. package/test/unit/spec/meeting/index.js +836 -41
  128. package/test/unit/spec/meeting/muteState.js +3 -0
  129. package/test/unit/spec/meeting/utils.js +33 -0
  130. package/test/unit/spec/meetings/index.js +309 -10
  131. package/test/unit/spec/meetings/request.js +141 -0
  132. package/test/unit/spec/meetings/utils.js +161 -0
  133. package/test/unit/spec/member/index.js +7 -0
  134. package/test/unit/spec/member/util.js +24 -0
  135. package/test/unit/spec/recording-controller/index.js +9 -8
  136. package/test/unit/spec/webinar/index.ts +141 -16
@@ -1,11 +1,13 @@
1
1
  import {cloneDeep, isEmpty, zip} from 'lodash';
2
2
  import HashTree, {LeafDataItem} from './hashTree';
3
3
  import LoggerProxy from '../common/logs/logger-proxy';
4
+ import Metrics from '../metrics';
5
+ import BEHAVIORAL_METRICS from '../metrics/constants';
4
6
  import {Enum, HTTP_VERBS} from '../constants';
5
- import {DataSetNames, EMPTY_HASH} from './constants';
7
+ import {DataSetNames, DATA_SET_INIT_PRIORITY, EMPTY_HASH} from './constants';
6
8
  import {ObjectType, HtMeta, HashTreeObject} from './types';
7
- import {LocusDTO} from '../locus-info/types';
8
- import {deleteNestedObjectsWithHtMeta, isMetadata} from './utils';
9
+ import {LocusDTO, LocusErrorCodes} from '../locus-info/types';
10
+ import {deleteNestedObjectsWithHtMeta, isMetadata, sortByInitPriority} from './utils';
9
11
 
10
12
  export interface DataSet {
11
13
  url: string;
@@ -54,13 +56,24 @@ type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
54
56
  export const LocusInfoUpdateType = {
55
57
  OBJECTS_UPDATED: 'OBJECTS_UPDATED',
56
58
  MEETING_ENDED: 'MEETING_ENDED',
59
+ LOCUS_NOT_FOUND: 'LOCUS_NOT_FOUND',
57
60
  } as const;
58
61
 
59
62
  export type LocusInfoUpdateType = Enum<typeof LocusInfoUpdateType>;
60
- export type LocusInfoUpdateCallback = (
61
- updateType: LocusInfoUpdateType,
62
- data?: {updatedObjects: HashTreeObject[]}
63
- ) => void;
63
+
64
+ interface LocusUpdatePayloads {
65
+ [LocusInfoUpdateType.OBJECTS_UPDATED]: {updatedObjects: HashTreeObject[]};
66
+ [LocusInfoUpdateType.MEETING_ENDED]: unknown; // No extra data
67
+ [LocusInfoUpdateType.LOCUS_NOT_FOUND]: unknown; // No extra data
68
+ }
69
+
70
+ export type LocusInfoUpdate = {
71
+ [K in keyof LocusUpdatePayloads]: {
72
+ updateType: K;
73
+ } & LocusUpdatePayloads[K];
74
+ }[keyof LocusUpdatePayloads];
75
+
76
+ export type LocusInfoUpdateCallback = (update: LocusInfoUpdate) => void;
64
77
 
65
78
  interface LeafInfo {
66
79
  type: ObjectType;
@@ -75,6 +88,13 @@ interface LeafInfo {
75
88
  */
76
89
  export class MeetingEndedError extends Error {}
77
90
 
91
+ /**
92
+ * This error is thrown when a 404 is received from Locus hash tree endpoints, indicating that the locus URL
93
+ * is no longer valid (e.g. participant moved to a breakout room, or meeting ended).
94
+ * It's handled internally by HashTreeParser and results in LOCUS_NOT_FOUND being sent up.
95
+ */
96
+ export class LocusNotFoundError extends Error {}
97
+
78
98
  /* Currently Locus always sends Metadata objects only in the "self" dataset.
79
99
  * If this ever changes, update all the code that relies on this constant.
80
100
  */
@@ -99,6 +119,10 @@ class HashTreeParser {
99
119
  heartbeatIntervalMs?: number;
100
120
  private excludedDataSets: string[];
101
121
  state: 'active' | 'stopped';
122
+ private syncQueue: Array<{dataSetName: string; reason: string; isInitialization?: boolean}> = [];
123
+ private isSyncInProgress = false;
124
+ private isSyncAllInProgress = false;
125
+ private syncQueueProcessingPromise: Promise<void> = Promise.resolve();
102
126
 
103
127
  /**
104
128
  * Constructor for HashTreeParser
@@ -224,16 +248,16 @@ class HashTreeParser {
224
248
  * @param {DataSet} dataSetInfo The new data set to be added
225
249
  * @returns {Promise}
226
250
  */
227
- private initializeNewVisibleDataSet(
251
+ private async initializeNewVisibleDataSet(
228
252
  visibleDataSetInfo: VisibleDataSetInfo,
229
253
  dataSetInfo: DataSet
230
- ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
254
+ ): Promise<void> {
231
255
  if (this.isVisibleDataSet(dataSetInfo.name)) {
232
256
  LoggerProxy.logger.info(
233
257
  `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Data set "${dataSetInfo.name}" already exists, skipping init`
234
258
  );
235
259
 
236
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
260
+ return;
237
261
  }
238
262
 
239
263
  LoggerProxy.logger.info(
@@ -241,7 +265,7 @@ class HashTreeParser {
241
265
  );
242
266
 
243
267
  if (!this.addToVisibleDataSetsList(visibleDataSetInfo)) {
244
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
268
+ return;
245
269
  }
246
270
 
247
271
  const hashTree = new HashTree([], dataSetInfo.leafCount);
@@ -251,51 +275,8 @@ class HashTreeParser {
251
275
  hashTree,
252
276
  };
253
277
 
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
- );
278
+ this.enqueueSyncForDataset(dataSetInfo.name, 'new visible data set initialization', true);
279
+ await this.syncQueueProcessingPromise;
299
280
  }
300
281
 
301
282
  /**
@@ -382,9 +363,8 @@ class HashTreeParser {
382
363
  if (this.state === 'stopped') {
383
364
  return;
384
365
  }
385
- const updatedObjects: HashTreeObject[] = [];
386
366
 
387
- for (const dataSet of visibleDataSets) {
367
+ for (const dataSet of sortByInitPriority(visibleDataSets, DATA_SET_INIT_PRIORITY)) {
388
368
  const {name, leafCount, url} = dataSet;
389
369
 
390
370
  if (!this.dataSets[name]) {
@@ -420,19 +400,12 @@ class HashTreeParser {
420
400
  );
421
401
  this.dataSets[name].hashTree = new HashTree([], leafCount);
422
402
 
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
- }
403
+ this.enqueueSyncForDataset(name, `initialization sync for ${debugText}`, true);
429
404
  }
430
405
  }
431
406
 
432
- this.callLocusInfoUpdateCallback({
433
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
434
- updatedObjects,
435
- });
407
+ // wait for all enqueued initialization syncs to complete
408
+ await this.syncQueueProcessingPromise;
436
409
  }
437
410
 
438
411
  /**
@@ -450,7 +423,7 @@ class HashTreeParser {
450
423
  // object mapping dataset names to arrays of leaf data
451
424
  const leafInfo: Record<string, Array<LeafInfo>> = {};
452
425
 
453
- const findAndStoreMetaData = (currentLocusPart: any) => {
426
+ const findAndStoreMetaData = (currentLocusPart: any, currentLocusPartName: string) => {
454
427
  if (typeof currentLocusPart !== 'object' || currentLocusPart === null) {
455
428
  return;
456
429
  }
@@ -465,10 +438,18 @@ class HashTreeParser {
465
438
  };
466
439
 
467
440
  if (copyData) {
468
- newLeafInfo.data = cloneDeep(currentLocusPart);
441
+ if ((type as string).toLowerCase() === ObjectType.control) {
442
+ // control entries require special handling, because they are signalled by Locus
443
+ // differently when coming in messages vs API responses
444
+ newLeafInfo.data = {
445
+ [currentLocusPartName]: cloneDeep(currentLocusPart),
446
+ };
447
+ } else {
448
+ newLeafInfo.data = cloneDeep(currentLocusPart);
469
449
 
470
- // remove any nested other objects that have their own htMeta
471
- deleteNestedObjectsWithHtMeta(newLeafInfo.data);
450
+ // remove any nested other objects that have their own htMeta
451
+ deleteNestedObjectsWithHtMeta(newLeafInfo.data);
452
+ }
472
453
  }
473
454
 
474
455
  for (const dataSetName of dataSetNames) {
@@ -480,19 +461,19 @@ class HashTreeParser {
480
461
  }
481
462
 
482
463
  if (Array.isArray(currentLocusPart)) {
483
- for (const item of currentLocusPart) {
484
- findAndStoreMetaData(item);
464
+ for (const [index, item] of currentLocusPart.entries()) {
465
+ findAndStoreMetaData(item, index.toString());
485
466
  }
486
467
  } else {
487
468
  for (const key of Object.keys(currentLocusPart)) {
488
469
  if (Object.prototype.hasOwnProperty.call(currentLocusPart, key)) {
489
- findAndStoreMetaData(currentLocusPart[key]);
470
+ findAndStoreMetaData(currentLocusPart[key], key);
490
471
  }
491
472
  }
492
473
  }
493
474
  };
494
475
 
495
- findAndStoreMetaData(locus);
476
+ findAndStoreMetaData(locus, 'locus');
496
477
 
497
478
  return leafInfo;
498
479
  }
@@ -584,6 +565,32 @@ class HashTreeParser {
584
565
  });
585
566
  }
586
567
 
568
+ /**
569
+ * Handles known errors that can happen during syncs
570
+ *
571
+ * @param {any} error - The error to handle
572
+ * @returns {boolean} true if the error was recognized and handled, false otherwise
573
+ */
574
+ private handleSyncErrors(error: any) {
575
+ if (error instanceof MeetingEndedError) {
576
+ this.callLocusInfoUpdateCallback({
577
+ updateType: LocusInfoUpdateType.MEETING_ENDED,
578
+ });
579
+
580
+ return true;
581
+ }
582
+ if (error instanceof LocusNotFoundError) {
583
+ this.callLocusInfoUpdateCallback({
584
+ updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND,
585
+ });
586
+ this.stop();
587
+
588
+ return true;
589
+ }
590
+
591
+ return false;
592
+ }
593
+
587
594
  /**
588
595
  * Asynchronously initializes new visible data sets
589
596
  *
@@ -600,11 +607,7 @@ class HashTreeParser {
600
607
  );
601
608
  queueMicrotask(() => {
602
609
  this.initializeNewVisibleDataSets(dataSetsRequiringInitialization).catch((error) => {
603
- if (error instanceof MeetingEndedError) {
604
- this.callLocusInfoUpdateCallback({
605
- updateType: LocusInfoUpdateType.MEETING_ENDED,
606
- });
607
- } else {
610
+ if (!this.handleSyncErrors(error)) {
608
611
  LoggerProxy.logger.warn(
609
612
  `HashTreeParser#queueInitForNewVisibleDataSets --> ${
610
613
  this.debugId
@@ -683,6 +686,7 @@ class HashTreeParser {
683
686
  const {dataSets, locus, metadata} = update;
684
687
 
685
688
  if (!dataSets) {
689
+ // this happens for example when we handle GET /loci response
686
690
  LoggerProxy.logger.info(
687
691
  `HashTreeParser#handleLocusUpdate --> ${this.debugId} received hash tree update without dataSets`
688
692
  );
@@ -786,6 +790,18 @@ class HashTreeParser {
786
790
  }
787
791
  }
788
792
 
793
+ /**
794
+ * Updates the leaf count for a data set, resizing its hash tree accordingly.
795
+ *
796
+ * @param {InternalDataSet} dataSet - The data set to update
797
+ * @param {number} newLeafCount - The new leaf count
798
+ * @returns {void}
799
+ */
800
+ private updateDataSetLeafCount(dataSet: InternalDataSet, newLeafCount: number): void {
801
+ dataSet.hashTree?.resize(newLeafCount);
802
+ dataSet.leafCount = newLeafCount;
803
+ }
804
+
789
805
  /**
790
806
  * Checks for changes in the visible data sets based on the updated objects.
791
807
  * @param {HashTreeObject[]} updatedObjects - The list of updated hash tree objects.
@@ -952,7 +968,7 @@ class HashTreeParser {
952
968
  }
953
969
  const allDataSets = await this.getAllVisibleDataSetsFromLocus();
954
970
 
955
- for (const ds of addedDataSets) {
971
+ for (const ds of sortByInitPriority(addedDataSets, DATA_SET_INIT_PRIORITY)) {
956
972
  const dataSetInfo = allDataSets.find((d) => d.name === ds.name);
957
973
 
958
974
  LoggerProxy.logger.info(
@@ -964,12 +980,8 @@ class HashTreeParser {
964
980
  `HashTreeParser#initializeNewVisibleDataSets --> ${this.debugId} missing info about data set "${ds.name}" in Locus response from visibleDataSetsUrl`
965
981
  );
966
982
  } 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
983
  // eslint-disable-next-line no-await-in-loop
970
- const updates = await this.initializeNewVisibleDataSet(ds, dataSetInfo);
971
-
972
- this.callLocusInfoUpdateCallback(updates);
984
+ await this.initializeNewVisibleDataSet(ds, dataSetInfo);
973
985
  }
974
986
  }
975
987
  }
@@ -1007,7 +1019,7 @@ class HashTreeParser {
1007
1019
 
1008
1020
  // when we detect new visible datasets, it may be that the metadata about them is not
1009
1021
  // available in the message, they will require separate async initialization
1010
- let dataSetsRequiringInitialization = [];
1022
+ let dataSetsRequiringInitialization: VisibleDataSetInfo[] = [];
1011
1023
 
1012
1024
  // first find out if there are any visible data set changes - they're signalled in Metadata object updates
1013
1025
  const metadataUpdates = (message.locusStateElements || []).filter((object) =>
@@ -1015,7 +1027,7 @@ class HashTreeParser {
1015
1027
  );
1016
1028
 
1017
1029
  if (metadataUpdates.length > 0) {
1018
- const updatedMetadataObjects = [];
1030
+ const updatedMetadataObjects: HashTreeObject[] = [];
1019
1031
 
1020
1032
  metadataUpdates.forEach((object) => {
1021
1033
  // todo: once Locus supports it, we will use the "view" field here instead of dataSetNames
@@ -1044,7 +1056,7 @@ class HashTreeParser {
1044
1056
  }
1045
1057
  }
1046
1058
 
1047
- if (message.locusStateElements?.length > 0) {
1059
+ if (message.locusStateElements && message.locusStateElements.length > 0) {
1048
1060
  // by this point we now have this.dataSets setup for data sets from this message
1049
1061
  // and hash trees created for the new visible data sets,
1050
1062
  // so we can now process all the updates from the message
@@ -1140,20 +1152,17 @@ class HashTreeParser {
1140
1152
  * @param {Object} updates parsed from a Locus message
1141
1153
  * @returns {void}
1142
1154
  */
1143
- private callLocusInfoUpdateCallback(updates: {
1144
- updateType: LocusInfoUpdateType;
1145
- updatedObjects?: HashTreeObject[];
1146
- }) {
1155
+ private callLocusInfoUpdateCallback(updates: LocusInfoUpdate) {
1147
1156
  if (this.state === 'stopped') {
1148
1157
  return;
1149
1158
  }
1150
1159
 
1151
- const {updateType, updatedObjects} = updates;
1160
+ const {updateType} = updates;
1152
1161
 
1153
- if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updatedObjects?.length > 0) {
1162
+ if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updates.updatedObjects?.length > 0) {
1154
1163
  // Filter out updates for objects that already have a higher version in their datasets,
1155
1164
  // or removals for objects that still exist in any of their datasets
1156
- const filteredUpdates = updatedObjects.filter((object) => {
1165
+ const filteredUpdates = updates.updatedObjects.filter((object) => {
1157
1166
  const {elementId} = object.htMeta;
1158
1167
  const {type, id, version} = elementId;
1159
1168
 
@@ -1190,10 +1199,10 @@ class HashTreeParser {
1190
1199
  });
1191
1200
 
1192
1201
  if (filteredUpdates.length > 0) {
1193
- this.locusInfoUpdateCallback(updateType, {updatedObjects: filteredUpdates});
1202
+ this.locusInfoUpdateCallback({updateType, updatedObjects: filteredUpdates});
1194
1203
  }
1195
1204
  } else if (updateType !== LocusInfoUpdateType.OBJECTS_UPDATED) {
1196
- this.locusInfoUpdateCallback(updateType, {updatedObjects});
1205
+ this.locusInfoUpdateCallback({updateType});
1197
1206
  }
1198
1207
  }
1199
1208
 
@@ -1215,76 +1224,89 @@ class HashTreeParser {
1215
1224
  * Performs a sync for the given data set.
1216
1225
  *
1217
1226
  * @param {InternalDataSet} dataSet - The data set to sync
1218
- * @param {string} rootHash - Our current root hash for this data set
1219
1227
  * @param {string} reason - The reason for the sync (used for logging)
1228
+ * @param {boolean} [isInitialization] - Whether this is an initialization sync (sends empty leaves data instead of comparing hashes)
1220
1229
  * @returns {Promise<void>}
1221
1230
  */
1222
1231
  private async performSync(
1223
1232
  dataSet: InternalDataSet,
1224
- rootHash: string,
1225
- reason: string
1233
+ reason: string,
1234
+ isInitialization?: boolean
1226
1235
  ): Promise<void> {
1227
1236
  if (!dataSet.hashTree) {
1228
1237
  return;
1229
1238
  }
1230
1239
 
1240
+ const {hashTree} = dataSet;
1241
+ const rootHash = hashTree.getRootHash();
1242
+
1231
1243
  try {
1232
1244
  LoggerProxy.logger.info(
1233
1245
  `HashTreeParser#performSync --> ${this.debugId} ${reason}, syncing data set "${dataSet.name}"`
1234
1246
  );
1235
1247
 
1236
- const mismatchedLeavesData: Record<number, LeafDataItem[]> = {};
1248
+ let leavesData: Record<number, LeafDataItem[]> = {};
1237
1249
 
1238
- if (dataSet.leafCount !== 1) {
1239
- let receivedHashes;
1250
+ if (!isInitialization) {
1251
+ if (dataSet.leafCount !== 1) {
1252
+ let receivedHashes;
1240
1253
 
1241
- try {
1242
- // request hashes from sender
1243
- const {hashes, dataSet: latestDataSetInfo} = await this.getHashesFromLocus(
1244
- dataSet.name,
1245
- rootHash
1246
- );
1254
+ try {
1255
+ // request hashes from sender
1256
+ const hashesResult = await this.getHashesFromLocus(dataSet.name, rootHash);
1247
1257
 
1248
- receivedHashes = hashes;
1258
+ if (!hashesResult) {
1259
+ // hashes match, no sync needed
1260
+ return;
1261
+ }
1249
1262
 
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
- );
1263
+ receivedHashes = hashesResult.hashes;
1257
1264
 
1258
- return;
1265
+ this.updateDataSetLeafCount(dataSet, hashesResult.dataSet.leafCount);
1266
+ } catch (error: any) {
1267
+ if (error?.statusCode === 409) {
1268
+ // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1269
+ LoggerProxy.logger.info(
1270
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
1271
+ );
1272
+
1273
+ return;
1274
+ }
1275
+ throw error;
1259
1276
  }
1260
- throw error;
1261
- }
1262
1277
 
1263
- // identify mismatched leaves
1264
- const mismatchedLeaveIndexes = dataSet.hashTree.diffHashes(receivedHashes);
1278
+ // identify mismatched leaves
1279
+ const mismatchedLeaveIndexes = hashTree.diffHashes(receivedHashes);
1265
1280
 
1266
- mismatchedLeaveIndexes.forEach((index) => {
1267
- mismatchedLeavesData[index] = dataSet.hashTree.getLeafData(index);
1268
- });
1269
- } else {
1270
- mismatchedLeavesData[0] = dataSet.hashTree.getLeafData(0);
1281
+ mismatchedLeaveIndexes.forEach((index) => {
1282
+ leavesData[index] = hashTree.getLeafData(index);
1283
+ });
1284
+ } else {
1285
+ leavesData = {0: hashTree.getLeafData(0)};
1286
+ }
1271
1287
  }
1272
1288
  // request sync for mismatched leaves
1273
- if (Object.keys(mismatchedLeavesData).length > 0) {
1274
- const syncResponse = await this.sendSyncRequestToLocus(dataSet, mismatchedLeavesData);
1289
+ let syncResponse: HashTreeMessage | null = null;
1275
1290
 
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
- }
1291
+ if (isInitialization) {
1292
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {isInitialization: true});
1293
+ } else if (Object.keys(leavesData).length > 0) {
1294
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {
1295
+ mismatchedLeavesData: leavesData,
1296
+ });
1297
+ }
1298
+
1299
+ // sync API may return nothing (in that case data will arrive via messages)
1300
+ // or it may return a response in the same format as messages
1301
+ // We still need to restart the sync timer as a safety net in case the messages don't arrive.
1302
+ this.runSyncAlgorithm(dataSet);
1303
+
1304
+ if (syncResponse) {
1305
+ // the format of sync response is the same as messages, so we can reuse the same handler
1306
+ this.handleMessage(syncResponse, 'via sync API');
1281
1307
  }
1282
1308
  } catch (error) {
1283
- if (error instanceof MeetingEndedError) {
1284
- this.callLocusInfoUpdateCallback({
1285
- updateType: LocusInfoUpdateType.MEETING_ENDED,
1286
- });
1287
- } else {
1309
+ if (!this.handleSyncErrors(error)) {
1288
1310
  LoggerProxy.logger.warn(
1289
1311
  `HashTreeParser#performSync --> ${this.debugId} error during sync for data set "${dataSet.name}":`,
1290
1312
  error
@@ -1293,6 +1315,105 @@ class HashTreeParser {
1293
1315
  }
1294
1316
  }
1295
1317
 
1318
+ /**
1319
+ * Enqueues a sync for the given data set. If the data set is already in the queue, the request is ignored.
1320
+ * This ensures that all syncs are executed sequentially and no more than 1 sync runs at a time.
1321
+ *
1322
+ * @param {string} dataSetName - The name of the data set to sync
1323
+ * @param {string} reason - The reason for the sync (used for logging)
1324
+ * @param {boolean} [isInitialization=false] - Whether this is an initialization sync (uses empty leaves data instead of hash comparison)
1325
+ * @returns {void}
1326
+ */
1327
+ private enqueueSyncForDataset(
1328
+ dataSetName: string,
1329
+ reason: string,
1330
+ isInitialization = false
1331
+ ): void {
1332
+ if (this.state === 'stopped') return;
1333
+
1334
+ const existingEntry = this.syncQueue.find((entry) => entry.dataSetName === dataSetName);
1335
+
1336
+ if (existingEntry) {
1337
+ if (isInitialization) {
1338
+ existingEntry.isInitialization = true;
1339
+ }
1340
+ LoggerProxy.logger.info(
1341
+ `HashTreeParser#enqueueSyncForDataset --> ${this.debugId} data set "${dataSetName}" already in sync queue, skipping`
1342
+ );
1343
+
1344
+ return;
1345
+ }
1346
+
1347
+ this.syncQueue.push({dataSetName, reason, isInitialization});
1348
+
1349
+ if (!this.isSyncInProgress) {
1350
+ this.syncQueueProcessingPromise = this.processSyncQueue();
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * Processes the sync queue sequentially. Only one instance of this method runs at a time.
1356
+ *
1357
+ * @returns {Promise<void>}
1358
+ */
1359
+ private async processSyncQueue(): Promise<void> {
1360
+ if (this.isSyncInProgress) return;
1361
+
1362
+ this.isSyncInProgress = true;
1363
+ try {
1364
+ while (this.syncQueue.length > 0 && this.state !== 'stopped') {
1365
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1366
+ const {dataSetName, reason, isInitialization} = this.syncQueue.shift()!;
1367
+ const dataSet = this.dataSets[dataSetName];
1368
+
1369
+ if (!dataSet?.hashTree) {
1370
+ // eslint-disable-next-line no-continue
1371
+ continue;
1372
+ }
1373
+
1374
+ // eslint-disable-next-line no-await-in-loop
1375
+ await this.performSync(dataSet, reason, isInitialization);
1376
+ }
1377
+ } finally {
1378
+ this.isSyncInProgress = false;
1379
+ }
1380
+ }
1381
+
1382
+ /**
1383
+ * Syncs all data sets that have hash trees, one by one in sequence, using the priority order
1384
+ * provided by sortByInitPriority(). Does nothing if the parser is stopped or if a syncAllDatasets
1385
+ * call is already in progress.
1386
+ *
1387
+ * @returns {Promise<void>}
1388
+ */
1389
+ public async syncAllDatasets(): Promise<void> {
1390
+ if (this.state === 'stopped') return;
1391
+ if (this.isSyncAllInProgress) return;
1392
+
1393
+ this.isSyncAllInProgress = true;
1394
+ try {
1395
+ const dataSetsWithHashTrees = Object.values(this.dataSets)
1396
+ .filter((dataSet) => dataSet?.hashTree)
1397
+ .map((dataSet) => ({name: dataSet.name}));
1398
+
1399
+ const sorted = sortByInitPriority(dataSetsWithHashTrees, DATA_SET_INIT_PRIORITY);
1400
+
1401
+ LoggerProxy.logger.info(
1402
+ `HashTreeParser#syncAllDatasets --> ${this.debugId} syncing datasets: ${sorted
1403
+ .map((ds) => ds.name)
1404
+ .join(', ')}`
1405
+ );
1406
+
1407
+ for (const ds of sorted) {
1408
+ this.enqueueSyncForDataset(ds.name, 'syncAllDatasets');
1409
+ }
1410
+
1411
+ await this.syncQueueProcessingPromise;
1412
+ } finally {
1413
+ this.isSyncAllInProgress = false;
1414
+ }
1415
+ }
1416
+
1296
1417
  /**
1297
1418
  * Runs the sync algorithm for the given data set.
1298
1419
  *
@@ -1320,12 +1441,6 @@ class HashTreeParser {
1320
1441
 
1321
1442
  dataSet.hashTree.resize(receivedDataSet.leafCount);
1322
1443
 
1323
- // temporary log for the workshop // todo: remove
1324
- const ourCurrentRootHash = dataSet.hashTree.getRootHash();
1325
- LoggerProxy.logger.info(
1326
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} dataSet="${dataSet.name}" version=${dataSet.version} hashes before starting timer: ours=${ourCurrentRootHash} Locus=${dataSet.root}`
1327
- );
1328
-
1329
1444
  const delay = dataSet.idleMs + this.getWeightedBackoffTime(dataSet.backoff);
1330
1445
 
1331
1446
  if (delay > 0) {
@@ -1337,7 +1452,7 @@ class HashTreeParser {
1337
1452
  `HashTreeParser#runSyncAlgorithm --> ${this.debugId} setting "${dataSet.name}" sync timer for ${delay}`
1338
1453
  );
1339
1454
 
1340
- dataSet.timer = setTimeout(async () => {
1455
+ dataSet.timer = setTimeout(() => {
1341
1456
  dataSet.timer = undefined;
1342
1457
 
1343
1458
  if (!dataSet.hashTree) {
@@ -1351,9 +1466,8 @@ class HashTreeParser {
1351
1466
  const rootHash = dataSet.hashTree.getRootHash();
1352
1467
 
1353
1468
  if (dataSet.root !== rootHash) {
1354
- await this.performSync(
1355
- dataSet,
1356
- rootHash,
1469
+ this.enqueueSyncForDataset(
1470
+ dataSet.name,
1357
1471
  `Root hash mismatch: received=${dataSet.root}, ours=${rootHash}`
1358
1472
  );
1359
1473
  } else {
@@ -1399,18 +1513,15 @@ class HashTreeParser {
1399
1513
  const backoffTime = this.getWeightedBackoffTime(dataSet.backoff);
1400
1514
  const delay = this.heartbeatIntervalMs + backoffTime;
1401
1515
 
1402
- dataSet.heartbeatWatchdogTimer = setTimeout(async () => {
1516
+ dataSet.heartbeatWatchdogTimer = setTimeout(() => {
1403
1517
  dataSet.heartbeatWatchdogTimer = undefined;
1404
1518
 
1405
1519
  LoggerProxy.logger.warn(
1406
1520
  `HashTreeParser#resetHeartbeatWatchdogs --> ${this.debugId} Heartbeat watchdog fired for data set "${dataSet.name}" - no heartbeat received within expected interval, initiating sync`
1407
1521
  );
1408
1522
 
1409
- await this.performSync(
1410
- dataSet,
1411
- dataSet.hashTree.getRootHash(),
1412
- `heartbeat watchdog expired`
1413
- );
1523
+ this.enqueueSyncForDataset(dataSet.name, `heartbeat watchdog expired`);
1524
+ this.resetHeartbeatWatchdogs([dataSet]);
1414
1525
  }, delay);
1415
1526
  }
1416
1527
  }
@@ -1443,6 +1554,7 @@ class HashTreeParser {
1443
1554
  `HashTreeParser#stop --> ${this.debugId} Stopping HashTreeParser, clearing timers and hash trees`
1444
1555
  );
1445
1556
  this.stopAllTimers();
1557
+ this.syncQueue = [];
1446
1558
  Object.values(this.dataSets).forEach((dataSet) => {
1447
1559
  dataSet.hashTree = undefined;
1448
1560
  });
@@ -1451,17 +1563,27 @@ class HashTreeParser {
1451
1563
  }
1452
1564
 
1453
1565
  /**
1454
- * Resumes the HashTreeParser that was previously stopped.
1566
+ * Cleans up the HashTreeParser, stopping all timers and clearing all internal state.
1567
+ * After calling this, the parser should not be used anymore.
1568
+ * @returns {void}
1569
+ */
1570
+ public cleanUp() {
1571
+ this.stop();
1572
+ this.dataSets = {};
1573
+ }
1574
+
1575
+ /**
1576
+ * Resumes the HashTreeParser that was previously stopped, using a hash tree message.
1455
1577
  * @param {HashTreeMessage} message - The message to resume with, it must contain metadata with visible data sets info
1456
1578
  * @returns {void}
1457
1579
  */
1458
- public resume(message: HashTreeMessage) {
1580
+ public resumeFromMessage(message: HashTreeMessage) {
1459
1581
  // check that message contains metadata with visible data sets - this is essential to be able to resume
1460
1582
  const metadataObject = message.locusStateElements?.find((el) => isMetadata(el));
1461
1583
 
1462
1584
  if (!metadataObject?.data?.visibleDataSets) {
1463
1585
  LoggerProxy.logger.warn(
1464
- `HashTreeParser#resume --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1586
+ `HashTreeParser#resumeFromMessage --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1465
1587
  );
1466
1588
 
1467
1589
  return;
@@ -1482,7 +1604,7 @@ class HashTreeParser {
1482
1604
  };
1483
1605
  }
1484
1606
  LoggerProxy.logger.info(
1485
- `HashTreeParser#resume --> ${
1607
+ `HashTreeParser#resumeFromMessage --> ${
1486
1608
  this.debugId
1487
1609
  } Resuming HashTreeParser with data sets: ${Object.keys(this.dataSets).join(
1488
1610
  ', '
@@ -1493,18 +1615,47 @@ class HashTreeParser {
1493
1615
  this.handleMessage(message, 'on resume');
1494
1616
  }
1495
1617
 
1618
+ /**
1619
+ * Resumes the HashTreeParser that was previously stopped, using a Locus API response.
1620
+ * Unlike resumeFromMessage(), this does not require metadata/dataSets in the input,
1621
+ * as it fetches all necessary information from Locus via initializeFromGetLociResponse.
1622
+ * @param {LocusDTO} locus - locus object from an API response
1623
+ * @returns {Promise}
1624
+ */
1625
+ public async resumeFromApiResponse(locus: LocusDTO) {
1626
+ this.state = 'active';
1627
+ this.dataSets = {};
1628
+
1629
+ LoggerProxy.logger.info(
1630
+ `HashTreeParser#resumeFromApiResponse --> ${this.debugId} Resuming HashTreeParser from API response`
1631
+ );
1632
+
1633
+ await this.initializeFromGetLociResponse(locus);
1634
+ }
1635
+
1496
1636
  private checkForSentinelHttpResponse(error: any, dataSetName?: string) {
1637
+ // 404 for any dataset means the locus is no longer available at this URL - could be replaced or ended
1638
+ // if a dataset is just not visible, we would get a 400
1639
+ if (error.statusCode === 404) {
1640
+ LoggerProxy.logger.info(
1641
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received 404 for data set "${dataSetName}", locus not found`
1642
+ );
1643
+ this.stopAllTimers();
1644
+
1645
+ throw new LocusNotFoundError();
1646
+ }
1647
+
1497
1648
  const isValidDataSetForSentinel =
1498
1649
  dataSetName === undefined ||
1499
1650
  PossibleSentinelMessageDataSetNames.includes(dataSetName.toLowerCase());
1500
1651
 
1501
1652
  if (
1502
- ((error.statusCode === 409 && error.body?.errorCode === 2403004) ||
1503
- error.statusCode === 404) &&
1653
+ error.statusCode === 409 &&
1654
+ error.body?.errorCode === LocusErrorCodes.LOCUS_INACTIVE &&
1504
1655
  isValidDataSetForSentinel
1505
1656
  ) {
1506
1657
  LoggerProxy.logger.info(
1507
- `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode} for data set "${dataSetName}", indicating that the meeting has ended`
1658
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode}/${error.body?.errorCode} for data set "${dataSetName}", indicating that the meeting has ended`
1508
1659
  );
1509
1660
  this.stopAllTimers();
1510
1661
 
@@ -1516,7 +1667,7 @@ class HashTreeParser {
1516
1667
  * Gets the current hashes from the locus for a specific data set.
1517
1668
  * @param {string} dataSetName
1518
1669
  * @param {string} currentRootHash
1519
- * @returns {string[]}
1670
+ * @returns {Object|null} An object containing the hashes and leaf count, or null if the hashes match and no sync is needed
1520
1671
  */
1521
1672
  private getHashesFromLocus(dataSetName: string, currentRootHash: string) {
1522
1673
  LoggerProxy.logger.info(
@@ -1535,6 +1686,15 @@ class HashTreeParser {
1535
1686
  },
1536
1687
  })
1537
1688
  .then((response) => {
1689
+ if (!response.body || isEmpty(response.body)) {
1690
+ // 204 with empty body means our hashes match Locus, no sync needed
1691
+ LoggerProxy.logger.info(
1692
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got ${response.statusCode} with empty body for data set "${dataSetName}", hashes match - no sync needed`
1693
+ );
1694
+
1695
+ return null;
1696
+ }
1697
+
1538
1698
  const hashes = response.body?.hashes as string[] | undefined;
1539
1699
  const dataSetFromResponse = response.body?.dataSet;
1540
1700
 
@@ -1563,6 +1723,13 @@ class HashTreeParser {
1563
1723
  error
1564
1724
  );
1565
1725
  this.checkForSentinelHttpResponse(error, dataSet.name);
1726
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
1727
+ debugId: this.debugId,
1728
+ dataSetName,
1729
+ request: 'GET /hashtree',
1730
+ statusCode: error.statusCode,
1731
+ reason: error.message,
1732
+ });
1566
1733
 
1567
1734
  throw error;
1568
1735
  });
@@ -1572,29 +1739,43 @@ class HashTreeParser {
1572
1739
  * Sends a sync request to Locus for the specified data set.
1573
1740
  *
1574
1741
  * @param {InternalDataSet} dataSet The data set to sync.
1575
- * @param {Record<number, LeafDataItem[]>} mismatchedLeavesData The mismatched leaves data to include in the sync request.
1742
+ * @param {Object} options Either `{ isInitialization: true }` for init syncs (uses leafCount=1 with empty leaf data) or `{ mismatchedLeavesData }` for normal syncs.
1576
1743
  * @returns {Promise<HashTreeMessage|null>}
1577
1744
  */
1578
1745
  private sendSyncRequestToLocus(
1579
1746
  dataSet: InternalDataSet,
1580
- mismatchedLeavesData: Record<number, LeafDataItem[]>
1747
+ options: {isInitialization: true} | {mismatchedLeavesData: Record<number, LeafDataItem[]>}
1581
1748
  ): Promise<HashTreeMessage | null> {
1582
1749
  LoggerProxy.logger.info(
1583
1750
  `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Sending sync request for data set "${dataSet.name}"`
1584
1751
  );
1585
1752
 
1753
+ const isInitialization = 'isInitialization' in options;
1754
+
1586
1755
  const url = `${dataSet.url}/sync`;
1587
- const body = {
1588
- leafCount: dataSet.leafCount,
1756
+ const body: {
1757
+ leafCount: number;
1758
+ leafDataEntries: {leafIndex: number; elementIds: LeafDataItem[]}[];
1759
+ } = {
1760
+ leafCount: isInitialization ? 1 : dataSet.leafCount,
1589
1761
  leafDataEntries: [],
1590
1762
  };
1591
1763
 
1592
- Object.keys(mismatchedLeavesData).forEach((index) => {
1593
- body.leafDataEntries.push({
1594
- leafIndex: parseInt(index, 10),
1595
- elementIds: mismatchedLeavesData[index],
1764
+ if (isInitialization) {
1765
+ // initialization sync: Locus requires leafCount=1 with a single empty leaf
1766
+ body.leafDataEntries.push({leafIndex: 0, elementIds: []});
1767
+ } else {
1768
+ const {mismatchedLeavesData} = options;
1769
+
1770
+ Object.keys(mismatchedLeavesData).forEach((index) => {
1771
+ const leafIndex = parseInt(index, 10);
1772
+
1773
+ body.leafDataEntries.push({
1774
+ leafIndex,
1775
+ elementIds: mismatchedLeavesData[leafIndex],
1776
+ });
1596
1777
  });
1597
- });
1778
+ }
1598
1779
 
1599
1780
  const ourCurrentRootHash = dataSet.hashTree ? dataSet.hashTree.getRootHash() : EMPTY_HASH;
1600
1781
 
@@ -1627,6 +1808,13 @@ class HashTreeParser {
1627
1808
  error
1628
1809
  );
1629
1810
  this.checkForSentinelHttpResponse(error, dataSet.name);
1811
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
1812
+ debugId: this.debugId,
1813
+ dataSetName: dataSet.name,
1814
+ request: 'POST /sync',
1815
+ statusCode: error.statusCode,
1816
+ reason: error.message,
1817
+ });
1630
1818
 
1631
1819
  throw error;
1632
1820
  });