@fluidframework/map 1.1.0-75972 → 1.2.0-77818

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/directory.ts CHANGED
@@ -629,6 +629,18 @@ export class SharedDirectory extends SharedObject<ISharedDirectoryEvents> implem
629
629
  }
630
630
  }
631
631
 
632
+ /**
633
+ * {@inheritDoc @fluidframework/shared-object-base#SharedObject.rollback}
634
+ * @internal
635
+ */
636
+ protected rollback(content: any, localOpMetadata: unknown) {
637
+ const op: IDirectoryOperation = content as IDirectoryOperation;
638
+ const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
639
+ if (subdir) {
640
+ subdir.rollback(op, localOpMetadata);
641
+ }
642
+ }
643
+
632
644
  /**
633
645
  * Converts the given relative path to absolute against the root.
634
646
  * @param relativePath - The path to convert
@@ -675,8 +687,7 @@ export class SharedDirectory extends SharedObject<ISharedDirectoryEvents> implem
675
687
  submit: (op: IDirectoryClearOperation, localOpMetadata: unknown) => {
676
688
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
677
689
  if (subdir) {
678
- // We don't reuse the metadata but send a new one on each submit.
679
- subdir.submitClearMessage(op);
690
+ subdir.resubmitClearMessage(op, localOpMetadata);
680
691
  }
681
692
  },
682
693
  },
@@ -693,8 +704,7 @@ export class SharedDirectory extends SharedObject<ISharedDirectoryEvents> implem
693
704
  submit: (op: IDirectoryDeleteOperation, localOpMetadata: unknown) => {
694
705
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
695
706
  if (subdir) {
696
- // We don't reuse the metadata but send a new one on each submit.
697
- subdir.submitKeyMessage(op);
707
+ subdir.resubmitKeyMessage(op, localOpMetadata);
698
708
  }
699
709
  },
700
710
  },
@@ -712,8 +722,7 @@ export class SharedDirectory extends SharedObject<ISharedDirectoryEvents> implem
712
722
  submit: (op: IDirectorySetOperation, localOpMetadata: unknown) => {
713
723
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
714
724
  if (subdir) {
715
- // We don't reuse the metadata but send a new one on each submit.
716
- subdir.submitKeyMessage(op);
725
+ subdir.resubmitKeyMessage(op, localOpMetadata);
717
726
  }
718
727
  },
719
728
  },
@@ -732,7 +741,7 @@ export class SharedDirectory extends SharedObject<ISharedDirectoryEvents> implem
732
741
  const parentSubdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
733
742
  if (parentSubdir) {
734
743
  // We don't reuse the metadata but send a new one on each submit.
735
- parentSubdir.submitSubDirectoryMessage(op);
744
+ parentSubdir.resubmitSubDirectoryMessage(op, localOpMetadata);
736
745
  }
737
746
  },
738
747
  },
@@ -751,7 +760,7 @@ export class SharedDirectory extends SharedObject<ISharedDirectoryEvents> implem
751
760
  const parentSubdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
752
761
  if (parentSubdir) {
753
762
  // We don't reuse the metadata but send a new one on each submit.
754
- parentSubdir.submitSubDirectoryMessage(op);
763
+ parentSubdir.resubmitSubDirectoryMessage(op, localOpMetadata);
755
764
  }
756
765
  },
757
766
  },
@@ -832,15 +841,64 @@ export class SharedDirectory extends SharedObject<ISharedDirectoryEvents> implem
832
841
  }
833
842
  }
834
843
 
844
+ interface IKeyEditLocalOpMetadata {
845
+ type: "edit";
846
+ pendingMessageId: number;
847
+ previousValue: ILocalValue | undefined;
848
+ }
849
+
850
+ interface IClearLocalOpMetadata {
851
+ type: "clear";
852
+ pendingMessageId: number;
853
+ previousStorage: Map<string, ILocalValue>;
854
+ }
855
+
856
+ interface ICreateSubDirLocalOpMetadata {
857
+ type: "createSubDir";
858
+ pendingMessageId: number;
859
+ previouslyExisted: boolean;
860
+ }
861
+
862
+ interface IDeleteSubDirLocalOpMetadata {
863
+ type: "deleteSubDir";
864
+ pendingMessageId: number;
865
+ subDirectory: SubDirectory | undefined;
866
+ }
867
+
868
+ type SubDirLocalOpMetadata = ICreateSubDirLocalOpMetadata | IDeleteSubDirLocalOpMetadata;
869
+ type DirectoryLocalOpMetadata = IClearLocalOpMetadata | IKeyEditLocalOpMetadata | SubDirLocalOpMetadata;
870
+
871
+ function isKeyEditLocalOpMetadata(metadata: any): metadata is IKeyEditLocalOpMetadata {
872
+ return metadata !== undefined && typeof metadata.pendingMessageId === "number" && metadata.type === "edit";
873
+ }
874
+
875
+ function isClearLocalOpMetadata(metadata: any): metadata is IClearLocalOpMetadata {
876
+ return metadata !== undefined && metadata.type === "clear" && typeof metadata.pendingMessageId === "number" &&
877
+ typeof metadata.previousStorage === "object";
878
+ }
879
+
880
+ function isSubDirLocalOpMetadata(metadata: any): metadata is SubDirLocalOpMetadata {
881
+ return metadata !== undefined && typeof metadata.pendingMessageId === "number" &&
882
+ ((metadata.type === "createSubDir" && typeof metadata.previouslyExisted === "boolean") ||
883
+ metadata.type === "deleteSubDir");
884
+ }
885
+
886
+ function isDirectoryLocalOpMetadata(metadata: any): metadata is DirectoryLocalOpMetadata {
887
+ return metadata !== undefined && typeof metadata.pendingMessageId === "number" &&
888
+ (metadata.type === "edit" || metadata.type === "deleteSubDir" ||
889
+ (metadata.type === "clear" && typeof metadata.previousStorage === "object") ||
890
+ (metadata.type === "createSubDir" && typeof metadata.previouslyExisted === "boolean"));
891
+ }
892
+
835
893
  /**
836
894
  * Node of the directory tree.
837
895
  * @sealed
838
896
  */
