@fluidframework/map 1.1.0 → 1.2.0

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