@fluidframework/map 2.52.0 → 2.53.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
@@ -48,6 +48,7 @@ import type {
48
48
  ISerializedValue,
49
49
  } from "./internalInterfaces.js";
50
50
  import { serializeValue, migrateIfSharedSerializable } from "./localValues.js";
51
+ import { findLast, findLastIndex } from "./utils.js";
51
52
 
52
53
  // We use path-browserify since this code can run safely on the server or the browser.
53
54
  // We standardize on using posix slashes everywhere.
@@ -71,15 +72,15 @@ interface IDirectoryMessageHandler {
71
72
  msg: ISequencedDocumentMessage,
72
73
  op: IDirectoryOperation,
73
74
  local: boolean,
74
- localOpMetadata: unknown,
75
+ localOpMetadata: DirectoryLocalOpMetadata | undefined,
75
76
  ): void;
76
77
 
77
78
  /**
78
- * Communicate the operation to remote clients.
79
- * @param op - The directory operation to submit
80
- * @param localOpMetadata - The metadata to be submitted with the message.
79
+ * Resubmit a previously submitted operation that was not delivered.
80
+ * @param op - The directory operation to resubmit
81
+ * @param localOpMetadata - The metadata that was originally submitted with the message.
81
82
  */
82
- submit(op: IDirectoryOperation, localOpMetadata: unknown): void;
83
+ resubmit(op: IDirectoryOperation, localOpMetadata: DirectoryLocalOpMetadata): void;
83
84
  }
84
85
 
85
86
  /**
@@ -205,6 +206,45 @@ export type IDirectorySubDirectoryOperation =
205
206
  */
206
207
  export type IDirectoryOperation = IDirectoryStorageOperation | IDirectorySubDirectoryOperation;
207
208
 
209
+ interface PendingKeySet {
210
+ type: "set";
211
+ path: string;
212
+ value: unknown;
213
+ }
214
+
215
+ interface PendingKeyDelete {
216
+ type: "delete";
217
+ path: string;
218
+ key: string;
219
+ }
220
+
221
+ interface PendingClear {
222
+ type: "clear";
223
+ path: string;
224
+ }
225
+
226
+ /**
227
+ * Represents the "lifetime" of a series of pending set operations before the pending
228
+ * set operations are ack'd.
229
+ */
230
+ interface PendingKeyLifetime {
231
+ type: "lifetime";
232
+ key: string;
233
+ path: string;
234
+ /**
235
+ * A non-empty array of pending key sets that occurred during this lifetime. If the list
236
+ * becomes empty (e.g. during processing or rollback), the lifetime no longer exists and
237
+ * must be removed from the pending data.
238
+ */
239
+ keySets: PendingKeySet[];
240
+ }
241
+
242
+ /**
243
+ * A member of the pendingStorageData array, which tracks outstanding changes and can be used to
244
+ * compute optimistic values. Local sets are aggregated into lifetimes.
245
+ */
246
+ type PendingStorageEntry = PendingKeyLifetime | PendingKeyDelete | PendingClear;
247
+
208
248
  /**
209
249
  * Create info for the subdirectory.
210
250
  *
@@ -650,7 +690,10 @@ export class SharedDirectory
650
690
  * @param localOpMetadata - The local metadata associated with the op. We send a unique id that is used to track
651
691
  * this op while it has not been ack'd. This will be sent when we receive this op back from the server.
652
692
  */