839
897
  class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirectory {
840
898
  /**
841
- * Tells if the sub directory is disposed or not.
899
+ * Tells if the sub directory is deleted or not.
842
900
  */
843
- private _disposed = false;
901
+ private _deleted = false;
844
902
 
845
903
  /**
846
904
  * String representation for the class.
@@ -860,12 +918,12 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
860
918
  /**
861
919
  * Keys that have been modified locally but not yet ack'd from the server.
862
920
  */
863
- private readonly pendingKeys: Map<string, number> = new Map();
921
+ private readonly pendingKeys: Map<string, number[]> = new Map();
864
922
 
865
923
  /**
866
924
  * Subdirectories that have been modified locally but not yet ack'd from the server.
867
925
  */
868
- private readonly pendingSubDirectories: Map<string, number> = new Map();
926
+ private readonly pendingSubDirectories: Map<string, number[]> = new Map();
869
927
 
870
928
  /**
871
929
  * This is used to assign a unique id to every outgoing operation and helps in tracking unack'd ops.
@@ -873,10 +931,9 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
873
931
  private pendingMessageId: number = -1;
874
932
 
875
933
  /**
876
- * If a clear has been performed locally but not yet ack'd from the server, then this stores the pending id
877
- * of that clear operation. Otherwise, is -1.
934
+ * The pending ids of any clears that have been performed locally but not yet ack'd from the server
878
935
  */
879
- private pendingClearMessageId: number = -1;
936
+ private readonly pendingClearMessageIds: number[] = [];
880
937
 
881
938
  /**
882
939
  * Constructor.
@@ -895,16 +952,23 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
895
952
  }
896
953
 
897
954
  public dispose(error?: Error): void {
898
- this._disposed = true;
955
+ this._deleted = true;
899
956
  this.emit("disposed", this);
900
957
  }
901
958
 
959
+ /**
960
+ * Unmark the deleted property when rolling back delete.
961
+ */
962
+ private undispose(): void {
963
+ this._deleted = false;
964
+ }
965
+
902
966
  public get disposed(): boolean {
903
- return this._disposed;
967
+ return this._deleted;
904
968
  }
905
969
 
906
970
  private throwIfDisposed() {
907
- if (this._disposed) {
971
+ if (this._deleted) {
908
972
  throw new UsageError("Cannot access Disposed subDirectory");
909
973
  }
910
974
  }
@@ -945,7 +1009,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
945
1009
  this.directory.handle);
946
1010
 
947
1011
  // Set the value locally.
948
- this.setCore(
1012
+ const previousValue = this.setCore(
949
1013
  key,
950
1014
  localValue,
951
1015
  true,
@@ -962,7 +1026,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
962
1026
  type: "set",
963
1027
  value: serializableValue,
964
1028
  };
965
- this.submitKeyMessage(op);
1029
+ this.submitKeyMessage(op, previousValue);
966
1030
  return this;
967
1031
  }
968
1032
 
@@ -988,7 +1052,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
988
1052
  }
989
1053
 
990
1054
  // Create the sub directory locally first.
991
- this.createSubDirectoryCore(subdirName, true);
1055
+ const isNew = this.createSubDirectoryCore(subdirName, true);
992
1056
 
