@fluidframework/merge-tree 2.10.0-306579 → 2.10.0-307399

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/api-report/merge-tree.legacy.alpha.api.md +27 -0
  2. package/dist/collections/index.d.ts +1 -1
  3. package/dist/collections/index.d.ts.map +1 -1
  4. package/dist/collections/index.js +2 -1
  5. package/dist/collections/index.js.map +1 -1
  6. package/dist/collections/list.d.ts +7 -0
  7. package/dist/collections/list.d.ts.map +1 -1
  8. package/dist/collections/list.js +27 -1
  9. package/dist/collections/list.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/legacy.d.ts +2 -0
  14. package/dist/mergeTree.d.ts.map +1 -1
  15. package/dist/mergeTree.js +10 -9
  16. package/dist/mergeTree.js.map +1 -1
  17. package/dist/ops.d.ts +36 -0
  18. package/dist/ops.d.ts.map +1 -1
  19. package/dist/ops.js.map +1 -1
  20. package/dist/segmentPropertiesManager.d.ts +81 -8
  21. package/dist/segmentPropertiesManager.d.ts.map +1 -1
  22. package/dist/segmentPropertiesManager.js +211 -61
  23. package/dist/segmentPropertiesManager.js.map +1 -1
  24. package/dist/test/propertyManager.spec.d.ts +6 -0
  25. package/dist/test/propertyManager.spec.d.ts.map +1 -0
  26. package/dist/test/propertyManager.spec.js +156 -0
  27. package/dist/test/propertyManager.spec.js.map +1 -0
  28. package/dist/zamboni.d.ts.map +1 -1
  29. package/dist/zamboni.js +1 -0
  30. package/dist/zamboni.js.map +1 -1
  31. package/lib/collections/index.d.ts +1 -1
  32. package/lib/collections/index.d.ts.map +1 -1
  33. package/lib/collections/index.js +1 -1
  34. package/lib/collections/index.js.map +1 -1
  35. package/lib/collections/list.d.ts +7 -0
  36. package/lib/collections/list.d.ts.map +1 -1
  37. package/lib/collections/list.js +25 -0
  38. package/lib/collections/list.js.map +1 -1
  39. package/lib/index.d.ts +2 -2
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js.map +1 -1
  42. package/lib/legacy.d.ts +2 -0
  43. package/lib/mergeTree.d.ts.map +1 -1
  44. package/lib/mergeTree.js +10 -9
  45. package/lib/mergeTree.js.map +1 -1
  46. package/lib/ops.d.ts +36 -0
  47. package/lib/ops.d.ts.map +1 -1
  48. package/lib/ops.js.map +1 -1
  49. package/lib/segmentPropertiesManager.d.ts +81 -8
  50. package/lib/segmentPropertiesManager.d.ts.map +1 -1
  51. package/lib/segmentPropertiesManager.js +212 -62
  52. package/lib/segmentPropertiesManager.js.map +1 -1
  53. package/lib/test/propertyManager.spec.d.ts +6 -0
  54. package/lib/test/propertyManager.spec.d.ts.map +1 -0
  55. package/lib/test/propertyManager.spec.js +154 -0
  56. package/lib/test/propertyManager.spec.js.map +1 -0
  57. package/lib/zamboni.d.ts.map +1 -1
  58. package/lib/zamboni.js +1 -0
  59. package/lib/zamboni.js.map +1 -1
  60. package/package.json +24 -17
  61. package/src/collections/index.ts +7 -1
  62. package/src/collections/list.ts +29 -0
  63. package/src/index.ts +3 -0
  64. package/src/mergeTree.ts +16 -11
  65. package/src/ops.ts +38 -0
  66. package/src/segmentPropertiesManager.ts +277 -88
  67. package/src/zamboni.ts +3 -0
@@ -3,13 +3,16 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
 
6
- /* eslint-disable @typescript-eslint/no-non-null-assertion */
7
-
8
6
  import { assert } from "@fluidframework/core-utils/internal";
9
7
 
8
+ import { DoublyLinkedList, iterateListValuesWhile } from "./collections/index.js";
10
9
  import { UnassignedSequenceNumber, UniversalSequenceNumber } from "./constants.js";
11
- import { IMergeTreeAnnotateMsg } from "./ops.js";
12
- import { MapLike, PropertySet, clone, createMap, extend } from "./properties.js";
10
+ import type {
11
+ AdjustParams,
12
+ IMergeTreeAnnotateAdjustMsg,
13
+ IMergeTreeAnnotateMsg,
14
+ } from "./ops.js";
15
+ import { MapLike, PropertySet, clone, createMap } from "./properties.js";
13
16
 