653
- public submitDirectoryMessage(op: IDirectoryOperation, localOpMetadata: unknown): void {
693
+ public submitDirectoryMessage(
694
+ op: IDirectoryOperation,
695
+ localOpMetadata: DirectoryLocalOpMetadata,
696
+ ): void {
654
697
  this.submitLocalMessage(op, localOpMetadata);
655
698
  }
656
699
 
@@ -662,11 +705,14 @@ export class SharedDirectory
662
705
  /**
663
706
  * {@inheritDoc @fluidframework/shared-object-base#SharedObject.reSubmitCore}
664
707
  */
665
- protected override reSubmitCore(content: unknown, localOpMetadata: unknown): void {
708
+ protected override reSubmitCore(
709
+ content: unknown,
710
+ localOpMetadata: DirectoryLocalOpMetadata,
711
+ ): void {
666
712
  const message = content as IDirectoryOperation;
667
713
  const handler = this.messageHandlers.get(message.type);
668
714
  assert(handler !== undefined, 0x00d /* Missing message handler for message type */);
669
- handler.submit(message, localOpMetadata);
715
+ handler.resubmit(message, localOpMetadata);
670
716
  }
671
717
 
672
718
  /**
@@ -774,7 +820,7 @@ export class SharedDirectory
774
820
  protected processCore(
775
821
  message: ISequencedDocumentMessage,
776
822
  local: boolean,
777
- localOpMetadata: unknown,
823
+ localOpMetadata: DirectoryLocalOpMetadata | undefined,
778
824
  ): void {
779
825
  // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
780
826
  if (message.type === MessageType.Operation) {
@@ -791,7 +837,10 @@ export class SharedDirectory
791
837
  /**
792
838
  * {@inheritDoc @fluidframework/shared-object-base#SharedObject.rollback}
793
839
  */
794
- protected override rollback(content: unknown, localOpMetadata: unknown): void {
840
+ protected override rollback(
841
+ content: unknown,
842
+ localOpMetadata: DirectoryLocalOpMetadata,
843
+ ): void {
795
844
  const op: IDirectoryOperation = content as IDirectoryOperation;
796
845
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
797
846
  if (subdir) {
@@ -839,8 +888,8 @@ export class SharedDirectory
839
888
  process: (
840
889
  msg: ISequencedDocumentMessage,
841
890
  op: IDirectoryClearOperation,
842
- local,
843
- localOpMetadata,
891
+ local: boolean,
892
+ localOpMetadata: ClearLocalOpMetadata | undefined,
844
893
  ) => {
845
894
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
846
895
  // If there is pending delete op for any subDirectory in the op.path, then don't apply the this op
@@ -849,7 +898,7 @@ export class SharedDirectory
849
898
  subdir.processClearMessage(msg, op, local, localOpMetadata);
850
899
  }
851
900
  },
852
- submit: (op: IDirectoryClearOperation, localOpMetadata: unknown) => {
901
+ resubmit: (op: IDirectoryClearOperation, localOpMetadata: ClearLocalOpMetadata) => {
853
902
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
854
903
  if (subdir) {
855
904
  subdir.resubmitClearMessage(op, localOpMetadata);
@@ -860,8 +909,8 @@ export class SharedDirectory
860
909
  process: (
861
910
  msg: ISequencedDocumentMessage,
862
911
  op: IDirectoryDeleteOperation,
863
- local,
864
- localOpMetadata,
912
+ local: boolean,
913
+ localOpMetadata: EditLocalOpMetadata | undefined,
865
914
  ) => {
866
915
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
867
916
  // If there is pending delete op for any subDirectory in the op.path, then don't apply the this op
@@ -870,7 +919,7 @@ export class SharedDirectory
870
919
  subdir.processDeleteMessage(msg, op, local, localOpMetadata);
871
920
  }
872
921
  },
873
- submit: (op: IDirectoryDeleteOperation, localOpMetadata: unknown) => {
922
+ resubmit: (op: IDirectoryDeleteOperation, localOpMetadata: EditLocalOpMetadata) => {
874
923
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
875
924
  if (subdir) {
876
925
  subdir.resubmitKeyMessage(op, localOpMetadata);
@@ -881,8 +930,8 @@ export class SharedDirectory
881
930
  process: (
882
931
  msg: ISequencedDocumentMessage,
883
932
  op: IDirectorySetOperation,
884
- local,
885
- localOpMetadata,
933
+ local: boolean,
934
+ localOpMetadata: EditLocalOpMetadata | undefined,
886
935
  ) => {
887
936
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
888
937
  // If there is pending delete op for any subDirectory in the op.path, then don't apply the this op
@@ -893,7 +942,7 @@ export class SharedDirectory
893
942
  subdir.processSetMessage(msg, op, localValue, local, localOpMetadata);
894
943
  }
895
944
  },
896
- submit: (op: IDirectorySetOperation, localOpMetadata: unknown) => {
945
+ resubmit: (op: IDirectorySetOperation, localOpMetadata: EditLocalOpMetadata) => {
897
946
  const subdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
898
947
  if (subdir) {
899
948
  subdir.resubmitKeyMessage(op, localOpMetadata);
@@ -905,8 +954,8 @@ export class SharedDirectory
905
954
  process: (
906
955
  msg: ISequencedDocumentMessage,
907
956
  op: IDirectoryCreateSubDirectoryOperation,
908
- local,
909
- localOpMetadata,
957
+ local: boolean,
958
+ localOpMetadata: SubDirLocalOpMetadata | undefined,
910
959
  ) => {
911
960
  const parentSubdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
912
961
  // If there is pending delete op for any subDirectory in the op.path, then don't apply the this op
@@ -915,7 +964,10 @@ export class SharedDirectory
915
964
  parentSubdir.processCreateSubDirectoryMessage(msg, op, local, localOpMetadata);
916
965
  }
917
966
  },
918
- submit: (op: IDirectoryCreateSubDirectoryOperation, localOpMetadata: unknown) => {
967
+ resubmit: (
968
+ op: IDirectoryCreateSubDirectoryOperation,
969
+ localOpMetadata: SubDirLocalOpMetadata,
970
+ ) => {
919
971
  const parentSubdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
920
972
  if (parentSubdir) {
921
973
  // We don't reuse the metadata but send a new one on each submit.
@@ -928,8 +980,8 @@ export class SharedDirectory
928
980
  process: (
929
981
  msg: ISequencedDocumentMessage,
930
982
  op: IDirectoryDeleteSubDirectoryOperation,
931
- local,
932
- localOpMetadata,
983
+ local: boolean,
984
+ localOpMetadata: SubDirLocalOpMetadata | undefined,
933
985
  ) => {
934
986
  const parentSubdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
935
987
  // If there is pending delete op for any subDirectory in the op.path, then don't apply the this op
@@ -938,7 +990,10 @@ export class SharedDirectory
938
990
  parentSubdir.processDeleteSubDirectoryMessage(msg, op, local, localOpMetadata);
939
991
  }
940
992
  },
941
- submit: (op: IDirectoryDeleteSubDirectoryOperation, localOpMetadata: unknown) => {
993
+ resubmit: (
994
+ op: IDirectoryDeleteSubDirectoryOperation,
995
+ localOpMetadata: SubDirLocalOpMetadata,
996
+ ) => {
942
997
  const parentSubdir = this.getWorkingDirectory(op.path) as SubDirectory | undefined;
943
998
  if (parentSubdir) {
944
999
  // We don't reuse the metadata but send a new one on each submit.
@@ -1050,18 +1105,6 @@ export class SharedDirectory
1050
1105
  }
1051
1106
  }
1052
1107
 
1053
- interface IKeyEditLocalOpMetadata {
1054
- type: "edit";
1055
- pendingMessageId: number;
1056
- previousValue: unknown;
1057
- }
1058
-
1059
- interface IClearLocalOpMetadata {
1060
- type: "clear";
1061
- pendingMessageId: number;
1062
- previousStorage: Map<string, unknown>;
1063
- }
1064
-
1065
1108
  interface ICreateSubDirLocalOpMetadata {
1066
1109
  type: "createSubDir";
1067
1110
  }
@@ -1073,49 +1116,16 @@ interface IDeleteSubDirLocalOpMetadata {
1073
1116
 
1074
1117
  type SubDirLocalOpMetadata = ICreateSubDirLocalOpMetadata | IDeleteSubDirLocalOpMetadata;
1075
1118
 
1076
- /**
1077
- * Types of local op metadata.
1078
- */
1079
- export type DirectoryLocalOpMetadata =
1080
- | IClearLocalOpMetadata
1081
- | IKeyEditLocalOpMetadata
1082
- | SubDirLocalOpMetadata;
1083
-
1084
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
1085
-
1086
- function isKeyEditLocalOpMetadata(metadata: any): metadata is IKeyEditLocalOpMetadata {
1087
- return (
1088
- metadata !== undefined &&
1089
- typeof metadata.pendingMessageId === "number" &&
1090
- metadata.type === "edit"
1091
- );
1092
- }
1093
-
1094
- function isClearLocalOpMetadata(metadata: any): metadata is IClearLocalOpMetadata {
1095
- return (
1096
- metadata !== undefined &&
1097
- metadata.type === "clear" &&
1098
- typeof metadata.pendingMessageId === "number" &&
1099
- typeof metadata.previousStorage === "object"
1100
- );
1101
- }
1119
+ type EditLocalOpMetadata = PendingKeySet | PendingKeyDelete;
1102
1120
 
1103
- function isSubDirLocalOpMetadata(metadata: any): metadata is SubDirLocalOpMetadata {
1104
- return (
1105
- metadata !== undefined &&
1106
- (metadata.type === "createSubDir" || metadata.type === "deleteSubDir")
1107
- );
1108
- }
1121
+ type ClearLocalOpMetadata = PendingClear;
1109
1122
 
1110
- function isDirectoryLocalOpMetadata(metadata: any): metadata is DirectoryLocalOpMetadata {
1111
- return (
1112
- isKeyEditLocalOpMetadata(metadata) ||
1113
- isClearLocalOpMetadata(metadata) ||
1114
- isSubDirLocalOpMetadata(metadata)
1115
- );
1116
- }
1123
+ type StorageLocalOpMetadata = EditLocalOpMetadata | ClearLocalOpMetadata;
1117
1124
 
1118
- /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
1125
+ /**
1126
+ * Types of local op metadata.
1127
+ */
1128
+ export type DirectoryLocalOpMetadata = StorageLocalOpMetadata | SubDirLocalOpMetadata;
1119
1129
 
1120
1130
  // eslint-disable-next-line @rushstack/no-new-null
1121
1131
  function assertNonNullClientId(clientId: string | null): asserts clientId is string {
@@ -1139,24 +1149,11 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1139
1149
  */
1140
1150
  public [Symbol.toStringTag]: string = "SubDirectory";
1141
1151
 
1142
- /**
1143
- * The in-memory data the directory is storing.
1144
- */
1145
- private readonly _storage = new Map<string, unknown>();
1146
-
1147
1152
  /**
1148
1153
  * The subdirectories the directory is holding.
1149
1154
  */
1150
1155
  private readonly _subdirectories = new Map<string, SubDirectory>();
1151
1156
 
1152
- /**
1153
- * Keys that have been modified locally but not yet ack'd from the server. This is for operations on keys like
1154
- * set/delete operations on keys. The value of this map is list of pendingMessageIds at which that key
1155
- * was modified. We don't store the type of ops, and behaviour of key ops are different from behaviour of sub
1156
- * directory ops, so we have separate map from subDirectories tracker.
1157
- */
1158
- private readonly pendingKeys = new Map<string, number[]>();
1159
-
1160
1157
  /**
1161
1158
  * Subdirectories that have been deleted locally but not yet ack'd from the server. This maintains the record
1162
1159
  * of delete op that are pending or yet to be acked from server. This is maintained just to track the locally
@@ -1171,16 +1168,6 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1171
1168
  */
1172
1169
  private readonly pendingCreateSubDirectoriesTracker = new Map<string, number>();
1173
1170
 
1174
- /**
1175
- * This is used to assign a unique id to every outgoing operation and helps in tracking unack'd ops.
1176
- */
1177
- private pendingMessageId: number = -1;
1178
-
1179
- /**
1180
- * The pending ids of any clears that have been performed locally but not yet ack'd from the server
1181
- */
1182
- private readonly pendingClearMessageIds: number[] = [];
1183
-
1184
1171
  /**
1185
1172
  * Assigns a unique ID to each subdirectory created locally but pending for acknowledgement, facilitating the tracking
1186
1173
  * of the creation order.
@@ -1251,15 +1238,14 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1251
1238
  */
1252
1239
  public has(key: string): boolean {
1253
1240
  this.throwIfDisposed();
1254
- return this._storage.has(key);
1241
+ return this.optimisticallyHas(key);
1255
1242
  }
1256
1243
 
1257
1244
  /**
1258
1245
  * {@inheritDoc IDirectory.get}
1259
1246
  */
1260
1247
  public get<T = unknown>(key: string): T | undefined {
1261
- this.throwIfDisposed();
1262
- return this._storage.get(key) as T | undefined;
1248
+ return this.getOptimisticValue(key) as T | undefined;
1263
1249
  }
1264
1250
 
1265
1251
  /**
@@ -1271,25 +1257,70 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1271
1257
  if (key === undefined || key === null) {
1272
1258
  throw new Error("Undefined and null keys are not supported");
1273
1259
  }
1260
+ const previousOptimisticLocalValue = this.getOptimisticValue(key);
1274
1261
 
1275
1262
  // Create a local value and serialize it.
1276
1263
  bindHandles(value, this.serializer, this.directory.handle);
1277
1264
 
1278
- // Set the value locally.
1279
- const previousValue = this.setCore(key, value, true);
1280
-
1281
1265
  // If we are not attached, don't submit the op.
1282
1266
  if (!this.directory.isAttached()) {
1267
+ this.sequencedStorageData.set(key, value);
1268
+ const event: IDirectoryValueChanged = {
1269
+ key,
1270
+ path: this.absolutePath,
1271
+ previousValue: previousOptimisticLocalValue,
1272
+ };
1273
+ this.directory.emit("valueChanged", event, true, this.directory);
1274
+ const containedEvent: IValueChanged = {
1275
+ key,
1276
+ previousValue: previousOptimisticLocalValue,
1277
+ };
1278
+ this.emit("containedValueChanged", containedEvent, true, this);
1283
1279
  return this;
1284
1280
  }
1285
1281
 
1286
- const op: IDirectorySetOperation = {
1282
+ // A new pending key lifetime is created if:
1283
+ // 1. There isn't any pending entry for the key yet
1284
+ // 2. The most recent pending entry for the key was a deletion (as this terminates the prior lifetime)
1285
+ // 3. A clear was sent after the last pending entry for the key (which also terminates the prior lifetime)
1286
+ let latestPendingEntry = findLast(
1287
+ this.pendingStorageData,
1288
+ (entry) => entry.type === "clear" || entry.key === key,
1289
+ );
1290
+ if (
1291
+ latestPendingEntry === undefined ||
1292
+ latestPendingEntry.type === "delete" ||
1293
+ latestPendingEntry.type === "clear"
1294
+ ) {
1295
+ latestPendingEntry = { type: "lifetime", path: this.absolutePath, key, keySets: [] };
1296
+ this.pendingStorageData.push(latestPendingEntry);
1297
+ }
1298
+ const pendingKeySet: PendingKeySet = {
1299
+ type: "set",
1300
+ path: this.absolutePath,
1301
+ value,
1302
+ };
1303
+ latestPendingEntry.keySets.push(pendingKeySet);
1304
+
1305
+ const op: IDirectoryOperation = {
1287
1306
  key,
1288
1307
  path: this.absolutePath,
1289
1308
  type: "set",
1290
1309
  value: { type: ValueType[ValueType.Plain], value },
1291
1310
  };
1292
- this.submitKeyMessage(op, previousValue);
1311
+ this.submitKeyMessage(op, pendingKeySet);
1312
+
1313
+ const directoryValueChanged: IDirectoryValueChanged = {
1314
+ key,
1315
+ path: this.absolutePath,
1316
+ previousValue: previousOptimisticLocalValue,
1317
+ };
1318
+ this.directory.emit("valueChanged", directoryValueChanged, true, this.directory);
1319
+ const valueChanged: IValueChanged = {
1320
+ key,
1321
+ previousValue: previousOptimisticLocalValue,
1322
+ };
1323
+ this.emit("containedValueChanged", valueChanged, true, this);
1293
1324
  return this;
1294
1325
  }
1295
1326
 
@@ -1481,22 +1512,57 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1481
1512
  */
1482
1513
  public delete(key: string): boolean {
1483
1514
  this.throwIfDisposed();
1484
- // Delete the key locally first.
1485
- const previousValue = this.deleteCore(key, true);
1515
+ const previousOptimisticLocalValue = this.getOptimisticValue(key);
1486
1516
 
1487
- // If we are not attached, don't submit the op.
1488
1517
  if (!this.directory.isAttached()) {
1489
- return previousValue !== undefined;
1518
+ const successfullyRemoved = this.sequencedStorageData.delete(key);
1519
+ // Only emit if we actually deleted something.
1520
+ if (previousOptimisticLocalValue !== undefined && successfullyRemoved) {
1521
+ const event: IDirectoryValueChanged = {
1522
+ key,
1523
+ path: this.absolutePath,
1524
+ previousValue: previousOptimisticLocalValue,
1525
+ };
1526
+ this.directory.emit("valueChanged", event, true, this.directory);
1527
+ const containedEvent: IValueChanged = {
1528
+ key,
1529
+ previousValue: previousOptimisticLocalValue,
1530
+ };
1531
+ this.emit("containedValueChanged", containedEvent, true, this);
1532
+ }
1533
+ return successfullyRemoved;
1490
1534
  }
1491
1535
 
1492
- const op: IDirectoryDeleteOperation = {
1493
- key,
1494
- path: this.absolutePath,
1536
+ const pendingKeyDelete: PendingKeyDelete = {
1495
1537
  type: "delete",
1538
+ path: this.absolutePath,
1539
+ key,
1496
1540
  };
1541
+ this.pendingStorageData.push(pendingKeyDelete);
1497
1542
 
1498
- this.submitKeyMessage(op, previousValue);
1499
- return previousValue !== undefined;
1543
+ const op: IDirectoryOperation = {
1544
+ key,
1545
+ type: "delete",
1546
+ path: this.absolutePath,
1547
+ };
1548
+ this.submitKeyMessage(op, pendingKeyDelete);
1549
+ // Only emit if we locally believe we deleted something. Otherwise we still send the op
1550
+ // (permitting speculative deletion even if we don't see anything locally) but don't emit
1551
+ // a valueChanged since we in fact did not locally observe a value change.
1552
+ if (previousOptimisticLocalValue !== undefined) {
1553
+ const event: IDirectoryValueChanged = {
1554
+ key,
1555
+ path: this.absolutePath,
1556
+ previousValue: previousOptimisticLocalValue,
1557
+ };
1558
+ this.directory.emit("valueChanged", event, true, this.directory);
1559
+ const containedEvent: IValueChanged = {
1560
+ key,
1561
+ previousValue: previousOptimisticLocalValue,
1562
+ };
1563
+ this.emit("containedValueChanged", containedEvent, true, this);
1564
+ }
1565
+ return true;
1500
1566
  }
1501
1567
 
1502
1568
  /**
@@ -1505,19 +1571,24 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1505
1571
  public clear(): void {
1506
1572
  this.throwIfDisposed();
1507
1573
 
1508
- // If we are not attached, don't submit the op.
1509
1574
  if (!this.directory.isAttached()) {
1510
- this.clearCore(true);
1575
+ this.sequencedStorageData.clear();
1576
+ this.directory.emit("clear", true, this.directory);
1511
1577
  return;
1512
1578
  }
1513
1579
 
1514
- const copy = new Map<string, unknown>(this._storage);
1515
- this.clearCore(true);
1516
- const op: IDirectoryClearOperation = {
1580
+ const pendingClear: PendingClear = {
1581
+ type: "clear",
1517
1582
  path: this.absolutePath,
1583
+ };
1584
+ this.pendingStorageData.push(pendingClear);
1585
+
1586
+ this.directory.emit("clear", true, this.directory);
1587
+ const op: IDirectoryOperation = {
1518
1588
  type: "clear",
1589
+ path: this.absolutePath,
1519
1590
  };
1520
- this.submitClearMessage(op, copy);
1591
+ this.submitClearMessage(op, pendingClear);
1521
1592
  }
1522
1593
 
1523
1594
  /**
@@ -1528,10 +1599,9 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1528
1599
  callback: (value: unknown, key: string, map: Map<string, unknown>) => void,
1529
1600
  ): void {
1530
1601
  this.throwIfDisposed();
1531
- // eslint-disable-next-line unicorn/no-array-for-each
1532
- this._storage.forEach((value, key) => {
1533
- callback(value, key, this);
1534
- });
1602
+ for (const [key, localValue] of this.internalIterator()) {
1603
+ callback((localValue as { value: unknown }).value, key, this);
1604
+ }
1535
1605
  }
1536
1606
 
1537
1607
  /**
@@ -1539,7 +1609,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1539
1609
  */
1540
1610
  public get size(): number {
1541
1611
  this.throwIfDisposed();
1542
- return this._storage.size;
1612
+ return [...this.internalIterator()].length;
1543
1613
  }
1544
1614
 
1545
1615
  /**
@@ -1548,7 +1618,24 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1548
1618
  */
1549
1619
  public entries(): IterableIterator<[string, unknown]> {
1550
1620
  this.throwIfDisposed();
1551
- return this._storage.entries();
1621
+ const internalIterator = this.internalIterator();
1622
+ const next = (): IteratorResult<[string, unknown]> => {
1623
+ const nextResult = internalIterator.next();
1624
+ if (nextResult.done) {
1625
+ return { value: undefined, done: true };
1626
+ }
1627
+ // Unpack the stored value
1628
+ const [key, localValue] = nextResult.value;
1629
+ return { value: [key, localValue], done: false };
1630
+ };
1631
+
1632
+ const iterator = {
1633
+ next,
1634
+ [Symbol.iterator](): IterableIterator<[string, unknown]> {
1635
+ return this;
1636
+ },
1637
+ };
1638
+ return iterator;
1552
1639
  }
1553
1640
 
1554
1641
  /**
@@ -1557,7 +1644,22 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1557
1644
  */
1558
1645
  public keys(): IterableIterator<string> {
1559
1646
  this.throwIfDisposed();
1560
- return this._storage.keys();
1647
+ const internalIterator = this.internalIterator();
1648
+ const next = (): IteratorResult<string> => {
1649
+ const nextResult = internalIterator.next();
1650
+ if (nextResult.done) {
1651
+ return { value: undefined, done: true };
1652
+ }
1653
+ const [key] = nextResult.value;
1654
+ return { value: key, done: false };
1655
+ };
1656
+ const iterator = {
1657
+ next,
1658
+ [Symbol.iterator](): IterableIterator<string> {
1659
+ return this;
1660
+ },
1661
+ };
1662
+ return iterator;
1561
1663
  }
1562
1664
 
1563
1665
  /**
@@ -1566,7 +1668,22 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1566
1668
  */
1567
1669
  public values(): IterableIterator<unknown> {
1568
1670
  this.throwIfDisposed();
1569
- return this._storage.values();
1671
+ const internalIterator = this.internalIterator();
1672
+ const next = (): IteratorResult<unknown> => {
1673
+ const nextResult = internalIterator.next();
1674
+ if (nextResult.done) {
1675
+ return { value: undefined, done: true };
1676
+ }
1677
+ const [, localValue] = nextResult.value;
1678
+ return { value: localValue, done: false };
1679
+ };
1680
+ const iterator = {
1681
+ next,
1682
+ [Symbol.iterator](): IterableIterator<unknown> {
1683
+ return this;
1684
+ },
1685
+ };
1686
+ return iterator;
1570
1687
  }
1571
1688
 
1572
1689
  /**
@@ -1575,9 +1692,135 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1575
1692
  */
1576
1693
  public [Symbol.iterator](): IterableIterator<[string, unknown]> {
1577
1694
  this.throwIfDisposed();
1578
- return this.entries();
1695
+ return this.internalIterator();
1579
1696
  }
1580
1697
 
1698
+ /**
1699
+ * The data this SubDirectory instance is storing, but only including sequenced values (no local pending
1700
+ * modifications are included).
1701
+ */
1702
+ private readonly sequencedStorageData = new Map<string, unknown>();
1703
+
1704
+ /**
1705
+ * A data structure containing all local pending storage modifications, which is used in combination
1706
+ * with the sequencedStorageData to compute optimistic values.
1707
+ *
1708
+ * Pending sets are aggregated into "lifetimes", which permit correct relative iteration order
1709
+ * even across remote operations and rollbacks.
1710
+ */
1711
+ private readonly pendingStorageData: PendingStorageEntry[] = [];
1712
+
1713
+ /**
1714
+ * An internal iterator that iterates over the entries in the directory.
1715
+ */
1716
+ private readonly internalIterator = (): IterableIterator<[string, unknown]> => {
1717
+ // We perform iteration in two steps - first by iterating over members of the sequenced storage data that are not
1718
+ // optimistically deleted or cleared, and then over the pending data lifetimes that have not subsequently
1719
+ // been deleted or cleared. In total, this give an ordering of members based on when they were initially
1720
+ // added to the sub directory (even if they were later modified), similar to the native Map.
1721
+ const sequencedStorageDataIterator = this.sequencedStorageData.keys();
1722
+ const pendingStorageDataIterator = this.pendingStorageData.values();
1723
+ const next = (): IteratorResult<[string, unknown]> => {
1724
+ let nextSequencedKey = sequencedStorageDataIterator.next();
1725
+ while (!nextSequencedKey.done) {
1726
+ const key = nextSequencedKey.value;
1727
+ // If we have any pending deletes or clears, then we won't iterate to this key yet (if at all).
1728
+ // Either it is optimistically deleted and will not be part of the iteration, or it was
1729
+ // re-added later and we'll iterate to it when we get to the pending data.
1730
+ if (
1731
+ !this.pendingStorageData.some(
1732
+ (entry) =>
1733
+ entry.type === "clear" || (entry.type === "delete" && entry.key === key),
1734
+ )
1735
+ ) {
1736
+ assert(this.has(key), 0xc03 /* key should exist in sequenced or pending data */);
1737
+ const optimisticValue = this.getOptimisticValue(key);
1738
+ return { value: [key, optimisticValue], done: false };
1739
+ }
1740
+ nextSequencedKey = sequencedStorageDataIterator.next();
1741
+ }
1742
+
1743
+ let nextPending = pendingStorageDataIterator.next();
1744
+ while (!nextPending.done) {
1745
+ const nextPendingEntry = nextPending.value;
1746
+ // A lifetime entry may need to be iterated.
1747
+ if (nextPendingEntry.type === "lifetime") {
1748
+ const nextPendingEntryIndex = this.pendingStorageData.indexOf(nextPendingEntry);
1749
+ const mostRecentDeleteOrClearIndex = findLastIndex(
1750
+ this.pendingStorageData,
1751
+ (entry) =>
1752
+ entry.type === "clear" ||
1753
+ (entry.type === "delete" && entry.key === nextPendingEntry.key),
1754
+ );
1755
+ // Only iterate the pending entry now if it hasn't been deleted or cleared.
1756
+ if (nextPendingEntryIndex > mostRecentDeleteOrClearIndex) {
1757
+ const latestPendingValue =
1758
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1759
+ nextPendingEntry.keySets[nextPendingEntry.keySets.length - 1]!;
1760
+ // Skip iterating if we would have would have already iterated it as part of the sequenced data.
1761
+ // This is not a perfect check in the case the map has changed since the iterator was created
1762
+ // (e.g. if a remote client added the same key in the meantime).
1763
+ if (
1764
+ !this.sequencedStorageData.has(nextPendingEntry.key) ||
1765
+ mostRecentDeleteOrClearIndex !== -1
1766
+ ) {
1767
+ return { value: [nextPendingEntry.key, latestPendingValue.value], done: false };
1768
+ }
1769
+ }
1770
+ }
1771
+ nextPending = pendingStorageDataIterator.next();
1772
+ }
1773
+
1774
+ return { value: undefined, done: true };
1775
+ };
1776
+
1777
+ const iterator = {
1778
+ next,
1779
+ [Symbol.iterator](): IterableIterator<[string, unknown]> {
1780
+ return this;
1781
+ },
1782
+ };
1783
+ return iterator;
1784
+ };
1785
+
1786
+ /**
1787
+ * Compute the optimistic local value for a given key. This combines the sequenced data with
1788
+ * any pending changes that have not yet been sequenced.
1789
+ */
1790
+ private readonly getOptimisticValue = (key: string): unknown => {
1791
+ const latestPendingEntry = findLast(
1792
+ this.pendingStorageData,
1793
+ (entry) => entry.type === "clear" || entry.key === key,
1794
+ );
1795
+
1796
+ if (latestPendingEntry === undefined) {
1797
+ return this.sequencedStorageData.get(key);
1798
+ } else if (latestPendingEntry.type === "lifetime") {
1799
+ const latestPendingSet =
1800
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1801
+ latestPendingEntry.keySets[latestPendingEntry.keySets.length - 1]!;
1802
+ return latestPendingSet.value;
1803
+ } else {
1804
+ // Delete or clear
1805
+ return undefined;
1806
+ }
1807
+ };
1808
+
1809
+ /**
1810
+ * Determine if the directory optimistically has the key.
1811
+ * This will return true even if the value is undefined.
1812
+ */
1813
+ private readonly optimisticallyHas = (key: string): boolean => {
1814
+ const latestPendingEntry = findLast(
1815
+ this.pendingStorageData,
1816
+ (entry) => entry.type === "clear" || entry.key === key,
1817
+ );
1818
+
1819
+ return latestPendingEntry === undefined
1820
+ ? this.sequencedStorageData.has(key)
1821
+ : latestPendingEntry.type === "lifetime";
1822
+ };
1823
+
1581
1824
  /**
1582
1825
  * Process a clear operation.
1583
1826
  * @param msg - The message from the server to apply.
@@ -1590,25 +1833,52 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1590
1833
  msg: ISequencedDocumentMessage,
1591
1834
  op: IDirectoryClearOperation,
1592
1835
  local: boolean,
1593
- localOpMetadata: unknown,
1836
+ localOpMetadata: ClearLocalOpMetadata | undefined,
1594
1837
  ): void {
1595
1838
  this.throwIfDisposed();
1596
1839
  if (!this.isMessageForCurrentInstanceOfSubDirectory(msg)) {
1597
1840
  return;
1598
1841
  }
1842
+
1599
1843
  if (local) {
1844
+ this.sequencedStorageData.clear();
1845
+ const pendingClear = this.pendingStorageData.shift();
1600
1846
  assert(
1601
- isClearLocalOpMetadata(localOpMetadata),
1602
- 0x00f /* pendingMessageId is missing from the local client's operation */,
1603
- );
1604
- const pendingClearMessageId = this.pendingClearMessageIds.shift();
1605
- assert(
1606
- pendingClearMessageId === localOpMetadata.pendingMessageId,
1607
- 0x32a /* pendingMessageId does not match */,
1847
+ pendingClear !== undefined &&
1848
+ pendingClear.type === "clear" &&
1849
+ pendingClear === localOpMetadata,
1850
+ 0xc04 /* Got a local clear message we weren't expecting */,
1608
1851
  );
1609
- return;
1852
+ } else {
1853
+ // For pending set operations, collect the previous values before clearing sequenced data
1854
+ const pendingSets: { key: string; previousValue: unknown }[] = [];
1855
+ for (const entry of this.pendingStorageData) {
1856
+ if (entry.type === "lifetime") {
1857
+ const previousValue = this.sequencedStorageData.get(entry.key);
1858
+ pendingSets.push({ key: entry.key, previousValue });
1859
+ }
1860
+ }
1861
+ this.sequencedStorageData.clear();
1862
+
1863
+ // Only emit for remote ops, we would have already emitted for local ops. Only emit if there
1864
+ // is no optimistically-applied local pending clear that would supersede this remote clear.
1865
+ if (!this.pendingStorageData.some((entry) => entry.type === "clear")) {
1866
+ this.directory.emit("clear", local, this.directory);
1867
+ }
1868
+
1869
+ // For pending set operations, emit valueChanged events
1870
+ for (const { key, previousValue } of pendingSets) {
1871
+ this.directory.emit(
1872
+ "valueChanged",
1873
+ {
1874
+ key,
1875
+ previousValue,
1876
+ },
1877
+ local,
1878
+ this.directory,
1879
+ );
1880
+ }
1610
1881
  }
1611
- this.clearExceptPendingKeys(false);
1612
1882
  }
1613
1883
 
1614
1884
  /**
@@ -1623,18 +1893,44 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1623
1893
  msg: ISequencedDocumentMessage,
1624
1894
  op: IDirectoryDeleteOperation,
1625
1895
  local: boolean,
1626
- localOpMetadata: unknown,
1896
+ localOpMetadata: EditLocalOpMetadata | undefined,
1627
1897
  ): void {
1628
1898
  this.throwIfDisposed();
1629
- if (
1630
- !(
1631
- this.isMessageForCurrentInstanceOfSubDirectory(msg) &&
1632
- this.needProcessStorageOperation(op, local, localOpMetadata)
1633
- )
1634
- ) {
1899
+ if (!this.isMessageForCurrentInstanceOfSubDirectory(msg)) {
1635
1900
  return;
1636
1901
  }
1637
- this.deleteCore(op.key, local);
1902
+ if (local) {
1903
+ const pendingEntryIndex = this.pendingStorageData.findIndex(
1904
+ (entry) => entry.type !== "clear" && entry.key === op.key,
1905
+ );
1906
+ const pendingEntry = this.pendingStorageData[pendingEntryIndex];
1907
+ assert(
1908
+ pendingEntry !== undefined &&
1909
+ pendingEntry.type === "delete" &&
1910
+ pendingEntry.key === op.key,
1911
+ 0xc05 /* Got a local delete message we weren't expecting */,
1912
+ );
1913
+ this.pendingStorageData.splice(pendingEntryIndex, 1);
1914
+ this.sequencedStorageData.delete(op.key);
1915
+ } else {
1916
+ const previousValue: unknown = this.sequencedStorageData.get(op.key);
1917
+ this.sequencedStorageData.delete(op.key);
1918
+ // Suppress the event if local changes would cause the incoming change to be invisible optimistically.
1919
+ if (
1920
+ !this.pendingStorageData.some(
1921
+ (entry) => entry.type === "clear" || entry.key === op.key,
1922
+ )
1923
+ ) {
1924
+ const event: IDirectoryValueChanged = {
1925
+ key: op.key,
1926
+ path: this.absolutePath,
1927
+ previousValue,
1928
+ };
1929
+ this.directory.emit("valueChanged", event, local, this.directory);
1930
+ const containedEvent: IValueChanged = { key: op.key, previousValue };
1931
+ this.emit("containedValueChanged", containedEvent, local, this);
1932
+ }
1933
+ }
1638
1934
  }
1639
1935
 
1640
1936
  /**
@@ -1650,21 +1946,49 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1650
1946
  op: IDirectorySetOperation,
1651
1947
  value: unknown,
1652
1948
  local: boolean,
1653
- localOpMetadata: unknown,
1949
+ localOpMetadata: EditLocalOpMetadata | undefined,
1654
1950
  ): void {
1655
1951
  this.throwIfDisposed();
1656
- if (
1657
- !(
1658
- this.isMessageForCurrentInstanceOfSubDirectory(msg) &&
1659
- this.needProcessStorageOperation(op, local, localOpMetadata)
1660
- )
1661
- ) {
1952
+ if (!this.isMessageForCurrentInstanceOfSubDirectory(msg)) {
1662
1953
  return;
1663
1954
  }
1664
1955
 
1665
- // needProcessStorageOperation should have returned false if local is true
1666
- // so we can assume localValue is not undefined
1667
- this.setCore(op.key, value, local);
1956
+ const { key } = op;
1957
+
1958
+ if (local) {
1959
+ const pendingEntryIndex = this.pendingStorageData.findIndex(
1960
+ (entry) => entry.type !== "clear" && entry.key === key,
1961
+ );
1962
+ const pendingEntry = this.pendingStorageData[pendingEntryIndex];
1963
+ assert(
1964
+ pendingEntry !== undefined && pendingEntry.type === "lifetime",
1965
+ 0xc06 /* Couldn't match local set message to pending lifetime */,
1966
+ );
1967
+ const pendingKeySet = pendingEntry.keySets.shift();
1968
+ assert(
1969
+ pendingKeySet !== undefined && pendingKeySet === localOpMetadata,
1970
+ 0xc07 /* Got a local set message we weren't expecting */,
1971
+ );
1972
+ if (pendingEntry.keySets.length === 0) {
1973
+ this.pendingStorageData.splice(pendingEntryIndex, 1);
1974
+ }
1975
+
1976
+ this.sequencedStorageData.set(key, pendingKeySet.value);
1977
+ } else {
1978
+ // Get the previous value before setting the new value
1979
+ const previousValue: unknown = this.sequencedStorageData.get(key);
1980
+ this.sequencedStorageData.set(key, value);
1981
+
1982
+ // Suppress the event if local changes would cause the incoming change to be invisible optimistically.
1983
+ if (
1984
+ !this.pendingStorageData.some((entry) => entry.type === "clear" || entry.key === key)
1985
+ ) {
1986
+ const event: IDirectoryValueChanged = { key, path: this.absolutePath, previousValue };
1987
+ this.directory.emit("valueChanged", event, local, this.directory);
1988
+ const containedEvent: IValueChanged = { key, previousValue };
1989
+ this.emit("containedValueChanged", containedEvent, local, this);
1990
+ }
1991
+ }
1668
1992
  }
1669
1993
 
1670
1994
  /**
@@ -1679,7 +2003,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1679
2003
  msg: ISequencedDocumentMessage,
1680
2004
  op: IDirectoryCreateSubDirectoryOperation,
1681
2005
  local: boolean,
1682
- localOpMetadata: unknown,
2006
+ localOpMetadata: SubDirLocalOpMetadata | undefined,
1683
2007
  ): void {
1684
2008
  this.throwIfDisposed();
1685
2009
  if (
@@ -1711,7 +2035,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1711
2035
  msg: ISequencedDocumentMessage,
1712
2036
  op: IDirectoryDeleteSubDirectoryOperation,
1713
2037
  local: boolean,
1714
- localOpMetadata: unknown,
2038
+ localOpMetadata: SubDirLocalOpMetadata | undefined,
1715
2039
  ): void {
1716
2040
  this.throwIfDisposed();
1717
2041
  if (
@@ -1728,65 +2052,46 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1728
2052
  /**
1729
2053
  * Submit a clear operation.
1730
2054
  * @param op - The operation
2055
+ * @param localOpMetadata - The pending operation metadata
1731
2056
  */
1732
2057
  private submitClearMessage(
1733
2058
  op: IDirectoryClearOperation,
1734
- previousValue: Map<string, unknown>,
2059
+ localOpMetadata: ClearLocalOpMetadata,
1735
2060
  ): void {
1736
2061
  this.throwIfDisposed();
1737
- const pendingMsgId = ++this.pendingMessageId;
1738
- this.pendingClearMessageIds.push(pendingMsgId);
1739
- const metadata: IClearLocalOpMetadata = {
1740
- type: "clear",
1741
- pendingMessageId: pendingMsgId,
1742
- previousStorage: previousValue,
1743
- };
1744
- this.directory.submitDirectoryMessage(op, metadata);
2062
+ this.directory.submitDirectoryMessage(op, localOpMetadata);
1745
2063
  }
1746
2064
 
1747
2065
  /**
1748
2066
  * Resubmit a clear operation.
1749
2067
  * @param op - The operation
1750
2068
  */
1751
- public resubmitClearMessage(op: IDirectoryClearOperation, localOpMetadata: unknown): void {
1752
- assert(
1753
- isClearLocalOpMetadata(localOpMetadata),
1754
- 0x32b /* Invalid localOpMetadata for clear */,
1755
- );
1756
- // We don't reuse the metadata pendingMessageId but send a new one on each submit.
1757
- const pendingClearMessageId = this.pendingClearMessageIds.shift();
2069
+ public resubmitClearMessage(
2070
+ op: IDirectoryClearOperation,
2071
+ localOpMetadata: ClearLocalOpMetadata,
2072
+ ): void {
1758
2073
  // Only submit the op, if we have record for it, otherwise it is possible that the older instance
1759
2074
  // is already deleted, in which case we don't need to submit the op.
1760
- if (pendingClearMessageId === localOpMetadata.pendingMessageId) {
1761
- this.submitClearMessage(op, localOpMetadata.previousStorage);
1762
- }
1763
- }
1764
-
1765
- /**
1766
- * Get a new pending message id for the op and cache it to track the pending op
1767
- */
1768
- private getKeyMessageId(op: IDirectoryKeyOperation): number {
1769
- // We don't reuse the metadata pendingMessageId but send a new one on each submit.
1770
- const pendingMessageId = ++this.pendingMessageId;
1771
- const pendingMessageIds = this.pendingKeys.get(op.key);
1772
- if (pendingMessageIds === undefined) {
1773
- this.pendingKeys.set(op.key, [pendingMessageId]);
1774
- } else {
1775
- pendingMessageIds.push(pendingMessageId);
2075
+ const pendingEntryIndex = this.pendingStorageData.findIndex(
2076
+ (entry) => entry.type === "clear",
2077
+ );
2078
+ const pendingEntry = this.pendingStorageData[pendingEntryIndex];
2079
+ if (pendingEntry !== undefined) {
2080
+ this.submitClearMessage(op, localOpMetadata);
1776
2081
  }
1777
- return pendingMessageId;
1778
2082
  }
1779
2083
 
1780
2084
  /**
1781
2085
  * Submit a key operation.
1782
2086
  * @param op - The operation
1783
- * @param previousValue - The value of the key before this op
2087
+ * @param localOpMetadata - The pending operation metadata
1784
2088
  */
1785
- private submitKeyMessage(op: IDirectoryKeyOperation, previousValue?: unknown): void {
2089
+ private submitKeyMessage(
2090
+ op: IDirectoryKeyOperation,
2091
+ localOpMetadata: PendingKeySet | PendingKeyDelete,
2092
+ ): void {
1786
2093
  this.throwIfDisposed();
1787
- const pendingMessageId = this.getKeyMessageId(op);
1788
- const localMetadata = { type: "edit", pendingMessageId, previousValue };
1789
- this.directory.submitDirectoryMessage(op, localMetadata);
2094
+ this.directory.submitDirectoryMessage(op, localOpMetadata);
1790
2095
  }
1791
2096
 
1792
2097
  /**
@@ -1794,26 +2099,18 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1794
2099
  * @param op - The map key message
1795
2100
  * @param localOpMetadata - Metadata from the previous submit
1796
2101
  */
1797
- public resubmitKeyMessage(op: IDirectoryKeyOperation, localOpMetadata: unknown): void {
1798
- assert(
1799
- isKeyEditLocalOpMetadata(localOpMetadata),
1800
- 0x32d /* Invalid localOpMetadata in submit */,
1801
- );
1802
-
1803
- // clear the old pending message id
1804
- const pendingMessageIds = this.pendingKeys.get(op.key);
2102
+ public resubmitKeyMessage(
2103
+ op: IDirectoryKeyOperation,
2104
+ localOpMetadata: EditLocalOpMetadata,
2105
+ ): void {
1805
2106
  // Only submit the op, if we have record for it, otherwise it is possible that the older instance
1806
2107
  // is already deleted, in which case we don't need to submit the op.
1807
- if (pendingMessageIds !== undefined) {
1808
- const index = pendingMessageIds.indexOf(localOpMetadata.pendingMessageId);
1809
- if (index === -1) {
1810
- return;
1811
- }
1812
- pendingMessageIds.splice(index, 1);
1813
- if (pendingMessageIds.length === 0) {
1814
- this.pendingKeys.delete(op.key);
1815
- }
1816
- this.submitKeyMessage(op, localOpMetadata.previousValue);
2108
+ const pendingEntryIndex = this.pendingStorageData.findIndex(
2109
+ (entry) => entry.type !== "clear" && entry.key === op.key,
2110
+ );
2111
+ const pendingEntry = this.pendingStorageData[pendingEntryIndex];
2112
+ if (pendingEntry !== undefined) {
2113
+ this.submitKeyMessage(op, localOpMetadata as PendingKeySet | PendingKeyDelete);
1817
2114
  }
1818
2115
  }
1819
2116
 
@@ -1863,7 +2160,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1863
2160
  */
1864
2161
  private submitDeleteSubDirectoryMessage(
1865
2162
  op: IDirectorySubDirectoryOperation,
1866
- subDir: SubDirectory | undefined,
2163
+ subDir: SubDirectory,
1867
2164
  ): void {
1868
2165
  this.throwIfDisposed();
1869
2166
  this.updatePendingSubDirMessageCount(op);
@@ -1882,13 +2179,8 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1882
2179
  */
1883
2180
  public resubmitSubDirectoryMessage(
1884
2181
  op: IDirectorySubDirectoryOperation,
1885
- localOpMetadata: unknown,
2182
+ localOpMetadata: SubDirLocalOpMetadata,
1886
2183
  ): void {
1887
- assert(
1888
- isSubDirLocalOpMetadata(localOpMetadata),
1889
- 0x32f /* Invalid localOpMetadata for sub directory op */,
1890
- );
1891
-
1892
2184
  // Only submit the op, if we have record for it, otherwise it is possible that the older instance
1893
2185
  // is already deleted, in which case we don't need to submit the op.
1894
2186
  if (
@@ -1906,8 +2198,12 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1906
2198
  if (localOpMetadata.type === "createSubDir") {
1907
2199
  this.decrementPendingSubDirCount(this.pendingCreateSubDirectoriesTracker, op.subdirName);
1908
2200
  this.submitCreateSubDirectoryMessage(op);
1909
- } else {
2201
+ } else if (localOpMetadata.type === "deleteSubDir") {
1910
2202
  this.decrementPendingSubDirCount(this.pendingDeleteSubDirectoriesTracker, op.subdirName);
2203
+ assert(
2204
+ localOpMetadata.subDirectory !== undefined,
2205
+ 0xc08 /* localOpMetadata.subDirectory should be defined */,
2206
+ );
1911
2207
  this.submitDeleteSubDirectoryMessage(op, localOpMetadata.subDirectory);
1912
2208
  }
1913
2209
  }
@@ -1921,7 +2217,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1921
2217
  serializer: IFluidSerializer,
1922
2218
  ): Generator<[string, ISerializedValue], void> {
1923
2219
  this.throwIfDisposed();
1924
- for (const [key, value] of this._storage) {
2220
+ for (const [key, value] of this.sequencedStorageData.entries()) {
1925
2221
  const serializedValue = serializeValue(value, serializer, this.directory.handle);
1926
2222
  const res: [string, ISerializedValue] = [key, serializedValue];
1927
2223
  yield res;
@@ -1944,7 +2240,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1944
2240
  */
1945
2241
  public populateStorage(key: string, value: unknown): void {
1946
2242
  this.throwIfDisposed();
1947
- this._storage.set(key, value);
2243
+ this.sequencedStorageData.set(key, value);
1948
2244
  }
1949
2245
 
1950
2246
  /**
@@ -1958,82 +2254,93 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
1958
2254
  this._subdirectories.set(subdirName, newSubDir);
1959
2255
  }
1960
2256
 
1961
- /**
1962
- * Retrieve the local value at the given key. This is used to get value type information stashed on the local
1963
- * value so op handlers can be retrieved
1964
- * @param key - The key to retrieve from
1965
- * @returns The local value
1966
- */
1967
- public getLocalValue<T>(key: string): T {
1968
- this.throwIfDisposed();
1969
- return this._storage.get(key) as T;
1970
- }
1971
-
1972
- /**
1973
- * Remove the pendingMessageId from the map tracking it on rollback
1974
- * @param map - map tracking the pending messages
1975
- * @param key - key of the edit in the op
1976
- */
1977
- private rollbackPendingMessageId(
1978
- map: Map<string, number[]>,
1979
- key: string,
1980
- pendingMessageId,
1981
- ): void {
1982
- const pendingMessageIds = map.get(key);
1983
- const lastPendingMessageId = pendingMessageIds?.pop();
1984
- if (!pendingMessageIds || lastPendingMessageId !== pendingMessageId) {
1985
- throw new Error("Rollback op does not match last pending");
1986
- }
1987
- if (pendingMessageIds.length === 0) {
1988
- map.delete(key);
1989
- }
1990
- }
1991
-
1992
- /* eslint-disable @typescript-eslint/no-unsafe-member-access */
1993
-
1994
2257
  /**
1995
2258
  * Rollback a local op
1996
2259
  * @param op - The operation to rollback
1997
2260
  * @param localOpMetadata - The local metadata associated with the op.
1998
2261
  */
1999
2262
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2000
- public rollback(op: any, localOpMetadata: unknown): void {
2001
- if (!isDirectoryLocalOpMetadata(localOpMetadata)) {
2002
- throw new Error("Invalid localOpMetadata");
2003
- }
2004
-
2005
- if (op.type === "clear" && localOpMetadata.type === "clear") {
2006
- for (const [key, localValue] of localOpMetadata.previousStorage.entries()) {
2007
- this.setCore(key, localValue, true);
2008
- }
2263
+ public rollback(op: any, localOpMetadata: DirectoryLocalOpMetadata): void {
2264
+ const directoryOp = op as IDirectoryOperation;
2009
2265
 
2010
- const lastPendingClearId = this.pendingClearMessageIds.pop();
2011
- if (
2012
- lastPendingClearId === undefined ||
2013
- lastPendingClearId !== localOpMetadata.pendingMessageId
2014
- ) {
2015
- throw new Error("Rollback op does match last clear");
2266
+ if (directoryOp.type === "clear") {
2267
+ // A pending clear will be last in the list, since it terminates all prior lifetimes.
2268
+ const pendingClear = this.pendingStorageData.pop();
2269
+ assert(
2270
+ pendingClear !== undefined &&
2271
+ pendingClear.type === "clear" &&
2272
+ localOpMetadata.type === "clear",
2273
+ 0xc09 /* Unexpected clear rollback */,
2274
+ );
2275
+ for (const [key] of this.internalIterator()) {
2276
+ const event: IDirectoryValueChanged = {
2277
+ key,
2278
+ path: this.absolutePath,
2279
+ previousValue: undefined,
2280
+ };
2281
+ this.directory.emit("valueChanged", event, true, this.directory);
2282
+ const containedEvent: IValueChanged = { key, previousValue: undefined };
2283
+ this.emit("containedValueChanged", containedEvent, true, this);
2016
2284
  }
2017
2285
  } else if (
2018
- (op.type === "delete" || op.type === "set") &&
2019
- localOpMetadata.type === "edit"
2286
+ (directoryOp.type === "delete" || directoryOp.type === "set") &&
2287
+ (localOpMetadata.type === "set" || localOpMetadata.type === "delete")
2020
2288
  ) {
2021
- const key: unknown = op.key;
2022
- assert(key !== undefined, 0x8ad /* "key" property is missing from edit operation. */);
2289
+ // A pending set/delete may not be last in the list, as the lifetimes' order is based on when
2290
+ // they were created, not when they were last modified.
2291
+ const pendingEntryIndex = findLastIndex(
2292
+ this.pendingStorageData,
2293
+ (entry) => entry.type !== "clear" && entry.key === directoryOp.key,
2294
+ );
2295
+ const pendingEntry = this.pendingStorageData[pendingEntryIndex];
2023
2296
  assert(
2024
- typeof key === "string",
2025
- 0x8ae /* "key" property in edit operation is misconfigured. Expected a string. */,
2297
+ pendingEntry !== undefined &&
2298
+ (pendingEntry.type === "delete" || pendingEntry.type === "lifetime"),
2299
+ 0xc0a /* Unexpected pending data for set/delete op */,
2026
2300
  );
2027
-
2028
- if (localOpMetadata.previousValue === undefined) {
2029
- this.deleteCore(key, true);
2030
- } else {
2031
- this.setCore(key, localOpMetadata.previousValue, true);
2301
+ if (pendingEntry.type === "delete") {
2302
+ assert(pendingEntry === localOpMetadata, 0xc0b /* Unexpected delete rollback */);
2303
+ this.pendingStorageData.splice(pendingEntryIndex, 1);
2304
+ // Only emit if rolling back the delete actually results in a value becoming visible.
2305
+ if (this.getOptimisticValue(directoryOp.key) !== undefined) {
2306
+ const event: IDirectoryValueChanged = {
2307
+ key: directoryOp.key,
2308
+ path: this.absolutePath,
2309
+ previousValue: undefined,
2310
+ };
2311
+ this.directory.emit("valueChanged", event, true, this.directory);
2312
+ const containedEvent: IValueChanged = {
2313
+ key: directoryOp.key,
2314
+ previousValue: undefined,
2315
+ };
2316
+ this.emit("containedValueChanged", containedEvent, true, this);
2317
+ }
2318
+ } else if (pendingEntry.type === "lifetime") {
2319
+ const pendingKeySet = pendingEntry.keySets.pop();
2320
+ assert(
2321
+ pendingKeySet !== undefined && pendingKeySet === localOpMetadata,
2322
+ 0xc0c /* Unexpected set rollback */,
2323
+ );
2324
+ if (pendingEntry.keySets.length === 0) {
2325
+ this.pendingStorageData.splice(pendingEntryIndex, 1);
2326
+ }
2327
+ const event: IDirectoryValueChanged = {
2328
+ key: directoryOp.key,
2329
+ path: this.absolutePath,
2330
+ previousValue: pendingKeySet.value,
2331
+ };
2332
+ this.directory.emit("valueChanged", event, true, this.directory);
2333
+ const containedEvent: IValueChanged = {
2334
+ key: directoryOp.key,
2335
+ previousValue: pendingKeySet.value,
2336
+ };
2337
+ this.emit("containedValueChanged", containedEvent, true, this);
2032
2338
  }
2033
-
2034
- this.rollbackPendingMessageId(this.pendingKeys, key, localOpMetadata.pendingMessageId);
2035
- } else if (op.type === "createSubDirectory" && localOpMetadata.type === "createSubDir") {
2036
- const subdirName: unknown = op.subdirName;
2339
+ } else if (
2340
+ directoryOp.type === "createSubDirectory" &&
2341
+ localOpMetadata.type === "createSubDir"
2342
+ ) {
2343
+ const subdirName: unknown = directoryOp.subdirName;
2037
2344
  assert(
2038
2345
  subdirName !== undefined,
2039
2346
  0x8af /* "subdirName" property is missing from "createSubDirectory" operation. */,
@@ -2045,8 +2352,11 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
2045
2352
 
2046
2353
  this.deleteSubDirectoryCore(subdirName, true);
2047
2354
  this.decrementPendingSubDirCount(this.pendingCreateSubDirectoriesTracker, subdirName);
2048
- } else if (op.type === "deleteSubDirectory" && localOpMetadata.type === "deleteSubDir") {
2049
- const subdirName: unknown = op.subdirName;
2355
+ } else if (
2356
+ directoryOp.type === "deleteSubDirectory" &&
2357
+ localOpMetadata.type === "deleteSubDir"
2358
+ ) {
2359
+ const subdirName: unknown = directoryOp.subdirName;
2050
2360
  assert(
2051
2361
  subdirName !== undefined,
2052
2362
  0x8b1 /* "subdirName" property is missing from "deleteSubDirectory" operation. */,
@@ -2079,8 +2389,6 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
2079
2389
  }
2080
2390
  }
2081
2391
 
2082
- /* eslint-enable @typescript-eslint/no-unsafe-member-access */
2083
-
2084
2392
  /**
2085
2393
  * Converts the given relative path into an absolute path.
2086
2394
  * @param path - Relative path to convert
@@ -2090,86 +2398,6 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
2090
2398
  return posix.resolve(this.absolutePath, relativePath);
2091
2399
  }
2092
2400
 
2093
- /**
2094
- * If our local operations that have not yet been ack'd will eventually overwrite an incoming operation, we should
2095
- * not process the incoming operation.
2096
- * @param op - Operation to check
2097
- * @param local - Whether the operation originated from the local client
2098
- * @param localOpMetadata - For local client ops, this is the metadata that was submitted with the op.
2099
- * For ops from a remote client, this will be undefined.
2100
- * @returns True if the operation should be processed, false otherwise
2101
- */
2102
- private needProcessStorageOperation(
2103
- op: IDirectoryKeyOperation,
2104
- local: boolean,
2105
- localOpMetadata: unknown,
2106
- ): boolean {
2107
- const firstPendingClearMessageId = this.pendingClearMessageIds[0];
2108
- if (firstPendingClearMessageId !== undefined) {
2109
- if (local) {
2110
- assert(
2111
- localOpMetadata !== undefined &&
2112
- isKeyEditLocalOpMetadata(localOpMetadata) &&
2113
- localOpMetadata.pendingMessageId < firstPendingClearMessageId,
2114
- 0x010 /* "Received out of order storage op when there is an unackd clear message" */,
2115
- );
2116
- // Remove all pendingMessageIds lower than first pendingClearMessageId.
2117
- const lowestPendingClearMessageId = firstPendingClearMessageId;
2118
- const pendingKeyMessageIdArray = this.pendingKeys.get(op.key);
2119
- if (pendingKeyMessageIdArray !== undefined) {
2120
- let index = 0;
2121
- let pendingKeyMessageId = pendingKeyMessageIdArray[index];
2122
- while (
2123
- pendingKeyMessageId !== undefined &&
2124
- pendingKeyMessageId < lowestPendingClearMessageId
2125
- ) {
2126
- index += 1;
2127
- pendingKeyMessageId = pendingKeyMessageIdArray[index];
2128
- }
2129
- const newPendingKeyMessageId = pendingKeyMessageIdArray.splice(index);
2130
- if (newPendingKeyMessageId.length === 0) {
2131
- this.pendingKeys.delete(op.key);
2132
- } else {
2133
- this.pendingKeys.set(op.key, newPendingKeyMessageId);
2134
- }
2135
- }
2136
- }
2137
-
2138
- // If I have a NACK clear, we can ignore all ops.
2139
- return false;
2140
- }
2141
-
2142
- const pendingKeyMessageIds = this.pendingKeys.get(op.key);
2143
- if (pendingKeyMessageIds !== undefined) {
2144
- // Found an NACK op, clear it from the directory if the latest sequence number in the directory
2145
- // match the message's and don't process the op.
2146
- if (local) {
2147
- assert(
2148
- localOpMetadata !== undefined && isKeyEditLocalOpMetadata(localOpMetadata),
2149
- 0x011 /* pendingMessageId is missing from the local client's operation */,
2150
- );
2151
- if (pendingKeyMessageIds[0] !== localOpMetadata.pendingMessageId) {
2152
- // TODO: AB#7742: Hitting this block indicates that the pending message Id received
2153
- // is not consistent with the "next" local op
2154
- this.logger.sendTelemetryEvent({
2155
- eventName: "unexpectedPendingMessage",
2156
- expectedPendingMessage: pendingKeyMessageIds[0],
2157
- actualPendingMessage: localOpMetadata.pendingMessageId,
2158
- expectedPendingMessagesLength: pendingKeyMessageIds.length,
2159
- });
2160
- }
2161
- pendingKeyMessageIds.shift();
2162
- if (pendingKeyMessageIds.length === 0) {
2163
- this.pendingKeys.delete(op.key);
2164
- }
2165
- }
2166
- return false;
2167
- }
2168
-
2169
- // If we don't have a NACK op on the key, we need to process the remote ops.
2170
- return !local;
2171
- }
2172
-
2173
2401
  /**
2174
2402
  * This return true if the message is for the current instance of this sub directory. As the sub directory
2175
2403
  * can be deleted and created again, then this finds if the message is for current instance of directory or not.
@@ -2200,7 +2428,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
2200
2428
  msg: ISequencedDocumentMessage,
2201
2429
  op: IDirectorySubDirectoryOperation,
2202
2430
  local: boolean,
2203
- localOpMetadata: unknown,
2431
+ localOpMetadata: SubDirLocalOpMetadata | undefined,
2204
2432
  ): boolean {
2205
2433
  assertNonNullClientId(msg.clientId);
2206
2434
  const pendingDeleteCount = this.pendingDeleteSubDirectoriesTracker.get(op.subdirName);
@@ -2210,10 +2438,7 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
2210
2438
  (pendingCreateCount !== undefined && pendingCreateCount > 0)
2211
2439
  ) {
2212
2440
  if (local) {
2213
- assert(
2214
- isSubDirLocalOpMetadata(localOpMetadata),
2215
- 0x012 /* pendingMessageId is missing from the local client's operation */,
2216
- );
2441
+ assert(localOpMetadata !== undefined, 0xc0d /* localOpMetadata should be defined */);
2217
2442
  if (localOpMetadata.type === "deleteSubDir") {
2218
2443
  assert(
2219
2444
  pendingDeleteCount !== undefined && pendingDeleteCount > 0,
@@ -2241,7 +2466,9 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
2241
2466
  }
2242
2467
  // If this is delete op and we have keys in this subDirectory, then we need to delete these
2243
2468
  // keys except the pending ones as they will be sequenced after this delete.
2244
- directory.clearExceptPendingKeys(local);
2469
+ directory.sequencedStorageData.clear();
2470
+ directory.emit("clear", true, directory);
2471
+
2245
2472
  // In case of delete op, we need to reset the creation seqNum, clientSeqNum and client ids of
2246
2473
  // creators as the previous directory is getting deleted and we will initialize again when
2247
2474
  // we will receive op for the create again.
@@ -2304,75 +2531,6 @@ class SubDirectory extends TypedEventEmitter<IDirectoryEvents> implements IDirec
2304
2531
  return !local;
2305
2532
  }
2306
2533
 
2307
- /**
2308
- * Clear all keys in memory in response to a remote clear, but retain keys we have modified but not yet been ack'd.
2309
- */
2310
- private clearExceptPendingKeys(local: boolean): void {
2311
- // Assuming the pendingKeys is small and the map is large
2312
- // we will get the value for the pendingKeys and clear the map
2313
- const temp = new Map<string, unknown>();
2314
-
2315
- for (const [key] of this.pendingKeys) {
2316
- const value = this._storage.get(key);
2317
- // If this key is already deleted, then we don't need to add it again.
2318
- if (value !== undefined) {
2319
- temp.set(key, value);
2320
- }
2321
- }
2322
-
2323
- this.clearCore(local);
2324
-
2325
- for (const [key, value] of temp.entries()) {
2326
- this.setCore(key, value, true);
2327
- }
2328
- }
2329
-
2330
- /**
2331
- * Clear implementation used for both locally sourced clears as well as incoming remote clears.
2332
- * @param local - Whether the message originated from the local client
2333
- */
2334
- private clearCore(local: boolean): void {
2335
- this._storage.clear();
2336
- this.directory.emit("clear", local, this.directory);
2337
- }
2338
-
2339
- /**
2340
- * Delete implementation used for both locally sourced deletes as well as incoming remote deletes.
2341
- * @param key - The key being deleted
2342
- * @param local - Whether the message originated from the local client
2343
- * @returns Previous local value of the key if it existed, undefined if it did not exist
2344
- */
2345
- private deleteCore(key: string, local: boolean): unknown {
2346
- const previousLocalValue = this._storage.get(key);
2347
- const previousValue: unknown = previousLocalValue;
2348
- const successfullyRemoved = this._storage.delete(key);
2349
- if (successfullyRemoved) {
2350
- const event: IDirectoryValueChanged = { key, path: this.absolutePath, previousValue };
2351
- this.directory.emit("valueChanged", event, local, this.directory);
2352
- const containedEvent: IValueChanged = { key, previousValue };
2353
- this.emit("containedValueChanged", containedEvent, local, this);
2354
- }
2355
- return previousLocalValue;
2356
- }
2357
-
2358
- /**
2359
- * Set implementation used for both locally sourced sets as well as incoming remote sets.
2360
- * @param key - The key being set
2361
- * @param value - The value being set
2362
- * @param local - Whether the message originated from the local client
2363
- * @returns Previous local value of the key, if any
2364
- */
2365
- private setCore(key: string, value: unknown, local: boolean): unknown {
2366
- const previousLocalValue = this._storage.get(key);
2367
- const previousValue: unknown = previousLocalValue;
2368
- this._storage.set(key, value);
2369
- const event: IDirectoryValueChanged = { key, path: this.absolutePath, previousValue };
2370
- this.directory.emit("valueChanged", event, local, this.directory);
2371
- const containedEvent: IValueChanged = { key, previousValue };
2372
- this.emit("containedValueChanged", containedEvent, local, this);
2373
- return previousLocalValue;
2374
- }
2375
-
2376
2534
  /**
2377
2535
  * Create subdirectory implementation used for both locally sourced creation as well as incoming remote creation.
2378
2536
  * @param subdirName - The name of the subdirectory being created