@fluidframework/map 1.0.0 → 1.1.0-76254

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.
package/src/mapKernel.ts CHANGED
@@ -9,7 +9,6 @@ import { assert, TypedEventEmitter } from "@fluidframework/common-utils";
9
9
  import {
10
10
  ISerializableValue,
11
11
  ISerializedValue,
12
- IValueChanged,
13
12
  ISharedMapEvents,
14
13
  } from "./interfaces";
15
14
  import {
@@ -112,6 +111,40 @@ export interface IMapDataObjectSerialized {
112
111
  [key: string]: ISerializedValue;
113
112
  }
114
113
 
114
+ interface IMapKeyEditLocalOpMetadata {
115
+ type: "edit";
116
+ pendingMessageId: number;
117
+ previousValue?: ILocalValue;
118
+ }
119
+
120
+ interface IMapKeyAddLocalOpMetadata {
121
+ type: "add";
122
+ pendingMessageId: number;
123
+ }
124
+
125
+ interface IMapClearLocalOpMetadata {
126
+ type: "clear";
127
+ pendingMessageId: number;
128
+ previousMap?: Map<string, ILocalValue>;
129
+ }
130
+
131
+ type MapKeyLocalOpMetadata = IMapKeyEditLocalOpMetadata | IMapKeyAddLocalOpMetadata;
132
+ type MapLocalOpMetadata = IMapClearLocalOpMetadata | MapKeyLocalOpMetadata;
133
+
134
+ function isMapKeyLocalOpMetadata(metadata: any): metadata is MapKeyLocalOpMetadata {
135
+ return metadata !== undefined && typeof metadata.pendingMessageId === "number" &&
136
+ (metadata.type === "add" || metadata.type === "edit");
137
+ }
138
+
139
+ function isClearLocalOpMetadata(metadata: any): metadata is IMapClearLocalOpMetadata {
140
+ return metadata !== undefined && metadata.type === "clear" && typeof metadata.pendingMessageId === "number";
141
+ }
142
+
143
+ function isMapLocalOpMetadata(metadata: any): metadata is MapLocalOpMetadata {
144
+ return metadata !== undefined && typeof metadata.pendingMessageId === "number" &&
145
+ (metadata.type === "add" || metadata.type === "edit" || metadata.type === "clear");
146
+ }
147
+
115
148
  /**
116
149
  * A SharedMap is a map-like distributed data structure.
117
150
  */
@@ -136,7 +169,7 @@ export class MapKernel {
136
169
  /**
137
170
  * Keys that have been modified locally but not yet ack'd from the server.
138
171
  */
139
- private readonly pendingKeys: Map<string, number> = new Map();
172
+ private readonly pendingKeys: Map<string, number[]> = new Map();
140
173
 
141
174
  /**
142
175
  * This is used to assign a unique id to every outgoing operation and helps in tracking unack'd ops.
@@ -144,10 +177,9 @@ export class MapKernel {
144
177
  private pendingMessageId: number = -1;
145
178
 
146
179
  /**
147
- * If a clear has been performed locally but not yet ack'd from the server, then this stores the pending id
148
- * of that clear operation. Otherwise, is -1.
180
+ * The pending ids of any clears that have been performed locally but not yet ack'd from the server
149
181
  */
150
- private pendingClearMessageId: number = -1;
182
+ private readonly pendingClearMessageIds: number[] = [];
151
183
 
152
184
  /**
153
185
  * Object to create encapsulations of the values stored in the map.
@@ -250,14 +282,8 @@ export class MapKernel {
250
282
  * {@inheritDoc ISharedMap.get}
251
283
  */
252
284
  public get<T = any>(key: string): T | undefined {
253
- if (!this.data.has(key)) {
254
- return undefined;
255
- }
256
-
257
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
258
- const localValue = this.data.get(key)!;
259
-
260
- return localValue.value as T;
285
+ const localValue = this.data.get(key);
286
+ return localValue === undefined ? undefined : localValue.value as T;
261
287
  }
262
288
 
263
289
  /**
@@ -286,7 +312,7 @@ export class MapKernel {
286
312
  this.handle);
287
313
 
288
314
  // Set the value locally.
289
- this.setCore(
315
+ const previousValue = this.setCore(
290
316
  key,
291
317
  localValue,
292
318
  true,
@@ -302,7 +328,7 @@ export class MapKernel {
302
328
  type: "set",
303
329
  value: serializableValue,
304
330
  };
305
- this.submitMapKeyMessage(op);
331
+ this.submitMapKeyMessage(op, previousValue);
306
332
  }
307
333
 
308
334
  /**
@@ -312,26 +338,28 @@ export class MapKernel {
312
338
  */
313
339
  public delete(key: string): boolean {
314
340
  // Delete the key locally first.
315
- const successfullyRemoved = this.deleteCore(key, true);
341
+ const previousValue = this.deleteCore(key, true);
316
342
 
317
343
  // If we are not attached, don't submit the op.
318
344
  if (!this.isAttached()) {
319
- return successfullyRemoved;
345
+ return previousValue !== undefined;
320
346
  }
321
347
 
322
348
  const op: IMapDeleteOperation = {
323
349
  key,
324
350
  type: "delete",
325
351
  };
326
- this.submitMapKeyMessage(op);
352
+ this.submitMapKeyMessage(op, previousValue);
327
353
 
328
- return successfullyRemoved;
354
+ return previousValue !== undefined;
329
355
  }
330
356
 
331
357
  /**
332
358
  * Clear all data from the map.
333
359
  */
334
360
  public clear(): void {
361
+ const copy = this.isAttached() ? new Map<string, ILocalValue>(this.data) : undefined;
362
+
335
363
  // Clear the data locally first.
336
364
  this.clearCore(true);
337
365
 
@@ -343,7 +371,7 @@ export class MapKernel {
343
371
  const op: IMapClearOperation = {
344
372
  type: "clear",
345
373
  };
346
- this.submitMapClearMessage(op);
374
+ this.submitMapClearMessage(op, copy);
347
375
  }
348
376
 
349
377
  /**
@@ -399,27 +427,25 @@ export class MapKernel {
399
427
  * @returns True if the operation was submitted, false otherwise.
400
428
  */
401
429
  public trySubmitMessage(op: any, localOpMetadata: unknown): boolean {
402
- const type: string = op.type;
403
- if (this.messageHandlers.has(type)) {
404
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
405
- this.messageHandlers.get(type)!.submit(op as IMapOperation, localOpMetadata);
406
- return true;
430
+ const handler = this.messageHandlers.get(op.type);
431
+ if (handler === undefined) {
432
+ return false;
407
433
  }
408
- return false;
434
+ handler.submit(op as IMapOperation, localOpMetadata);
435
+ return true;
409
436
  }
410
437
 
411
438
  public tryGetStashedOpLocalMetadata(op: any): unknown {
412
- const type: string = op.type;
413
- if (this.messageHandlers.has(type)) {
414
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
415
- return this.messageHandlers.get(type)!.getStashedOpLocalMetadata(op as IMapOperation);
439
+ const handler = this.messageHandlers.get(op.type);
440
+ if (handler === undefined) {
441
+ throw new Error("no apply stashed op handler");
416
442
  }
417
- throw new Error("no apply stashed op handler");
443
+ return handler.getStashedOpLocalMetadata(op as IMapOperation);
418
444
  }
419
445
 
420
446
  /**
421
447
  * Process the given op if a handler is registered.
422
- * @param message - The message to process
448
+ * @param op - The message to process
423
449
  * @param local - Whether the message originated from the local client
424
450
  * @param localOpMetadata - For local client messages, this is the metadata that was submitted with the message.
425
451
  * For messages from a remote client, this will be undefined.
@@ -430,14 +456,56 @@ export class MapKernel {
430
456
  local: boolean,
431
457
  localOpMetadata: unknown,
432
458
  ): boolean {
433
- if (this.messageHandlers.has(op.type)) {
434
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
435
- this.messageHandlers
436
- .get(op.type)!
437
- .process(op, local, localOpMetadata);
438
- return true;
459
+ const handler = this.messageHandlers.get(op.type);
460
+ if (handler === undefined) {
461
+ return false;
462
+ }
463
+ handler.process(op, local, localOpMetadata);
464
+ return true;
465
+ }
466
+
467
+ /**
468
+ * Rollback a local op
469
+ * @param op - The operation to rollback
470
+ * @param localOpMetadata - The local metadata associated with the op.
471
+ */
472
+ public rollback(op: any, localOpMetadata: unknown) {
473
+ if (!isMapLocalOpMetadata(localOpMetadata)) {
474
+ throw new Error("Invalid localOpMetadata");
475
+ }
476
+
477
+ if (op.type === "clear" && localOpMetadata.type === "clear") {
478
+ if (localOpMetadata.previousMap === undefined) {
479
+ throw new Error("Cannot rollback without previous map");
480
+ }
481
+ localOpMetadata.previousMap.forEach((localValue, key) => {
482
+ this.setCore(key, localValue, true);
483
+ });
484
+
485
+ const lastPendingClearId = this.pendingClearMessageIds.pop();
486
+ if (lastPendingClearId === undefined || lastPendingClearId !== localOpMetadata.pendingMessageId) {
487
+ throw new Error("Rollback op does match last clear");
488
+ }
489
+ } else if (op.type === "delete" || op.type === "set") {
490
+ if (localOpMetadata.type === "add") {
491
+ this.deleteCore(op.key, true);
492
+ } else if (localOpMetadata.type === "edit" && localOpMetadata.previousValue !== undefined) {
493
+ this.setCore(op.key, localOpMetadata.previousValue, true);
494
+ } else {
495
+ throw new Error("Cannot rollback without previous value");
496
+ }
497
+
498
+ const pendingMessageIds = this.pendingKeys.get(op.key);
499
+ const lastPendingMessageId = pendingMessageIds?.pop();
500
+ if (!pendingMessageIds || lastPendingMessageId !== localOpMetadata.pendingMessageId) {
501
+ throw new Error("Rollback op does not match last pending");
502
+ }
503
+ if (pendingMessageIds.length === 0) {
504
+ this.pendingKeys.delete(op.key);
505
+ }
506
+ } else {
507
+ throw new Error("Unsupported op for rollback");
439
508
  }
440
- return false;
441
509
  }
442
510
 
443
511
  /**
@@ -445,19 +513,19 @@ export class MapKernel {
445
513
  * @param key - The key being set
446
514
  * @param value - The value being set
447
515
  * @param local - Whether the message originated from the local client
448
- * @param op - The message if from a remote set, or null if from a local set
516
+ * @returns Previous local value of the key, if any
449
517
  */
450
- private setCore(key: string, value: ILocalValue, local: boolean): void {
451
- const previousValue = this.get(key);
518
+ private setCore(key: string, value: ILocalValue, local: boolean): ILocalValue | undefined {
519
+ const previousLocalValue = this.data.get(key);
520
+ const previousValue = previousLocalValue?.value;
452
521
  this.data.set(key, value);
453
- const event: IValueChanged = { key, previousValue };
454
- this.eventEmitter.emit("valueChanged", event, local, this.eventEmitter);
522
+ this.eventEmitter.emit("valueChanged", { key, previousValue }, local, this.eventEmitter);
523
+ return previousLocalValue;
455
524
  }
456
525
 
457
526
  /**
458
527
  * Clear implementation used for both locally sourced clears as well as incoming remote clears.
459
528
  * @param local - Whether the message originated from the local client
460
- * @param op - The message if from a remote clear, or null if from a local clear
461
529
  */
462
530
  private clearCore(local: boolean): void {
463
531
  this.data.clear();
@@ -468,17 +536,16 @@ export class MapKernel {
468
536
  * Delete implementation used for both locally sourced deletes as well as incoming remote deletes.
469
537
  * @param key - The key being deleted
470
538
  * @param local - Whether the message originated from the local client
471
- * @param op - The message if from a remote delete, or null if from a local delete
472
- * @returns True if the key existed and was deleted, false if it did not exist
539
+ * @returns Previous local value of the key if it existed, undefined if it did not exist
473
540
  */
474
- private deleteCore(key: string, local: boolean): boolean {
475
- const previousValue = this.get(key);
541
+ private deleteCore(key: string, local: boolean): ILocalValue | undefined {
542
+ const previousLocalValue = this.data.get(key);
543
+ const previousValue = previousLocalValue?.value;
476
544
  const successfullyRemoved = this.data.delete(key);
477
545
  if (successfullyRemoved) {
478
- const event: IValueChanged = { key, previousValue };
479
- this.eventEmitter.emit("valueChanged", event, local, this.eventEmitter);
546
+ this.eventEmitter.emit("valueChanged", { key, previousValue }, local, this.eventEmitter);
480
547
  }
481
- return successfullyRemoved;
548
+ return previousLocalValue;
482
549
  }
483
550
 
484
551
  /**
@@ -492,9 +559,9 @@ export class MapKernel {
492
559
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
493
560
  temp.set(key, this.data.get(key)!);
494
561
  });
495
- this.data.clear();
562
+ this.clearCore(false);
496
563
  temp.forEach((value, key) => {
497
- this.data.set(key, value);
564
+ this.setCore(key, value, true);
498
565
  });
499
566
  }
500
567
 
@@ -521,7 +588,6 @@ export class MapKernel {
521
588
  * not process the incoming operation.
522
589
  * @param op - Operation to check
523
590
  * @param local - Whether the message originated from the local client
524
- * @param message - The message
525
591
  * @param localOpMetadata - For local client messages, this is the metadata that was submitted with the message.
526
592
  * For messages from a remote client, this will be undefined.
527
593
  * @returns True if the operation should be processed, false otherwise
@@ -531,24 +597,28 @@ export class MapKernel {
531
597
  local: boolean,
532
598
  localOpMetadata: unknown,
533
599
  ): boolean {
534
- if (this.pendingClearMessageId !== -1) {
600
+ if (this.pendingClearMessageIds.length > 0) {
535
601
  if (local) {
536
- assert(localOpMetadata !== undefined && localOpMetadata as number < this.pendingClearMessageId,
602
+ assert(localOpMetadata !== undefined && isMapKeyLocalOpMetadata(localOpMetadata) &&
603
+ localOpMetadata.pendingMessageId < this.pendingClearMessageIds[0],
537
604
  0x013 /* "Received out of order op when there is an unackd clear message" */);
538
605
  }
539
606
  // If we have an unack'd clear, we can ignore all ops.
540
607
  return false;
541
608
  }
542
609
 
543
- if (this.pendingKeys.has(op.key)) {
610
+ const pendingKeyMessageId = this.pendingKeys.get(op.key);
611
+ if (pendingKeyMessageId !== undefined) {
544
612
  // Found an unack'd op. Clear it from the map if the pendingMessageId in the map matches this message's
545
613
  // and don't process the op.
546
614
  if (local) {
547
- assert(localOpMetadata !== undefined,
615
+ assert(localOpMetadata !== undefined && isMapKeyLocalOpMetadata(localOpMetadata),
548
616
  0x014 /* pendingMessageId is missing from the local client's operation */);
549
- const pendingMessageId = localOpMetadata as number;
550
- const pendingKeyMessageId = this.pendingKeys.get(op.key);
551
- if (pendingKeyMessageId === pendingMessageId) {
617
+ const pendingMessageIds = this.pendingKeys.get(op.key);
618
+ assert(pendingMessageIds !== undefined && pendingMessageIds[0] === localOpMetadata.pendingMessageId,
619
+ 0x2fa /* Unexpected pending message received */);
620
+ pendingMessageIds.shift();
621
+ if (pendingMessageIds.length === 0) {
552
622
  this.pendingKeys.delete(op.key);
553
623
  }
554
624
  }
@@ -570,12 +640,11 @@ export class MapKernel {
570
640
  {
571
641
  process: (op: IMapClearOperation, local, localOpMetadata) => {
572
642
  if (local) {
573
- assert(localOpMetadata !== undefined,
643
+ assert(isClearLocalOpMetadata(localOpMetadata),
574
644
  0x015 /* "pendingMessageId is missing from the local client's clear operation" */);
575
- const pendingMessageId = localOpMetadata as number;
576
- if (this.pendingClearMessageId === pendingMessageId) {
577
- this.pendingClearMessageId = -1;
578
- }
645
+ const pendingClearMessageId = this.pendingClearMessageIds.shift();
646
+ assert(pendingClearMessageId === localOpMetadata.pendingMessageId,
647
+ 0x2fb /* pendingMessageId does not match */);
579
648
  return;
580
649
  }
581
650
  if (this.pendingKeys.size !== 0) {
@@ -585,12 +654,16 @@ export class MapKernel {
585
654
  this.clearCore(local);
586
655
  },
587
656
  submit: (op: IMapClearOperation, localOpMetadata: unknown) => {
588
- // We don't reuse the metadata but send a new one on each submit.
589
- this.submitMapClearMessage(op);
657
+ assert(isClearLocalOpMetadata(localOpMetadata), 0x2fc /* Invalid localOpMetadata for clear */);
658
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
659
+ const pendingClearMessageId = this.pendingClearMessageIds.shift();
660
+ assert(pendingClearMessageId === localOpMetadata.pendingMessageId,
661
+ 0x2fd /* pendingMessageId does not match */);
662
+ this.submitMapClearMessage(op, localOpMetadata.previousMap);
590
663
  },
591
664
  getStashedOpLocalMetadata: (op: IMapClearOperation) => {
592
- // We don't reuse the metadata but send a new one on each submit.
593
- return this.getMapClearMessageLocalMetadata(op);
665
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
666
+ return { type: "clear", pendingMessageId: this.getMapClearMessageId() };
594
667
  },
595
668
  });
596
669
  messageHandlers.set(
@@ -603,12 +676,11 @@ export class MapKernel {
603
676
  this.deleteCore(op.key, local);
604
677
  },
605
678
  submit: (op: IMapDeleteOperation, localOpMetadata: unknown) => {
606
- // We don't reuse the metadata but send a new one on each submit.
607
- this.submitMapKeyMessage(op);
679
+ this.resubmitMapKeyMessage(op, localOpMetadata);
608
680
  },
609
681
  getStashedOpLocalMetadata: (op: IMapDeleteOperation) => {
610
- // We don't reuse the metadata but send a new one on each submit.
611
- return this.getMapKeyMessageLocalMetadata(op);
682
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
683
+ return { type: "edit", pendingMessageId: this.getMapKeyMessageId(op) };
612
684
  },
613
685
  });
614
686
  messageHandlers.set(
@@ -624,21 +696,20 @@ export class MapKernel {
624
696
  this.setCore(op.key, context, local);
625
697
  },
626
698
  submit: (op: IMapSetOperation, localOpMetadata: unknown) => {
627
- // We don't reuse the metadata but send a new one on each submit.
628
- this.submitMapKeyMessage(op);
699
+ this.resubmitMapKeyMessage(op, localOpMetadata);
629
700
  },
630
701
  getStashedOpLocalMetadata: (op: IMapSetOperation) => {
631
- // We don't reuse the metadata but send a new one on each submit.
632
- return this.getMapKeyMessageLocalMetadata(op);
702
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
703
+ return { type: "edit", pendingMessageId: this.getMapKeyMessageId(op) };
633
704
  },
634
705
  });
635
706
 
636
707
  return messageHandlers;
637
708
  }
638
709
 
639
- private getMapClearMessageLocalMetadata(op: IMapClearOperation): number {
710
+ private getMapClearMessageId(): number {
640
711
  const pendingMessageId = ++this.pendingMessageId;
641
- this.pendingClearMessageId = pendingMessageId;
712
+ this.pendingClearMessageIds.push(pendingMessageId);
642
713
  return pendingMessageId;
643
714
  }
644
715
 
@@ -646,23 +717,57 @@ export class MapKernel {
646
717
  * Submit a clear message to remote clients.
647
718
  * @param op - The clear message
648
719
  */
649
- private submitMapClearMessage(op: IMapClearOperation): void {
650
- const pendingMessageId = this.getMapClearMessageLocalMetadata(op);
651
- this.submitMessage(op, pendingMessageId);
720
+ private submitMapClearMessage(op: IMapClearOperation, previousMap?: Map<string, ILocalValue>): void {
721
+ const metadata = { type: "clear", pendingMessageId: this.getMapClearMessageId(), previousMap };
722
+ this.submitMessage(op, metadata);
652
723
  }
653
724
 
654
- private getMapKeyMessageLocalMetadata(op: IMapKeyOperation): number {
725
+ private getMapKeyMessageId(op: IMapKeyOperation): number {
655
726
  const pendingMessageId = ++this.pendingMessageId;
656
- this.pendingKeys.set(op.key, pendingMessageId);
727
+ const pendingMessageIds = this.pendingKeys.get(op.key);
728
+ if (pendingMessageIds !== undefined) {
729
+ pendingMessageIds.push(pendingMessageId);
730
+ } else {
731
+ this.pendingKeys.set(op.key, [pendingMessageId]);
732
+ }
657
733
  return pendingMessageId;
658
734
  }
659
735
 
660
736
  /**
661
737
  * Submit a map key message to remote clients.
662
738
  * @param op - The map key message
663
- */
664
- private submitMapKeyMessage(op: IMapKeyOperation): void {
665
- const pendingMessageId = this.getMapKeyMessageLocalMetadata(op);
666
- this.submitMessage(op, pendingMessageId);
739
+ * @param previousValue - The value of the key before this op
740
+ */
741
+ private submitMapKeyMessage(op: IMapKeyOperation, previousValue?: ILocalValue): void {
742
+ const pendingMessageId = this.getMapKeyMessageId(op);
743
+ const localMetadata = previousValue ?
744
+ { type: "edit", pendingMessageId, previousValue } :
745
+ { type: "add", pendingMessageId };
746
+ this.submitMessage(op, localMetadata);
747
+ }
748
+
749
+ /**
750
+ * Submit a map key message to remote clients based on a previous submit.
751
+ * @param op - The map key message
752
+ * @param localOpMetadata - Metadata from the previous submit
753
+ */
754
+ private resubmitMapKeyMessage(op: IMapKeyOperation, localOpMetadata: unknown): void {
755
+ assert(isMapKeyLocalOpMetadata(localOpMetadata), 0x2fe /* Invalid localOpMetadata in submit */);
756
+
757
+ // clear the old pending message id
758
+ const pendingMessageIds = this.pendingKeys.get(op.key);
759
+ assert(pendingMessageIds !== undefined && pendingMessageIds[0] === localOpMetadata.pendingMessageId,
760
+ 0x2ff /* Unexpected pending message received */);
761
+ pendingMessageIds.shift();
762
+ if (pendingMessageIds.length === 0) {
763
+ this.pendingKeys.delete(op.key);
764
+ }
765
+
766
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
767
+ const pendingMessageId = this.getMapKeyMessageId(op);
768
+ const localMetadata = localOpMetadata.type === "edit" ?
769
+ { type: "edit", pendingMessageId, previousValue: localOpMetadata.previousValue } :
770
+ { type: "add", pendingMessageId };
771
+ this.submitMessage(op, localMetadata);
667
772
  }
668
773
  }
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/map";
9
- export const pkgVersion = "1.0.0";
9
+ export const pkgVersion = "1.1.0-76254";