993
1057
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
994
1058
  const subDir: IDirectory = this._subdirectories.get(subdirName)!;
@@ -1003,7 +1067,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1003
1067
  subdirName,
1004
1068
  type: "createSubDirectory",
1005
1069
  };
1006
- this.submitSubDirectoryMessage(op);
1070
+ this.submitCreateSubDirectoryMessage(op, !isNew);
1007
1071
 
1008
1072
  return subDir;
1009
1073
  }
@@ -1030,11 +1094,11 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1030
1094
  public deleteSubDirectory(subdirName: string): boolean {
1031
1095
  this.throwIfDisposed();
1032
1096
  // Delete the sub directory locally first.
1033
- const successfullyRemoved = this.deleteSubDirectoryCore(subdirName, true);
1097
+ const subDir = this.deleteSubDirectoryCore(subdirName, true);
1034
1098
 
1035
1099
  // If we are not attached, don't submit the op.
1036
1100
  if (!this.directory.isAttached()) {
1037
- return successfullyRemoved;
1101
+ return subDir !== undefined;
1038
1102
  }
1039
1103
 
1040
1104
  const op: IDirectoryDeleteSubDirectoryOperation = {
@@ -1043,8 +1107,8 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1043
1107
  type: "deleteSubDirectory",
1044
1108
  };
1045
1109
 
1046
- this.submitSubDirectoryMessage(op);
1047
- return successfullyRemoved;
1110
+ this.submitDeleteSubDirectoryMessage(op, subDir);
1111
+ return subDir !== undefined;
1048
1112
  }
1049
1113
 
1050
1114
  /**
@@ -1071,11 +1135,11 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1071
1135
  public delete(key: string): boolean {
1072
1136
  this.throwIfDisposed();
1073
1137
  // Delete the key locally first.
1074
- const successfullyRemoved = this.deleteCore(key, true);
1138
+ const previousValue = this.deleteCore(key, true);
1075
1139
 
1076
1140
  // If we are not attached, don't submit the op.
1077
1141
  if (!this.directory.isAttached()) {
1078
- return successfullyRemoved;
1142
+ return previousValue !== undefined;
1079
1143
  }
1080
1144
 
1081
1145
  const op: IDirectoryDeleteOperation = {
@@ -1084,8 +1148,8 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1084
1148
  type: "delete",
1085
1149
  };
1086
1150
 
1087
- this.submitKeyMessage(op);
1088
- return successfullyRemoved;
1151
+ this.submitKeyMessage(op, previousValue);
1152
+ return previousValue !== undefined;
1089
1153
  }
1090
1154
 
1091
1155
  /**
@@ -1093,19 +1157,20 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1093
1157
  */
1094
1158
  public clear(): void {
1095
1159
  this.throwIfDisposed();
1096
- // Clear the data locally first.
1097
- this.clearCore(true);
1098
1160
 
1099
1161
  // If we are not attached, don't submit the op.
1100
1162
  if (!this.directory.isAttached()) {
1163
+ this.clearCore(true);
1101
1164
  return;
1102
1165
  }
1103
1166
 
1167
+ const copy = new Map<string, ILocalValue>(this._storage);
1168
+ this.clearCore(true);
1104
1169
  const op: IDirectoryClearOperation = {
1105
1170
  path: this.absolutePath,
1106
1171
  type: "clear",
1107
1172
  };
1108
- this.submitClearMessage(op);
1173
+ this.submitClearMessage(op, copy);
1109
1174
  }
1110
1175
 
1111
1176
  /**
@@ -1209,16 +1274,14 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1209
1274
  ): void {
1210
1275
  this.throwIfDisposed();
1211
1276
  if (local) {
1212
- assert(localOpMetadata !== undefined,
1213
- 0x00f /* pendingMessageId is missing from the local client's operation */);
1214
- const pendingMessageId = localOpMetadata as number;
1215
- if (this.pendingClearMessageId === pendingMessageId) {
1216
- this.pendingClearMessageId = -1;
1217
- }
1277
+ assert(isClearLocalOpMetadata(localOpMetadata),
1278
+ 0x00f /* `pendingMessageId is missing from the local client's ${op.type} operation` */);
1279
+ const pendingClearMessageId = this.pendingClearMessageIds.shift();
1280
+ assert(pendingClearMessageId === localOpMetadata.pendingMessageId,
1281
+ "pendingMessageId does not match");
1218
1282
  return;
1219
1283
  }
1220
1284
  this.clearExceptPendingKeys();
1221
- this.directory.emit("clear", local, this.directory);
1222
1285
  }
1223
1286
 