14
17
  /**
15
18
  * @internal
@@ -25,7 +28,6 @@ export enum PropertiesRollback {
25
28
  */
26
29
  Rollback,
27
30
  }
28
-
29
31
  /**
30
32
  * Minimally copies properties and the property manager from source to destination.
31
33
  * @internal
@@ -45,133 +47,320 @@ export function copyPropertiesAndManager(
45
47
  destination.properties = clone(source.properties);
46
48
  } else {
47
49
  destination.propertyManager ??= new PropertiesManager();
48
- destination.properties = source.propertyManager.copyTo(
49
- source.properties,
50
- destination.properties,
51
- destination.propertyManager,
52
- );
50
+ source.propertyManager.copyTo(source.properties, destination);
53
51
  }
54
52
  }
55
53
  }
56
54
 
55
+ type PropertyChange = {
56
+ seq: number;
57
+ } & ({ adjust: AdjustParams; raw?: undefined } | { raw: unknown; adjust?: undefined });
58
+
59
+ interface PropertyChanges {
60
+ msnConsensus: unknown;
61
+ remote: DoublyLinkedList<PropertyChange>;
62
+ local: DoublyLinkedList<PropertyChange>;
63
+ }
64
+
65
+ function computePropertyValue(
66
+ consensus: unknown,
67
+ ...changes: Iterable<PropertyChange>[]
68
+ ): unknown {
69
+ let computedValue: unknown = consensus;
70
+ for (const change of changes) {
71
+ for (const op of change) {
72
+ const { raw, adjust } = op;
73
+ if (adjust === undefined) {
74
+ computedValue = raw;
75
+ } else {
76
+ const adjusted =
77
+ (typeof computedValue === "number" ? computedValue : 0) + adjust.delta;
78
+ if (adjust.max !== undefined && adjusted > adjust.max) {
79
+ computedValue = adjust.max;
80
+ } else if (adjust.min !== undefined && adjusted < adjust.min) {
81
+ computedValue = adjust.min;
82
+ } else {
83
+ computedValue = adjusted;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return computedValue;
89
+ }
90
+
57
91
  /**
58
92
  * @internal
59
93
  */
60
- export class PropertiesManager {
61
- private pendingKeyUpdateCount: MapLike<number> | undefined;
94
+ export type PropsOrAdjust =
95
+ | Pick<IMergeTreeAnnotateAdjustMsg, "props" | "adjust">
96
+ | Pick<IMergeTreeAnnotateMsg, "props" | "adjust">;
97
+
98
+ const opToChanges = (op: PropsOrAdjust, seq: number): [string, PropertyChange][] => [
99
+ ...Object.entries(op.props ?? {})
100
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
101
+ .map<[string, PropertyChange]>(([k, raw]) => [k, { raw, seq }])
102
+ .filter(([_, v]) => v.raw !== undefined),
103
+ ...Object.entries(op.adjust ?? {}).map<[string, PropertyChange]>(([k, adjust]) => [
104
+ k,
105
+ { adjust, seq },
106
+ ]),
107
+ ];
62
108
 
63
- public ackPendingProperties(annotateOp: IMergeTreeAnnotateMsg): void {
64
- this.decrementPendingCounts(annotateOp.props);
109
+ function applyChanges(
110
+ op: PropsOrAdjust,
111
+ seg: { properties?: MapLike<unknown> },
112
+ seq: number,
113
+ run: (
114
+ properties: MapLike<unknown>,
115
+ deltas: MapLike<unknown>,
116
+ key: string,
117
+ value: PropertyChange,
118
+ ) => void,
119
+ ): MapLike<unknown> {
120
+ const properties = (seg.properties ??= createMap<unknown>());
121
+ const deltas: MapLike<unknown> = {};
122
+ for (const [key, value] of opToChanges(op, seq)) {
123
+ run(properties, deltas, key, value);
124
+ if (properties[key] === null) {
125
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
126
+ delete properties[key];
127
+ }
65
128
  }
129
+ return deltas;
130
+ }
66
131
 
67
- private decrementPendingCounts(props: PropertySet): void {
68
- for (const [key, value] of Object.entries(props)) {
69
- if (value !== undefined && this.pendingKeyUpdateCount?.[key] !== undefined) {
132
+ /**
133
+ * The PropertiesManager class handles changes to properties, both remote and local.
134
+ * It manages the lifecycle for local property changes, ensures all property changes are eventually consistent,
135
+ * and provides methods to acknowledge changes, update the minimum sequence number (msn), and copy properties to another manager.
136
+ * This class is essential for maintaining the integrity and consistency of property changes in collaborative environments.
137
+ * @internal
138
+ */
139
+ export class PropertiesManager {
140
+ private readonly changes = new Map<string, PropertyChanges>();
141
+
142
+ /**
143
+ * Rolls back local property changes.
144
+ * This method reverts property changes based on the provided operation and segment.
145
+ * If the operation is part of a collaborative session, it ensures that the changes are consistent with the remote state.
146
+ *
147
+ * @param op - The operation containing property changes. This can be an adjustment or a set of properties.
148
+ * @param seg - The segment containing properties. This object may have a properties map that will be modified.
149
+ * @param collaborating - Indicates if the operation is part of a collaborative session. Defaults to false.
150
+ * @returns The deltas of the rolled-back properties. This is a map-like object representing the changes that were reverted.
151
+ */
152
+ public rollbackProperties(
153
+ op: PropsOrAdjust,
154
+ seg: { properties?: MapLike<unknown> },
155
+ collaborating: boolean = false,
156
+ ): MapLike<unknown> {
157
+ return applyChanges(op, seg, UniversalSequenceNumber, (properties, deltas, key, value) => {
158
+ // eslint-disable-next-line unicorn/no-null
159
+ const previousValue = properties[key] ?? null;
160
+
161
+ const pending = this.changes.get(key);
162
+ if (collaborating) {
70
163
  assert(
71
- this.pendingKeyUpdateCount[key]! > 0,
72
- 0x05c /* "Trying to update more annotate props than do exist!" */,
164
+ pending !== undefined,
165
+ "Pending changes must exist for rollback when collaborating",
166
+ );
167
+ pending.local.pop();
168
+ properties[key] = computePropertyValue(
169
+ pending.msnConsensus,
170
+ pending.remote.map((n) => n.data),
171
+ pending.local.map((n) => n.data),
73
172
  );
74
- this.pendingKeyUpdateCount[key]--;
75
- if (this.pendingKeyUpdateCount?.[key] === 0) {
76
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
77
- delete this.pendingKeyUpdateCount[key];
173
+ if (pending.local.empty && pending.remote.empty) {
174
+ this.changes.delete(key);
78
175
  }
176
+ } else {
177
+ assert(pending === undefined, "Pending changes must not exist when not collaborating");
178
+ properties[key] = computePropertyValue(previousValue, [value]);
79
179
  }
80
- }
180
+ deltas[key] = previousValue;
181
+ });
81
182
  }
82
183
 
83
- public addProperties(
84
- oldProps: PropertySet,
85
- newProps: PropertySet,
86
- seq?: number,
184
+ /**
185
+ * Handles property changes.
186
+ * This method applies property changes based on the provided operation, segment, sequence number, and collaboration state.
187
+ * It also handles rolling back changes if specified.
188
+ *
189
+ * @param op - The operation containing property changes.
190
+ * @param seg - The segment containing properties.
191
+ * @param seq - The sequence number for the operation.
192
+ * @param msn - The minimum sequence number for the operation.
193
+ * @param collaborating - Indicates if the operation is part of a collaborative session. Defaults to false.
194
+ * @param rollback - Specifies if the changes should be rolled back. Defaults to PropertiesRollback.None.
195
+ * @returns The deltas of the applied or rolled-back properties. This is a map-like object representing the changes.
196
+ */
197
+ public handleProperties(
198
+ op: PropsOrAdjust,
199
+ seg: { properties?: MapLike<unknown> },
200
+ seq: number,
201
+ msn: number,
87
202
  collaborating: boolean = false,
88
203
  rollback: PropertiesRollback = PropertiesRollback.None,
89
- ): PropertySet {
90
- this.pendingKeyUpdateCount ??= createMap<number>();
91
-
92
- // Clean up counts for rolled back edits before modifying oldProps
93
- if (collaborating && rollback === PropertiesRollback.Rollback) {
94
- this.decrementPendingCounts(newProps);
204
+ ): MapLike<unknown> {
205
+ if (rollback === PropertiesRollback.Rollback) {
206
+ return this.rollbackProperties(op, seg, collaborating);
95
207
  }
96
-
97
- const shouldModifyKey = (key: string): boolean => {
98
- if (
99
- seq === UnassignedSequenceNumber ||
100
- seq === UniversalSequenceNumber ||
101
- this.pendingKeyUpdateCount?.[key] === undefined
102
- ) {
103
- return true;
104
- }
105
- return false;
106
- };
107
-
108
- const deltas: PropertySet = {};
109
-
110
- for (const [key, newValue] of Object.entries(newProps)) {
111
- if (newValue === undefined) {
112
- continue;
113
- }
114
-
208
+ const rtn = applyChanges(op, seg, seq, (properties, deltas, key, value) => {
209
+ // eslint-disable-next-line unicorn/no-null
210
+ const previousValue = properties[key] ?? null;
115
211
  if (collaborating) {
116
- if (seq === UnassignedSequenceNumber) {
117
- if (this.pendingKeyUpdateCount?.[key] === undefined) {
118
- this.pendingKeyUpdateCount[key] = 0;
212
+ const pending: PropertyChanges | undefined = this.changes.get(key) ?? {
213
+ msnConsensus: previousValue,
214
+ remote: new DoublyLinkedList(),
215
+ local: new DoublyLinkedList(),
216
+ };
217
+ this.changes.set(key, pending);
218
+ const local = seq === UnassignedSequenceNumber;
219
+ if (local) {
220
+ pending.local.push(value);
221
+ } else {
222
+ // we only track remotes if there are adjusts, as only adjusts make application anti-commutative
223
+ // this will limit the impact of this change to only those using adjusts. Additionally, we only
224
+ // need to track remotes at all to support emitting the legacy snapshot format, which only sharedstring
225
+ // uses. when we remove the ability to emit that format, we can remove all remote op tracking
226
+ if (value.raw !== undefined && pending.remote.empty) {
227
+ pending.msnConsensus = computePropertyValue(pending.msnConsensus, [value]);
228
+ } else {
229
+ pending.remote.push(value);
119
230
  }
120
- this.pendingKeyUpdateCount[key]++;
121
- } else if (!shouldModifyKey(key)) {
122
- continue;
123
231
  }
232
+ properties[key] = computePropertyValue(
233
+ pending.msnConsensus,
234
+ pending.remote.map((n) => n.data),
235
+ pending.local.map((n) => n.data),
236
+ );
237
+ if (local || pending.local.empty || properties[key] !== previousValue) {
238
+ deltas[key] = previousValue;
239
+ }
240
+ } else {
241
+ properties[key] = computePropertyValue(previousValue, [value]);
242
+ deltas[key] = previousValue;
124
243
  }
244
+ });
245
+ this.updateMsn(msn);
246
+ return rtn;
247
+ }
125
248
 
126
- const previousValue: unknown = oldProps[key];
127
- // The delta should be null if undefined, as that's how we encode delete
128
- // eslint-disable-next-line unicorn/no-null
129
- deltas[key] = previousValue === undefined ? null : previousValue;
130
- if (newValue === null) {
131
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
132
- delete oldProps[key];
249
+ /**
250
+ * Acknowledges property changes.
251
+ * This method acknowledges the property changes based on the provided sequence number and operation.
252
+ *
253
+ * @param seq - The sequence number for the operation.
254
+ * @param msn - The minimum sequence number for the operation.
255
+ * @param op - The operation containing property changes.
256
+ */
257
+ public ack(seq: number, msn: number, op: PropsOrAdjust): void {
258
+ for (const [key, value] of opToChanges(op, seq)) {
259
+ const change = this.changes.get(key);
260
+ const acked = change?.local?.shift();
261
+ assert(change !== undefined && acked !== undefined, "must have local change to ack");
262
+ // we only track remotes if there are adjusts, as only adjusts make application anti-commutative
263
+ // this will limit the impact of this change to only those using adjusts. Additionally, we only
264
+ // need to track remotes at all to support emitting the legacy snapshot format, which only sharedstring
265
+ // uses. when we remove the ability to emit that format, we can remove all remote op tracking
266
+ if (value.raw !== undefined && change.remote.empty) {
267
+ change.msnConsensus = computePropertyValue(change.msnConsensus, [value]);
133
268
  } else {
134
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
135
- oldProps[key] = newValue;
269
+ change.remote.push(value);
136
270
  }
137
271
  }
272
+ this.updateMsn(msn);
273
+ }
138
274
 
139
- return deltas;
275
+ /**
276
+ * Updates the minimum sequence number (msn).
277
+ * This method updates the minimum sequence number and removes any changes that have been acknowledged.
278
+ *
279
+ * @param msn - The minimum sequence number to update.
280
+ */
281
+ public updateMsn(msn: number): void {
282
+ for (const [key, pending] of this.changes) {
283
+ pending.msnConsensus = computePropertyValue(
284
+ pending.msnConsensus,
285
+ iterateListValuesWhile(pending.remote.first, (n) => {
286
+ if (n.data.seq <= msn) {
287
+ n.list?.remove(n);
288
+ return true;
289
+ }
290
+ return false;
291
+ }),
292
+ );
293
+ if (pending.local.empty && pending.remote.empty) {
294
+ this.changes.delete(key);
295
+ }
296
+ }
140
297
  }
141
298
 
299
+ /**
300
+ * Copies properties to another manager.
301
+ * This method copies the properties and their changes from the current manager to the destination manager.
302
+ *
303
+ * @param oldProps - The old properties to be copied.
304
+ * @param dest - The destination object containing properties and property manager.
305
+ */
142
306
  public copyTo(
143
- oldProps: PropertySet,
144
- newProps: PropertySet | undefined,
145
- newManager: PropertiesManager,
146
- ): PropertySet | undefined {
147
- if (oldProps) {
148
- // eslint-disable-next-line no-param-reassign
149
- newProps ??= createMap<unknown>();
150
- if (!newManager) {
151
- throw new Error("Must provide new PropertyManager");
152
- }
153
- extend(newProps, oldProps);
307
+ oldProps: PropertySet | undefined,
308
+ dest: {
309
+ properties?: PropertySet;
310
+ propertyManager?: PropertiesManager;
311
+ },
312
+ ): void {
313
+ const newManager = (dest.propertyManager ??= new PropertiesManager());
314
+ dest.properties = clone(oldProps);
315
+ for (const [key, { local, remote, msnConsensus }] of this.changes.entries()) {
316
+ newManager.changes.set(key, {
317
+ msnConsensus,
318
+ remote: new DoublyLinkedList(remote.empty ? undefined : remote.map((c) => c.data)),
319
+ local: new DoublyLinkedList(local.empty ? undefined : local.map((c) => c.data)),
320
+ });
321
+ }
322
+ }
154
323
 
155
- if (this.pendingKeyUpdateCount) {
156
- newManager.pendingKeyUpdateCount = clone(this.pendingKeyUpdateCount);
324
+ /**
325
+ * Gets properties at a specific sequence number.
326
+ * This method retrieves the properties at the given sequence number.
327
+ * This is only needed to support emitting snapshots in the legacy format.
328
+ * If we remove the ability to emit the legacy format, we can remove this method, along with the need to track remote changes at all.
329
+ *
330
+ * @param oldProps - The old properties to be retrieved.
331
+ * @param sequenceNumber - The sequence number to get properties at.
332
+ * @returns The properties at the given sequence number.
333
+ */
334
+ public getAtSeq(
335
+ oldProps: MapLike<unknown> | undefined,
336
+ sequenceNumber: number,
337
+ ): MapLike<unknown> {
338
+ const properties: MapLike<unknown> = { ...oldProps };
339
+ for (const [key, changes] of this.changes) {
340
+ properties[key] = computePropertyValue(
341
+ changes.msnConsensus,
342
+ iterateListValuesWhile(changes.remote.first, (c) => c.data.seq <= sequenceNumber),
343
+ );
344
+ if (properties[key] === null) {
345
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
346
+ delete properties[key];
157
347
  }
158
348
  }
159
- return newProps;
349
+ return properties;
160
350
  }
161
351
 
162
352
  /**
163
353
  * Determines if all of the defined properties in a given property set are pending.
354
+ *
355
+ * @param props - The properties to check.
356
+ * @returns True if all the properties are pending, false otherwise.
164
357
  */
165
358
  public hasPendingProperties(props: PropertySet): boolean {
166
359
  for (const [key, value] of Object.entries(props)) {
167
- if (value !== undefined && this.pendingKeyUpdateCount?.[key] === undefined) {
360
+ if (value !== undefined && this.changes.get(key)?.local.empty !== false) {
168
361
  return false;
169
362
  }
170
363
  }
171
364
  return true;
172
365
  }
173
-
174
- public hasPendingProperty(key: string): boolean {
175
- return (this.pendingKeyUpdateCount?.[key] ?? 0) > 0;
176
- }
177
366
  }
package/src/zamboni.ts CHANGED
@@ -35,6 +35,9 @@ export function zamboniSegments(
35
35
 
36
36
  for (let i = 0; i < zamboniSegmentsMaxCount; i++) {
37
37
  let segmentToScour = mergeTree.segmentsToScour.peek()?.value;
38
+
39
+ segmentToScour?.segment?.propertyManager?.updateMsn(mergeTree.collabWindow.minSeq);
40
+
38
41
  if (!segmentToScour || segmentToScour.maxSeq > mergeTree.collabWindow.minSeq) {
39
42
  break;
40
43
  }