1224
1287
  /**
@@ -1284,7 +1347,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1284
1347
  localOpMetadata: unknown,
1285
1348
  ): void {
1286
1349
  this.throwIfDisposed();
1287
- if (!this.needProcessSubDirectoryOperations(op, local, localOpMetadata)) {
1350
+ if (!this.needProcessSubDirectoryOperation(op, local, localOpMetadata)) {
1288
1351
  return;
1289
1352
  }
1290
1353
  this.createSubDirectoryCore(op.subdirName, local);
@@ -1305,7 +1368,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1305
1368
  localOpMetadata: unknown,
1306
1369
  ): void {
1307
1370
  this.throwIfDisposed();
1308
- if (!this.needProcessSubDirectoryOperations(op, local, localOpMetadata)) {
1371
+ if (!this.needProcessSubDirectoryOperation(op, local, localOpMetadata)) {
1309
1372
  return;
1310
1373
  }
1311
1374
  this.deleteSubDirectoryCore(op.subdirName, local);
@@ -1314,37 +1377,156 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1314
1377
  /**
1315
1378
  * Submit a clear operation.
1316
1379
  * @param op - The operation
1317
- * @internal
1318
1380
  */
1319
- public submitClearMessage(op: IDirectoryClearOperation): void {
1381
+ private submitClearMessage(op: IDirectoryClearOperation,
1382
+ previousValue: Map<string, ILocalValue>): void {
1320
1383
  this.throwIfDisposed();
1384
+ const pendingMsgId = ++this.pendingMessageId;
1385
+ this.pendingClearMessageIds.push(pendingMsgId);
1386
+ const metadata: IClearLocalOpMetadata = {
1387
+ type: "clear",
1388
+ pendingMessageId: pendingMsgId,
1389
+ previousStorage: previousValue,
1390
+ };
1391
+ this.directory.submitDirectoryMessage(op, metadata);
1392
+ }
1393
+
1394
+ /**
1395
+ * Resubmit a clear operation.
1396
+ * @param op - The operation
1397
+ * @internal
1398
+ */
1399
+ public resubmitClearMessage(op: IDirectoryClearOperation, localOpMetadata: unknown): void {
1400
+ assert(isClearLocalOpMetadata(localOpMetadata), "Invalid localOpMetadata for clear");
1401
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
1402
+ const pendingClearMessageId = this.pendingClearMessageIds.shift();
1403
+ assert(pendingClearMessageId === localOpMetadata.pendingMessageId,
1404
+ "pendingMessageId does not match");
1405
+ this.submitClearMessage(op, localOpMetadata.previousStorage);
1406
+ }
1407
+
1408
+ /**
1409
+ * Get a new pending message id for the op and cache it to track the pending op
1410
+ */
1411
+ private getKeyMessageId(op: IDirectoryKeyOperation): number {
1412
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
1321
1413
  const pendingMessageId = ++this.pendingMessageId;
1322
- this.directory.submitDirectoryMessage(op, pendingMessageId);
1323
- this.pendingClearMessageId = pendingMessageId;
1414
+ const pendingMessageIds = this.pendingKeys.get(op.key);
1415
+ if (pendingMessageIds !== undefined) {
1416
+ pendingMessageIds.push(pendingMessageId);
1417
+ } else {
1418
+ this.pendingKeys.set(op.key, [pendingMessageId]);
1419
+ }
1420
+ return pendingMessageId;
1324
1421
  }
1325
1422
 
1326
1423
  /**
1327
1424
  * Submit a key operation.
1328
1425
  * @param op - The operation
1426
+ * @param previousValue - The value of the key before this op
1427
+ */
1428
+ private submitKeyMessage(op: IDirectoryKeyOperation, previousValue?: ILocalValue): void {
1429
+ this.throwIfDisposed();
1430
+ const pendingMessageId = this.getKeyMessageId(op);
1431
+ const localMetadata = { type: "edit", pendingMessageId, previousValue };
1432
+ this.directory.submitDirectoryMessage(op, localMetadata);
1433
+ }
1434
+
1435
+ /**
1436
+ * Submit a key message to remote clients based on a previous submit.
1437
+ * @param op - The map key message
1438
+ * @param localOpMetadata - Metadata from the previous submit
1329
1439
  * @internal
1330
1440
  */
1331
- public submitKeyMessage(op: IDirectoryKeyOperation): void {
1441
+ public resubmitKeyMessage(op: IDirectoryKeyOperation, localOpMetadata: unknown): void {
1442
+ assert(isKeyEditLocalOpMetadata(localOpMetadata), "Invalid localOpMetadata in submit");
1443
+
1444
+ // clear the old pending message id
1445
+ const pendingMessageIds = this.pendingKeys.get(op.key);
1446
+ assert(pendingMessageIds !== undefined && pendingMessageIds[0] === localOpMetadata.pendingMessageId,
1447
+ "Unexpected pending message received");
1448
+ pendingMessageIds.shift();
1449
+ if (pendingMessageIds.length === 0) {
1450
+ this.pendingKeys.delete(op.key);
1451
+ }
1452
+
1453
+ this.submitKeyMessage(op, localOpMetadata.previousValue);
1454
+ }
1455
+
1456
+ /**
1457
+ * Get a new pending message id for the op and cache it to track the pending op
1458
+ */
1459
+ private getSubDirMessageId(op: IDirectorySubDirectoryOperation): number {
1460
+ // We don't reuse the metadata pendingMessageId but send a new one on each submit.
1461
+ const newMessageId = ++this.pendingMessageId;
1462
+ const pendingMessageIds = this.pendingSubDirectories.get(op.subdirName);
1463
+ if (pendingMessageIds !== undefined) {
1464
+ pendingMessageIds.push(newMessageId);
1465
+ } else {
1466
+ this.pendingSubDirectories.set(op.subdirName, [newMessageId]);
1467
+ }
1468
+ return newMessageId;
1469
+ }
1470
+
1471
+ /**
1472
+ * Submit a create subdirectory operation.
1473
+ * @param op - The operation
1474
+ * @param prevExisted - Whether the subdirectory existed before the op
1475
+ */
1476
+ private submitCreateSubDirectoryMessage(op: IDirectorySubDirectoryOperation,
1477
+ prevExisted: boolean): void {
1332
1478
  this.throwIfDisposed();
1333
- const pendingMessageId = ++this.pendingMessageId;
1334
- this.directory.submitDirectoryMessage(op, pendingMessageId);
1335
- this.pendingKeys.set(op.key, pendingMessageId);
1479
+ const newMessageId = this.getSubDirMessageId(op);
1480
+
1481
+ const localOpMetadata: ICreateSubDirLocalOpMetadata = {
1482
+ type: "createSubDir",
1483
+ pendingMessageId: newMessageId,
1484
+ previouslyExisted: prevExisted,
1485
+ };
1486
+ this.directory.submitDirectoryMessage(op, localOpMetadata);
1336
1487
  }
1337
1488
 
1338
1489
  /**
1339
- * Submit a subdirectory operation.
1490
+ * Submit a delete subdirectory operation.
1340
1491
  * @param op - The operation
1341
- * @internal
1492
+ * @param subDir - Any subdirectory deleted by the op
1342
1493
  */
1343
- public submitSubDirectoryMessage(op: IDirectorySubDirectoryOperation): void {
1494
+ private submitDeleteSubDirectoryMessage(op: IDirectorySubDirectoryOperation,
1495
+ subDir: SubDirectory | undefined): void {
1344
1496
  this.throwIfDisposed();
1345
- const pendingMessageId = ++this.pendingMessageId;
1346
- this.directory.submitDirectoryMessage(op, pendingMessageId);
1347
- this.pendingSubDirectories.set(op.subdirName, pendingMessageId);
1497
+ const newMessageId = this.getSubDirMessageId(op);
1498
+
1499
+ const localOpMetadata: IDeleteSubDirLocalOpMetadata = {
1500
+ type: "deleteSubDir",
1501
+ pendingMessageId: newMessageId,
1502
+ subDirectory: subDir,
1503
+ };
1504
+ this.directory.submitDirectoryMessage(op, localOpMetadata);
1505
+ }
1506
+
1507
+ /**
1508
+ * Submit a subdirectory operation again
1509
+ * @param op - The operation
1510
+ * @param localOpMetadata - metadata submitted with the op originally
1511
+ * @internal
1512
+ */
1513
+ public resubmitSubDirectoryMessage(op: IDirectorySubDirectoryOperation, localOpMetadata: unknown): void {
1514
+ assert(isSubDirLocalOpMetadata(localOpMetadata), "Invalid localOpMetadata for sub directory op");
1515
+
1516
+ // clear the old pending message id
1517
+ const pendingMessageIds = this.pendingSubDirectories.get(op.subdirName);
1518
+ assert(pendingMessageIds !== undefined && pendingMessageIds[0] === localOpMetadata.pendingMessageId,
1519
+ "Unexpected pending message received");
1520
+ pendingMessageIds.shift();
1521
+ if (pendingMessageIds.length === 0) {
1522
+ this.pendingSubDirectories.delete(op.subdirName);
1523
+ }
1524
+
1525
+ if (localOpMetadata.type === "createSubDir") {
1526
+ this.submitCreateSubDirectoryMessage(op, localOpMetadata.previouslyExisted);
1527
+ } else {
1528
+ this.submitDeleteSubDirectoryMessage(op, localOpMetadata.subDirectory);
1529
+ }
1348
1530
  }
1349
1531
 
1350
1532
  /**
@@ -1396,6 +1578,69 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1396
1578
  return this._storage.get(key) as T;
1397
1579
  }
1398
1580
 
1581
+ /**
1582
+ * Remove the pendingMessageId from the map tracking it on rollback
1583
+ * @param map - map tracking the pending messages
1584
+ * @param key - key of the edit in the op
1585
+ */
1586
+ private rollbackPendingMessageId(map: Map<string, number[]>, key: string, pendingMessageId) {
1587
+ const pendingMessageIds = map.get(key);
1588
+ const lastPendingMessageId = pendingMessageIds?.pop();
1589
+ if (!pendingMessageIds || lastPendingMessageId !== pendingMessageId) {
1590
+ throw new Error("Rollback op does not match last pending");
1591
+ }
1592
+ if (pendingMessageIds.length === 0) {
1593
+ map.delete(key);
1594
+ }
1595
+ }
1596
+
1597
+ /**
1598
+ * Rollback a local op
1599
+ * @param op - The operation to rollback
1600
+ * @param localOpMetadata - The local metadata associated with the op.
1601
+ */
1602
+ public rollback(op: any, localOpMetadata: unknown) {
1603
+ if (!isDirectoryLocalOpMetadata(localOpMetadata)) {
1604
+ throw new Error("Invalid localOpMetadata");
1605
+ }
1606
+
1607
+ if (op.type === "clear" && localOpMetadata.type === "clear") {
1608
+ localOpMetadata.previousStorage.forEach((localValue, key) => {
1609
+ this.setCore(key, localValue, true);
1610
+ });
1611
+
1612
+ const lastPendingClearId = this.pendingClearMessageIds.pop();
1613
+ if (lastPendingClearId === undefined || lastPendingClearId !== localOpMetadata.pendingMessageId) {
1614
+ throw new Error("Rollback op does match last clear");
1615
+ }
1616
+ } else if ((op.type === "delete" || op.type === "set") && localOpMetadata.type === "edit") {
1617
+ if (localOpMetadata.previousValue === undefined) {
1618
+ this.deleteCore(op.key, true);
1619
+ } else {
1620
+ this.setCore(op.key, localOpMetadata.previousValue, true);
1621
+ }
1622
+
1623
+ this.rollbackPendingMessageId(this.pendingKeys, op.key, localOpMetadata.pendingMessageId);
1624
+ } else if (op.type === "createSubDirectory" && localOpMetadata.type === "createSubDir") {
1625
+ if (!localOpMetadata.previouslyExisted) {
1626
+ this.deleteSubDirectoryCore(op.subdirName, true);
1627
+ }
1628
+
1629
+ this.rollbackPendingMessageId(this.pendingSubDirectories, op.subdirName, localOpMetadata.pendingMessageId);
1630
+ } else if (op.type === "deleteSubDirectory" && localOpMetadata.type === "deleteSubDir") {
1631
+ if (localOpMetadata.subDirectory !== undefined) {
1632
+ this.undeleteSubDirectoryTree(localOpMetadata.subDirectory);
1633
+ // don't need to register events because deleting never unregistered
1634
+ this._subdirectories.set(op.subdirName, localOpMetadata.subDirectory);
1635
+ this.emit("subDirectoryCreated", op.subdirName, true, this);
1636
+ }
1637
+
1638
+ this.rollbackPendingMessageId(this.pendingSubDirectories, op.subdirName, localOpMetadata.pendingMessageId);
1639
+ } else {
1640
+ throw new Error("Unsupported op for rollback");
1641
+ }
1642
+ }
1643
+
1399
1644
  /**
1400
1645
  * Converts the given relative path into an absolute path.
1401
1646
  * @param path - Relative path to convert
@@ -1409,10 +1654,9 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1409
1654
  * If our local operations that have not yet been ack'd will eventually overwrite an incoming operation, we should
1410
1655
  * not process the incoming operation.
1411
1656
  * @param op - Operation to check
1412
- * @param local - Whether the message originated from the local client
1413
- * @param message - The message
1414
- * @param localOpMetadata - For local client messages, this is the metadata that was submitted with the message.
1415
- * For messages from a remote client, this will be undefined.
1657
+ * @param local - Whether the operation originated from the local client
1658
+ * @param localOpMetadata - For local client ops, this is the metadata that was submitted with the op.
1659
+ * For ops from a remote client, this will be undefined.
1416
1660
  * @returns True if the operation should be processed, false otherwise
1417
1661
  */
1418
1662
  private needProcessStorageOperation(
@@ -1420,9 +1664,10 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1420
1664
  local: boolean,
1421
1665
  localOpMetadata: unknown,
1422
1666
  ): boolean {
1423
- if (this.pendingClearMessageId !== -1) {
1667
+ if (this.pendingClearMessageIds.length > 0) {
1424
1668
  if (local) {
1425
- assert(localOpMetadata !== undefined && localOpMetadata as number < this.pendingClearMessageId,
1669
+ assert(localOpMetadata !== undefined && isKeyEditLocalOpMetadata(localOpMetadata) &&
1670
+ localOpMetadata.pendingMessageId < this.pendingClearMessageIds[0],
1426
1671
  0x010 /* "Received out of order storage op when there is an unackd clear message" */);
1427
1672
  }
1428
1673
  // If I have a NACK clear, we can ignore all ops.
@@ -1434,10 +1679,13 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1434
1679
  // Found an NACK op, clear it from the directory if the latest sequence number in the directory
1435
1680
  // match the message's and don't process the op.
1436
1681
  if (local) {
1437
- assert(localOpMetadata !== undefined,
1682
+ assert(localOpMetadata !== undefined && isKeyEditLocalOpMetadata(localOpMetadata),
1438
1683
  0x011 /* pendingMessageId is missing from the local client's operation */);
1439
- const pendingMessageId = localOpMetadata as number;
1440
- if (pendingKeyMessageId === pendingMessageId) {
1684
+ const pendingMessageIds = this.pendingKeys.get(op.key);
1685
+ assert(pendingMessageIds !== undefined && pendingMessageIds[0] === localOpMetadata.pendingMessageId,
1686
+ "Unexpected pending message received");
1687
+ pendingMessageIds.shift();
1688
+ if (pendingMessageIds.length === 0) {
1441
1689
  this.pendingKeys.delete(op.key);
1442
1690
  }
1443
1691
  }
@@ -1458,7 +1706,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1458
1706
  * For messages from a remote client, this will be undefined.
1459
1707
  * @returns True if the operation should be processed, false otherwise
1460
1708
  */
1461
- private needProcessSubDirectoryOperations(
1709
+ private needProcessSubDirectoryOperation(
1462
1710
  op: IDirectorySubDirectoryOperation,
1463
1711
  local: boolean,
1464
1712
  localOpMetadata: unknown,
@@ -1466,10 +1714,13 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1466
1714
  const pendingSubDirectoryMessageId = this.pendingSubDirectories.get(op.subdirName);
1467
1715
  if (pendingSubDirectoryMessageId !== undefined) {
1468
1716
  if (local) {
1469
- assert(localOpMetadata !== undefined,
1717
+ assert(isSubDirLocalOpMetadata(localOpMetadata),
1470
1718
  0x012 /* pendingMessageId is missing from the local client's operation */);
1471
- const pendingMessageId = localOpMetadata as number;
1472
- if (pendingSubDirectoryMessageId === pendingMessageId) {
1719
+ const pendingMessageIds = this.pendingSubDirectories.get(op.subdirName);
1720
+ assert(pendingMessageIds !== undefined && pendingMessageIds[0] === localOpMetadata.pendingMessageId,
1721
+ "Unexpected pending message received");
1722
+ pendingMessageIds.shift();
1723
+ if (pendingMessageIds.length === 0) {
1473
1724
  this.pendingSubDirectories.delete(op.subdirName);
1474
1725
  }
1475
1726
  }
@@ -1490,9 +1741,9 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1490
1741
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1491
1742
  temp.set(key, this._storage.get(key)!);
1492
1743
  });
1493
- this._storage.clear();
1744
+ this.clearCore(false);
1494
1745
  temp.forEach((value, key, map) => {
1495
- this._storage.set(key, value);
1746
+ this.setCore(key, value, true);
1496
1747
  });
1497
1748
  }
1498
1749
 
@@ -1510,11 +1761,11 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1510
1761
  * Delete implementation used for both locally sourced deletes as well as incoming remote deletes.
1511
1762
  * @param key - The key being deleted
1512
1763
  * @param local - Whether the message originated from the local client
1513
- * @param op - The message if from a remote delete, or null if from a local delete
1514
- * @returns True if the key existed and was deleted, false if it did not exist
1764
+ * @returns Previous local value of the key if it existed, undefined if it did not exist
1515
1765
  */
1516
- private deleteCore(key: string, local: boolean) {
1517
- const previousValue = this.get(key);
1766
+ private deleteCore(key: string, local: boolean): ILocalValue | undefined {
1767
+ const previousLocalValue = this._storage.get(key);
1768
+ const previousValue = previousLocalValue?.value;
1518
1769
  const successfullyRemoved = this._storage.delete(key);
1519
1770
  if (successfullyRemoved) {
1520
1771
  const event: IDirectoryValueChanged = { key, path: this.absolutePath, previousValue };
@@ -1522,7 +1773,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1522
1773
  const containedEvent: IValueChanged = { key, previousValue };
1523
1774
  this.emit("containedValueChanged", containedEvent, local, this);
1524
1775
  }
1525
- return successfullyRemoved;
1776
+ return previousLocalValue;
1526
1777
  }
1527
1778
 
1528
1779
  /**
@@ -1530,30 +1781,35 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1530
1781
  * @param key - The key being set
1531
1782
  * @param value - The value being set
1532
1783
  * @param local - Whether the message originated from the local client
1533
- * @param op - The message if from a remote set, or null if from a local set
1784
+ * @returns Previous local value of the key, if any
1534
1785
  */
1535
- private setCore(key: string, value: ILocalValue, local: boolean) {
1536
- const previousValue = this.get(key);
1786
+ private setCore(key: string, value: ILocalValue, local: boolean): ILocalValue | undefined {
1787
+ const previousLocalValue = this._storage.get(key);
1788
+ const previousValue = previousLocalValue?.value;
1537
1789
  this._storage.set(key, value);
1538
1790
  const event: IDirectoryValueChanged = { key, path: this.absolutePath, previousValue };
1539
1791
  this.directory.emit("valueChanged", event, local, this.directory);
1540
1792
  const containedEvent: IValueChanged = { key, previousValue };
1541
1793
  this.emit("containedValueChanged", containedEvent, local, this);
1794
+ return previousLocalValue;
1542
1795
  }
1543
1796
 
1544
1797
  /**
1545
1798
  * Create subdirectory implementation used for both locally sourced creation as well as incoming remote creation.
1546
1799
  * @param subdirName - The name of the subdirectory being created
1547
1800
  * @param local - Whether the message originated from the local client
1801
+ * @returns - True if is newly created, false if it already existed.
1548
1802
  */
1549
- private createSubDirectoryCore(subdirName: string, local: boolean) {
1803
+ private createSubDirectoryCore(subdirName: string, local: boolean): boolean {
1550
1804
  if (!this._subdirectories.has(subdirName)) {
1551
1805
  const absolutePath = posix.join(this.absolutePath, subdirName);
1552
1806
  const subDir = new SubDirectory(this.directory, this.runtime, this.serializer, absolutePath);
1553
1807
  this.registerEventsOnSubDirectory(subDir, subdirName);
1554
1808
  this._subdirectories.set(subdirName, subDir);
1555
1809
  this.emit("subDirectoryCreated", subdirName, local, this);
1810
+ return true;
1556
1811
  }
1812
+ return false;
1557
1813
  }
1558
1814
 
1559
1815
  private registerEventsOnSubDirectory(subDirectory: SubDirectory, subDirName: string) {
@@ -1569,18 +1825,17 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1569
1825
  * Delete subdirectory implementation used for both locally sourced creation as well as incoming remote creation.
1570
1826
  * @param subdirName - The name of the subdirectory being deleted
1571
1827
  * @param local - Whether the message originated from the local client
1572
- * @param op - The message if from a remote delete, or null if from a local delete
1573
1828
  */
1574
1829
  private deleteSubDirectoryCore(subdirName: string, local: boolean) {
1575
- const previousValue = this.getSubDirectory(subdirName);
1830
+ const previousValue = this._subdirectories.get(subdirName);
1576
1831
  // This should make the subdirectory structure unreachable so it can be GC'd and won't appear in snapshots
1577
- // Might want to consider cleaning out the structure more exhaustively though?
1578
- const successfullyRemoved = this._subdirectories.delete(subdirName);
1832
+ // Might want to consider cleaning out the structure more exhaustively though? But not when rollback.
1579
1833
  if (previousValue !== undefined) {
1834
+ this._subdirectories.delete(subdirName);
1580
1835
  this.disposeSubDirectoryTree(previousValue);
1581
1836
  this.emit("subDirectoryDeleted", subdirName, local, this);
1582
1837
  }
1583
- return successfullyRemoved;
1838
+ return previousValue;
1584
1839
  }
1585
1840
 
1586
1841
  private disposeSubDirectoryTree(directory: IDirectory | undefined) {
@@ -1596,4 +1851,12 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1596
1851
  directory.dispose();
1597
1852
  }
1598
1853
  }
1854
+
1855
+ private undeleteSubDirectoryTree(directory: SubDirectory) {
1856
+ // Restore deleted subdirectory tree. This will unmark "deleted" from the subdirectories from bottom to top.
1857
+ for (const [_, subDirectory] of this._subdirectories.entries()) {
1858
+ this.undeleteSubDirectoryTree(subDirectory);
1859
+ }
1860
+ directory.undispose();
1861
+ }
1599
1862
  }