SharedString DDS annotateAdjustRange (#22751)

This update introduces a new feature to the `SharedString` DDS, allowing
for the adjustment of properties over a specified range. The
`annotateAdjustRange` method enables users to apply adjustments to
properties within a given range, providing more flexibility and control
over property modifications.

An adjustment is a modification applied to a property value within a
specified range. Adjustments can be used to increment or decrement
property values dynamically. They are particularly useful in scenarios
where property values need to be updated based on user interactions or
other events. For example, in a rich text editor, adjustments can be
used for modifying indentation levels or font sizes, where multiple
users could apply differing numerical adjustments.

### Key Features and Use Cases:
- **Adjustments with Constraints**: Adjustments can include optional
minimum and maximum constraints to ensure the final value falls within
specified bounds. This is particularly useful for maintaining consistent
formatting in rich text editors.
- **Consistent Property Changes**: The feature ensures that property
changes are consistent, managing both local and remote changes
effectively. This is essential for collaborative rich text editing where
multiple users may be making adjustments simultaneously.
- **Rich Text Formatting**: Adjustments can be used to modify text
properties such as font size, indentation, or other formatting
attributes dynamically based on user actions.

### Configuration and Compatibility Requirements:
This feature is only available when the configuration
`Fluid.Sequence.mergeTreeEnableAnnotateAdjust` is set to `true`.
Additionally, all collaborating clients must have this feature enabled
to use it. If any client does not have this feature enabled, it will
lead to the client exiting collaboration. A future major version of
Fluid will enable this feature by default.

### Usage Example:
```typescript
sharedString.annotateAdjustRange(start, end, {
    key: { value: 5, min: 0, max: 10 }
});
```


[AB#11819](https://dev.azure.com/fluidframework/235294da-091d-4c29-84fc-cdfc3d90890b/_workitems/edit/11819)

---------

Co-authored-by: Tyler Butler <tylerbu@microsoft.com>
This commit is contained in:
Tony Murphy 2024-11-15 13:21:18 -08:00 коммит произвёл GitHub
Родитель 65d9ac3dc3
Коммит d54b9dde14
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
29 изменённых файлов: 514 добавлений и 110 удалений

Просмотреть файл

@ -0,0 +1,30 @@
---
"fluid-framework": minor
"@fluidframework/merge-tree": minor
"@fluidframework/sequence": minor
"@fluidframework/undo-redo": minor
---
---
"section": feature
---
### SharedString DDS annotateAdjustRange
This update introduces a new feature to the `SharedString` DDS, allowing for the adjustment of properties over a specified range. The `annotateAdjustRange` method enables users to apply adjustments to properties within a given range, providing more flexibility and control over property modifications.
An adjustment is a modification applied to a property value within a specified range. Adjustments can be used to increment or decrement property values dynamically. They are particularly useful in scenarios where property values need to be updated based on user interactions or other events. For example, in a rich text editor, adjustments can be used for modifying indentation levels or font sizes, where multiple users could apply differing numerical adjustments.
### Key Features and Use Cases:
- **Adjustments with Constraints**: Adjustments can include optional minimum and maximum constraints to ensure the final value falls within specified bounds. This is particularly useful for maintaining consistent formatting in rich text editors.
- **Consistent Property Changes**: The feature ensures that property changes are consistent, managing both local and remote changes effectively. This is essential for collaborative rich text editing where multiple users may be making adjustments simultaneously.
- **Rich Text Formatting**: Adjustments can be used to modify text properties such as font size, indentation, or other formatting attributes dynamically based on user actions.
### Configuration and Compatibility Requirements:
This feature is only available when the configuration `Fluid.Sequence.mergeTreeEnableAnnotateAdjust` is set to `true`. Additionally, all collaborating clients must have this feature enabled to use it. If any client does not have this feature enabled, it will lead to the client exiting collaboration. A future major version of Fluid will enable this feature by default.
### Usage Example:
```typescript
sharedString.annotateAdjustRange(start, end, {
key: { value: 5, min: 0, max: 10 }
});
```

Просмотреть файл

@ -216,7 +216,7 @@ export interface IMergeTreeDeltaCallbackArgs<TOperationType extends MergeTreeDel
}
// @alpha (undocumented)
export type IMergeTreeDeltaOp = IMergeTreeInsertMsg | IMergeTreeRemoveMsg | IMergeTreeAnnotateMsg | IMergeTreeObliterateMsg | IMergeTreeObliterateSidedMsg;
export type IMergeTreeDeltaOp = IMergeTreeInsertMsg | IMergeTreeRemoveMsg | IMergeTreeAnnotateMsg | IMergeTreeAnnotateAdjustMsg | IMergeTreeObliterateMsg | IMergeTreeObliterateSidedMsg;
// @alpha (undocumented)
export interface IMergeTreeDeltaOpArgs {
@ -290,6 +290,7 @@ export type IMergeTreeOp = IMergeTreeDeltaOp | IMergeTreeGroupMsg;
export interface IMergeTreeOptions {
// (undocumented)
catchUpBlobName?: string;
mergeTreeEnableAnnotateAdjust?: boolean;
mergeTreeEnableObliterate?: boolean;
mergeTreeEnableObliterateReconnect?: boolean;
mergeTreeEnableSidedObliterate?: boolean;

Просмотреть файл

@ -250,6 +250,9 @@
"TypeAlias_Trackable": {
"backCompat": false
},
"Class_Client": {
"forwardCompat": false
},
"Class_PropertiesManager": {
"forwardCompat": false,
"backCompat": false

Просмотреть файл

@ -152,7 +152,7 @@ function createPropertyTrackingMergeTreeCallbacks(
const shouldAttributeAnnotate =
op.type === MergeTreeDeltaType.ANNOTATE &&
// Only attribute annotations which change the tracked property
op.props[propName] !== undefined &&
(op.props?.[propName] !== undefined || op.adjust?.[propName] !== undefined) &&
(isLocal || (propertyDeltas !== undefined && propName in propertyDeltas));
if (shouldAttributeInsert || shouldAttributeAnnotate) {

Просмотреть файл

@ -54,6 +54,7 @@ import {
toMoveInfo,
} from "./mergeTreeNodes.js";
import {
createAdjustRangeOp,
createAnnotateMarkerOp,
createAnnotateRangeOp,
// eslint-disable-next-line import/no-deprecated
@ -77,9 +78,11 @@ import {
IRelativePosition,
MergeTreeDeltaType,
ReferenceType,
type AdjustParams,
type IMergeTreeAnnotateAdjustMsg,
type IMergeTreeObliterateSidedMsg,
} from "./ops.js";
import { PropertySet } from "./properties.js";
import { PropertySet, type MapLike } from "./properties.js";
import { DetachedReferencePosition, ReferencePosition } from "./referencePositions.js";
import { Side, type InteriorSequencePlace } from "./sequencePlace.js";
import { SnapshotLoader } from "./snapshotLoader.js";
@ -245,6 +248,26 @@ export class Client extends TypedEventEmitter<IClientEvents> {
return annotateOp;
}
/**
* adjusts a value
*/
public annotateAdjustRangeLocal(
start: number,
end: number,
adjust: MapLike<AdjustParams>,
): IMergeTreeAnnotateAdjustMsg {
const annotateOp = createAdjustRangeOp(start, end, adjust);
for (const [key, value] of Object.entries(adjust)) {
if (value.min !== undefined && value.max !== undefined && value.min > value.max) {
throw new UsageError(`min is greater than max for ${key}`);
}
}
this.applyAnnotateRangeOp({ op: annotateOp });
return annotateOp;
}
/**
* Removes the range
*
@ -564,7 +587,7 @@ export class Client extends TypedEventEmitter<IClientEvents> {
this._mergeTree.annotateRange(
range.start,
range.end,
op.props,
op,
clientArgs.referenceSequenceNumber,
clientArgs.clientId,
clientArgs.sequenceNumber,
@ -686,6 +709,7 @@ export class Client extends TypedEventEmitter<IClientEvents> {
private getValidOpRange(
op:
| IMergeTreeAnnotateMsg
| IMergeTreeAnnotateAdjustMsg
| IMergeTreeInsertMsg
| IMergeTreeRemoveMsg
// eslint-disable-next-line import/no-deprecated
@ -767,11 +791,7 @@ export class Client extends TypedEventEmitter<IClientEvents> {
| ISequencedDocumentMessage
| Pick<ISequencedDocumentMessage, "referenceSequenceNumber" | "clientId">
| undefined,
): {
clientId: number;
referenceSequenceNumber: number;
sequenceNumber: number;
} {
): IMergeTreeClientSequenceArgs {
// If there this no sequenced message, then the op is local
// and unacked, so use this clients sequenced args
//
@ -907,7 +927,8 @@ export class Client extends TypedEventEmitter<IClientEvents> {
switch (resetOp.type) {
case MergeTreeDeltaType.ANNOTATE: {
assert(
segment.propertyManager?.hasPendingProperties(resetOp.props) === true,
segment.propertyManager?.hasPendingProperties(resetOp.props ?? resetOp.adjust) ===
true,
0x036 /* "Segment has no pending properties" */,
);
// if the segment has been removed or obliterated, there's no need to send the annotate op
@ -921,11 +942,18 @@ export class Client extends TypedEventEmitter<IClientEvents> {
(segment.localMovedSeq !== undefined &&
segment.movedSeq === UnassignedSequenceNumber))
) {
newOp = createAnnotateRangeOp(
segmentPosition,
segmentPosition + segment.cachedLength,
resetOp.props,
);
newOp =
resetOp.props === undefined
? createAdjustRangeOp(
segmentPosition,
segmentPosition + segment.cachedLength,
resetOp.adjust,
)
: createAnnotateRangeOp(
segmentPosition,
segmentPosition + segment.cachedLength,
resetOp.props,
);
}
break;
}

Просмотреть файл

@ -94,6 +94,7 @@ import {
copyPropertiesAndManager,
PropertiesManager,
PropertiesRollback,
type PropsOrAdjust,
} from "./segmentPropertiesManager.js";
import { Side, type InteriorSequencePlace } from "./sequencePlace.js";
import { SortedSegmentSet } from "./sortedSegmentSet.js";
@ -277,6 +278,14 @@ export interface IMergeTreeOptions {
* @defaultValue `false`
*/
mergeTreeEnableSidedObliterate?: boolean;
/**
* Enables support for annotate adjust operations, which allow for specifying
* a summand which is summed with the current value to compute the new value.
*
* @defaultValue `false`
*/
mergeTreeEnableAnnotateAdjust?: boolean;
}
/**
@ -1895,7 +1904,7 @@ export class MergeTree {
* Annotate a range with properties
* @param start - The inclusive start position of the range to annotate
* @param end - The exclusive end position of the range to annotate
* @param props - The properties to annotate the range with
* @param propsOrAdjust - The properties or adjustments to annotate the range with
* @param refSeq - The reference sequence number to use to apply the annotate
* @param clientId - The id of the client making the annotate
* @param seq - The sequence number of the annotate operation
@ -1905,15 +1914,18 @@ export class MergeTree {
public annotateRange(
start: number,
end: number,
props: PropertySet,
propsOrAdjust: PropsOrAdjust,
refSeq: number,
clientId: number,
seq: number,
opArgs: IMergeTreeDeltaOpArgs,
// eslint-disable-next-line import/no-deprecated
rollback: PropertiesRollback = PropertiesRollback.None,
): void {
if (propsOrAdjust.adjust !== undefined) {
errorIfOptionNotTrue(this.options, "mergeTreeEnableAnnotateAdjust");
}
this.ensureIntervalBoundary(start, refSeq, clientId);
this.ensureIntervalBoundary(end, refSeq, clientId);
const deltaSegments: IMergeTreeSegmentDelta[] = [];
@ -1921,17 +1933,18 @@ export class MergeTree {
seq === UnassignedSequenceNumber ? ++this.collabWindow.localSeq : undefined;
// eslint-disable-next-line import/no-deprecated
let segmentGroup: SegmentGroup | undefined;
const opObj = propsOrAdjust.props ?? propsOrAdjust.adjust;
const annotateSegment = (segment: ISegmentLeaf): boolean => {
assert(
!Marker.is(segment) ||
!(reservedMarkerIdKey in props) ||
props.markerId === segment.properties?.markerId,
!(reservedMarkerIdKey in opObj) ||
opObj.markerId === segment.properties?.markerId,
0x5ad /* Cannot change the markerId of an existing marker */,
);
const propertyManager = (segment.propertyManager ??= new PropertiesManager());
const propertyDeltas = propertyManager.handleProperties(
{ props },
propsOrAdjust,
segment,
seq,
this.collabWindow.minSeq,
@ -2409,12 +2422,11 @@ export class MergeTree {
this.annotateRange(
start,
start + segment.cachedLength,
props,
{ props },
UniversalSequenceNumber,
this.collabWindow.clientId,
UniversalSequenceNumber,
{ op: annotateOp },
PropertiesRollback.Rollback,
);
i++;

Просмотреть файл

@ -14,9 +14,11 @@ import {
IMergeTreeObliterateMsg,
IMergeTreeRemoveMsg,
MergeTreeDeltaType,
type AdjustParams,
type IMergeTreeAnnotateAdjustMsg,
type IMergeTreeObliterateSidedMsg,
} from "./ops.js";
import { PropertySet } from "./properties.js";
import { PropertySet, type MapLike } from "./properties.js";
import { normalizePlace, Side, type SequencePlace } from "./sequencePlace.js";
/**
@ -66,6 +68,28 @@ export function createAnnotateRangeOp(
};
}
/**
* Creates the op for annotating the range with the provided properties
* @param start - The inclusive start position of the range to annotate
* @param end - The exclusive end position of the range to annotate
* @param props - The properties to annotate the range with
* @returns The annotate op
*
* @internal
*/
export function createAdjustRangeOp(
start: number,
end: number,
adjust: MapLike<AdjustParams>,
): IMergeTreeAnnotateAdjustMsg {
return {
pos1: start,
pos2: end,
adjust: { ...adjust },
type: MergeTreeDeltaType.ANNOTATE,
};
}
/**
* Creates the op to remove a range
*

Просмотреть файл

@ -265,6 +265,7 @@ export type IMergeTreeDeltaOp =
| IMergeTreeInsertMsg
| IMergeTreeRemoveMsg
| IMergeTreeAnnotateMsg
| IMergeTreeAnnotateAdjustMsg
| IMergeTreeObliterateMsg
| IMergeTreeObliterateSidedMsg;

Просмотреть файл

@ -18,7 +18,7 @@ import {
import { NonCollabClient, UnassignedSequenceNumber } from "./constants.js";
import { MergeTree } from "./mergeTree.js";
import { ISegment } from "./mergeTreeNodes.js";
import { ISegment, type ISegmentLeaf } from "./mergeTreeNodes.js";
import { matchProperties } from "./properties.js";
import {
JsonSegmentSpecs,
@ -193,7 +193,7 @@ export class SnapshotLegacy {
extractSync(): ISegment[] {
const collabWindow = this.mergeTree.collabWindow;
this.seq = collabWindow.minSeq;
const seq = (this.seq = collabWindow.minSeq);
this.header = {
segmentsTotalLength: this.mergeTree.getLength(
this.mergeTree.collabWindow.minSeq,
@ -205,9 +205,9 @@ export class SnapshotLegacy {
let originalSegments = 0;
const segs: ISegment[] = [];
let prev: ISegment | undefined;
let prev: ISegmentLeaf | undefined;
const extractSegment = (
segment: ISegment,
segment: ISegmentLeaf,
pos: number,
refSeq: number,
clientId: number,
@ -216,29 +216,26 @@ export class SnapshotLegacy {
): boolean => {
if (
segment.seq !== UnassignedSequenceNumber &&
segment.seq! <= this.seq! &&
segment.seq! <= seq &&
(segment.removedSeq === undefined ||
segment.removedSeq === UnassignedSequenceNumber ||
segment.removedSeq > this.seq!)
segment.removedSeq > seq)
) {
originalSegments += 1;
if (prev?.canAppend(segment) && matchProperties(prev.properties, segment.properties)) {
prev = prev.clone();
const properties =
segment.propertyManager?.getAtSeq(segment.properties, seq) ?? segment.properties;
if (prev?.canAppend(segment) && matchProperties(prev.properties, properties)) {
prev.append(segment.clone());
} else {
if (prev) {
segs.push(prev);
}
prev = segment;
prev = segment.clone();
prev.properties = properties;
segs.push(prev);
}
}
return true;
};
this.mergeTree.mapRange(extractSegment, this.seq, NonCollabClient, undefined);
if (prev) {
segs.push(prev);
}
this.segments = [];
let totalLength: number = 0;

Просмотреть файл

@ -7,7 +7,9 @@
import { strict as assert } from "node:assert";
import { FluidErrorTypes } from "@fluidframework/core-interfaces/internal";
import { ISequencedDocumentMessage } from "@fluidframework/driver-definitions/internal";
import { isFluidError } from "@fluidframework/telemetry-utils/internal";
import { UnassignedSequenceNumber } from "../constants.js";
import { walkAllChildSegments } from "../mergeTreeNodeWalk.js";
@ -692,6 +694,200 @@ describe("client.applyMsg", () => {
logger.validate({ baseText: "DDDDDDcbD" });
});
describe("annotateRangeAdjust", () => {
it("validate local and remote adjust combine", () => {
const clients = createClientsAtInitialState(
{
initialState: "0123456789",
options: { mergeTreeEnableAnnotateAdjust: true },
},
"A",
"B",
);
let seq = 0;
const logger = new TestClientLogger(clients.all);
const ops: ISequencedDocumentMessage[] = [];
ops.push(
clients.A.makeOpMessage(
clients.A.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 1,
},
}),
seq++,
),
clients.B.makeOpMessage(
clients.B.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 1,
},
}),
seq++,
),
);
for (const op of ops.splice(0))
for (const c of clients.all) {
c.applyMsg(op);
}
assert.deepStrictEqual({ ...clients.A.getPropertiesAtPosition(2) }, { key: 2 });
assert.deepStrictEqual({ ...clients.B.getPropertiesAtPosition(2) }, { key: 2 });
logger.validate({ baseText: "0123456789" });
});
it("validate local and remote adjust combine with min", () => {
const clients = createClientsAtInitialState(
{
initialState: "0123456789",
options: { mergeTreeEnableAnnotateAdjust: true },
},
"A",
"B",
);
let seq = 0;
const logger = new TestClientLogger(clients.all);
const ops: ISequencedDocumentMessage[] = [];
ops.push(
clients.A.makeOpMessage(
clients.A.annotateAdjustRangeLocal(1, 3, {
key: {
delta: -1,
},
}),
seq++,
),
clients.B.makeOpMessage(
clients.B.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 1,
min: 0,
},
}),
seq++,
),
);
for (const op of ops.splice(0))
for (const c of clients.all) {
c.applyMsg(op);
}
assert.deepStrictEqual({ ...clients.A.getPropertiesAtPosition(2) }, { key: 0 });
assert.deepStrictEqual({ ...clients.B.getPropertiesAtPosition(2) }, { key: 0 });
logger.validate({ baseText: "0123456789" });
});
it("validate local and remote adjust combine with max", () => {
const clients = createClientsAtInitialState(
{
initialState: "0123456789",
options: { mergeTreeEnableAnnotateAdjust: true },
},
"A",
"B",
);
let seq = 0;
const logger = new TestClientLogger(clients.all);
const ops: ISequencedDocumentMessage[] = [];
ops.push(
clients.A.makeOpMessage(
clients.A.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 1,
},
}),
seq++,
),
clients.B.makeOpMessage(
clients.B.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 1,
max: 1,
},
}),
seq++,
),
);
for (const op of ops.splice(0))
for (const c of clients.all) {
c.applyMsg(op);
}
assert.deepStrictEqual({ ...clients.A.getPropertiesAtPosition(2) }, { key: 1 });
assert.deepStrictEqual({ ...clients.B.getPropertiesAtPosition(2) }, { key: 1 });
logger.validate({ baseText: "0123456789" });
});
it("validate local and remote adjust combine with min and max", () => {
const clients = createClientsAtInitialState(
{
initialState: "0123456789",
options: { mergeTreeEnableAnnotateAdjust: true },
},
"A",
"B",
);
let seq = 0;
const logger = new TestClientLogger(clients.all);
const ops: ISequencedDocumentMessage[] = [];
ops.push(
clients.A.makeOpMessage(
clients.A.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 1,
},
}),
seq++,
),
clients.B.makeOpMessage(
clients.B.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 0,
max: 0,
min: 0,
},
}),
seq++,
),
);
for (const op of ops.splice(0))
for (const c of clients.all) {
c.applyMsg(op);
}
assert.deepStrictEqual({ ...clients.A.getPropertiesAtPosition(2) }, { key: 0 });
assert.deepStrictEqual({ ...clients.B.getPropertiesAtPosition(2) }, { key: 0 });
logger.validate({ baseText: "0123456789" });
});
it("validate min must be less than max", () => {
const clients = createClientsAtInitialState(
{
initialState: "0123456789",
options: { mergeTreeEnableAnnotateAdjust: true },
},
"A",
);
try {
clients.A.annotateAdjustRangeLocal(1, 3, {
key: {
delta: 1,
max: 1,
min: 2,
},
});
assert.fail("should fail");
} catch (error: unknown) {
assert(isFluidError(error));
assert.equal(error.errorType, FluidErrorTypes.usageError);
}
});
});
describe("obliterate", () => {
// op types: 0) insert 1) remove 2) annotate
// Clients: 3 Ops: 3 Round: 86

Просмотреть файл

@ -167,7 +167,7 @@ function runApplyStashedOpFarmTests(
testOpts.resultsFilePostfix += extraSeed;
}
const clients: TestClient[] = [new TestClient()];
const clients: TestClient[] = [new TestClient({ mergeTreeEnableAnnotateAdjust: true })];
// This test is based on reconnectFarm, but we keep a second set of clients. For
// these clients, we apply the generated ops as stashed ops, then regenerate
// them to simulate resubmit(), then apply them. In the end, they should arrive
@ -175,7 +175,7 @@ function runApplyStashedOpFarmTests(
let stashClients: TestClient[] = [];
for (const [i, c] of clients.entries()) c.startOrUpdateCollaboration(clientNames[i]);
stashClients = [new TestClient()];
stashClients = [new TestClient({ mergeTreeEnableAnnotateAdjust: true })];
for (const [i, c] of stashClients.entries())
c.startOrUpdateCollaboration(clientNames[i]);

Просмотреть файл

@ -97,6 +97,7 @@ function runConflictFarmTests(opts: IConflictFarmConfig, extraSeed?: number): vo
new TestClient({
mergeTreeEnableObliterate: true,
mergeTreeEnableSidedObliterate: true,
mergeTreeEnableAnnotateAdjust: true,
}),
];
for (const [i, c] of clients.entries()) c.startOrUpdateCollaboration(clientNames[i]);

Просмотреть файл

@ -96,7 +96,7 @@ function runReconnectFarmTests(opts: IReconnectFarmConfig, extraSeed?: number):
testOpts.resultsFilePostfix += extraSeed;
}
const clients: TestClient[] = [new TestClient()];
const clients: TestClient[] = [new TestClient({ mergeTreeEnableAnnotateAdjust: true })];
for (const [i, c] of clients.entries()) c.startOrUpdateCollaboration(clientNames[i]);
let seq = 0;

Просмотреть файл

@ -35,7 +35,13 @@ describe("MergeTree.Client", () => {
const random = makeRandom(0xdeadbeef, 0xfeedbed, minLength, opsPerRollback);
// A: readonly, B: rollback, C: rollback + edit, D: edit
const clients = createClientsAtInitialState({ initialState: "" }, "A", "B", "C", "D");
const clients = createClientsAtInitialState(
{ initialState: "", options: { mergeTreeEnableAnnotateAdjust: true } },
"A",
"B",
"C",
"D",
);
let seq = 0;
for (let round = 0; round < defaultOptions.rounds; round++) {

Просмотреть файл

@ -49,7 +49,7 @@ describe("MergeTree", () => {
4,
6,
{
foo: "bar",
props: { foo: "bar" },
},
currentSequenceNumber,
localClientId,
@ -69,7 +69,7 @@ describe("MergeTree", () => {
3,
3,
{
foo: "bar",
props: { foo: "bar" },
},
currentSequenceNumber,
localClientId,
@ -100,7 +100,7 @@ describe("MergeTree", () => {
3,
8,
{
foo: "bar",
props: { foo: "bar" },
},
currentSequenceNumber,
localClientId,
@ -135,7 +135,7 @@ describe("MergeTree", () => {
3,
8,
{
foo: "bar",
props: { foo: "bar" },
},
currentSequenceNumber,
localClientId,
@ -170,7 +170,7 @@ describe("MergeTree", () => {
3,
8,
{
foo: "bar",
props: { foo: "bar" },
},
currentSequenceNumber,
localClientId,
@ -205,7 +205,7 @@ describe("MergeTree", () => {
4,
6,
{
foo: "bar",
props: { foo: "bar" },
},
remoteSequenceNumber,
remoteClientId,

Просмотреть файл

@ -15,6 +15,7 @@ import {
import { MergeTree } from "../mergeTree.js";
import { Marker, type ISegmentLeaf } from "../mergeTreeNodes.js";
import { MergeTreeDeltaType, ReferenceType } from "../ops.js";
import type { PropsOrAdjust } from "../segmentPropertiesManager.js";
import { TextSegment } from "../textSegment.js";
import { insertSegments } from "./testUtils.js";
@ -78,7 +79,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
props: { propertySource: "remote" },
},
currentSequenceNumber,
remoteClientId,
@ -100,7 +101,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "local",
props: { propertySource: "local" },
},
currentSequenceNumber,
localClientId,
@ -126,8 +127,8 @@ describe("MergeTree", () => {
);
});
describe("local first", () => {
const props = {
propertySource: "local",
const props: PropsOrAdjust = {
props: { propertySource: "local" },
};
beforeEach(() => {
mergeTree.annotateRange(
@ -156,7 +157,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
secondProperty: "local",
props: { secondProperty: "local" },
},
currentSequenceNumber,
localClientId,
@ -186,8 +187,10 @@ describe("MergeTree", () => {
});
it("unsequenced local after unsequenced local split", () => {
const secondChangeProps = {
secondChange: 1,
const secondChangeProps: PropsOrAdjust = {
props: {
secondChange: 1,
},
};
mergeTree.annotateRange(
annotateStart,
@ -199,8 +202,10 @@ describe("MergeTree", () => {
undefined as never,
);
const splitOnlyProps = {
splitOnly: 1,
const splitOnlyProps: PropsOrAdjust = {
props: {
splitOnly: 1,
},
};
mergeTree.annotateRange(
@ -241,7 +246,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -263,7 +268,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props: secondChangeProps,
...secondChangeProps,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -285,7 +290,7 @@ describe("MergeTree", () => {
op: {
pos1: splitPos,
pos2: annotateEnd,
props: splitOnlyProps,
...splitOnlyProps,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -309,8 +314,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
remoteProperty: 1,
props: { propertySource: "remote", remoteProperty: 1 },
},
currentSequenceNumber,
remoteClientId,
@ -335,7 +339,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -358,7 +362,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -370,8 +374,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
remoteProperty: 1,
props: { propertySource: "remote", remoteProperty: 1 },
},
currentSequenceNumber,
remoteClientId,
@ -401,9 +404,8 @@ describe("MergeTree", () => {
assert.equal(segment.properties?.propertySource, "local");
const props2 = {
propertySource: "local2",
secondSource: 1,
const props2: PropsOrAdjust = {
props: { propertySource: "local2", secondSource: 1 },
};
mergeTree.annotateRange(
annotateStart,
@ -418,8 +420,10 @@ describe("MergeTree", () => {
assert.equal(segment.properties?.propertySource, "local2");
assert.equal(segment.properties?.secondSource, 1);
const props3 = {
thirdSource: 1,
const props3: PropsOrAdjust = {
props: {
thirdSource: 1,
},
};
mergeTree.annotateRange(
annotateStart,
@ -439,7 +443,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -455,7 +459,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props: props2,
...props2,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -471,7 +475,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props: props3,
...props3,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -489,7 +493,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
secondSource: "local2",
props: { secondSource: "local2" },
},
currentSequenceNumber,
localClientId,
@ -501,7 +505,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -513,9 +517,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
remoteOnly: 1,
secondSource: "remote",
props: { propertySource: "remote", remoteOnly: 1, secondSource: "remote" },
},
currentSequenceNumber,
remoteClientId,
@ -541,8 +543,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
remoteProperty: 1,
props: { propertySource: "remote", remoteProperty: 1 },
},
currentSequenceNumber,
remoteClientId,
@ -587,7 +588,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "local",
props: { propertySource: "local" },
},
currentSequenceNumber,
localClientId,
@ -606,8 +607,8 @@ describe("MergeTree", () => {
});
it("remote before sequenced local", () => {
const props = {
propertySource: "local",
const props: PropsOrAdjust = {
props: { propertySource: "local" },
};
const segmentInfo = mergeTree.getContainingSegment<ISegmentLeaf>(
@ -633,7 +634,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -647,8 +648,8 @@ describe("MergeTree", () => {
});
});
describe("local with rewrite first", () => {
const props = {
propertySource: "local",
const props: PropsOrAdjust = {
props: { propertySource: "local" },
};
beforeEach(() => {
mergeTree.annotateRange(
@ -667,8 +668,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "local2",
secondProperty: "local",
props: { propertySource: "local2", secondProperty: "local" },
},
currentSequenceNumber,
localClientId,
@ -691,8 +691,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
remoteProperty: 1,
props: { propertySource: "remote", remoteProperty: 1 },
},
currentSequenceNumber,
remoteClientId,
@ -717,7 +716,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -729,8 +728,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
remoteProperty: 1,
props: { propertySource: "remote", remoteProperty: 1 },
},
currentSequenceNumber,
remoteClientId,
@ -755,7 +753,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
secondSource: "local2",
props: { secondSource: "local2" },
},
currentSequenceNumber,
localClientId,
@ -767,7 +765,7 @@ describe("MergeTree", () => {
op: {
pos1: annotateStart,
pos2: annotateEnd,
props,
...props,
type: MergeTreeDeltaType.ANNOTATE,
},
sequencedMessage: {
@ -779,9 +777,7 @@ describe("MergeTree", () => {
annotateStart,
annotateEnd,
{
propertySource: "remote",
remoteOnly: 1,
secondSource: "remote",
props: { propertySource: "remote", remoteOnly: 1, secondSource: "remote" },
},
currentSequenceNumber,
remoteClientId,

Просмотреть файл

@ -70,7 +70,25 @@ export const annotateRange: TestOperation = (
client: TestClient,
opStart: number,
opEnd: number,
) => client.annotateRangeLocal(opStart, opEnd, { client: client.longClientId });
random: IRandom,
) => {
// eslint-disable-next-line unicorn/prefer-ternary
if (random.bool()) {
return client.annotateRangeLocal(opStart, opEnd, {
[random.integer(1, 5)]: client.longClientId,
});
} else {
const max = random.pick([undefined, random.integer(-10, 100)]);
const min = random.pick([undefined, random.integer(-100, 10)]);
return client.annotateAdjustRangeLocal(opStart, opEnd, {
[random.integer(0, 2).toString()]: {
delta: random.integer(-5, 5),
min: (min ?? max ?? 0) > (max ?? 0) ? undefined : min,
max,
},
});
}
};
export const insertAtRefPos: TestOperation = (
client: TestClient,

Просмотреть файл

@ -56,7 +56,7 @@ describe("MergeTree.Client", () => {
const clients = createClientsAtInitialState(
{
initialState: "",
options: {},
options: { mergeTreeEnableAnnotateAdjust: true },
},
"A",
"B",

Просмотреть файл

@ -41,6 +41,7 @@ declare type current_as_old_for_Class_BaseSegment = requireAssignableTo<TypeOnly
* typeValidation.broken:
* "Class_Client": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_Class_Client = requireAssignableTo<TypeOnly<old.Client>, TypeOnly<current.Client>>
/*

Просмотреть файл

@ -211,6 +211,7 @@ export interface ISharedIntervalCollection<TInterval extends ISerializableInterv
// @alpha (undocumented)
export interface ISharedSegmentSequence<T extends ISegment> extends ISharedObject<ISharedSegmentSequenceEvents>, ISharedIntervalCollection<SequenceInterval>, MergeTreeRevertibleDriver {
annotateAdjustRange(start: number, end: number, adjust: MapLike<AdjustParams>): void;
annotateRange(start: number, end: number, props: PropertySet): void;
createLocalReferencePosition(segment: T, offset: number, refType: ReferenceType, properties: PropertySet | undefined, slidingPreference?: SlidingPreference, canSlideToEndpoint?: boolean): LocalReferencePosition;
getContainingSegment(pos: number): {

Просмотреть файл

@ -261,6 +261,21 @@
},
"TypeAlias_SharedStringSegment": {
"backCompat": false
},
"Class_SharedSegmentSequence": {
"forwardCompat": false
},
"Class_SharedStringClass": {
"forwardCompat": false
},
"Interface_ISharedSegmentSequence": {
"forwardCompat": false
},
"Interface_ISharedString": {
"forwardCompat": false
},
"TypeAlias_SharedString": {
"forwardCompat": false
}
},
"entrypoint": "legacy"

Просмотреть файл

@ -70,6 +70,7 @@ export interface SequenceOptions
| "mergeTreeReferencesCanSlideToEndpoint"
| "mergeTreeEnableObliterate"
| "mergeTreeEnableSidedObliterate"
| "mergeTreeEnableAnnotateAdjust"
> {
/**
* Enable the ability to use interval APIs that rely on positions before and

Просмотреть файл

@ -46,7 +46,9 @@ import {
createObliterateRangeOp,
createRemoveRangeOp,
matchProperties,
type AdjustParams,
type InteriorSequencePlace,
type MapLike,
} from "@fluidframework/merge-tree/internal";
import {
ISummaryTreeWithStats,
@ -300,6 +302,21 @@ export interface ISharedSegmentSequence<T extends ISegment>
*/
annotateRange(start: number, end: number, props: PropertySet): void;
/**
* Annotates a specified range within the sequence by applying the provided adjustments.
*
* @param start - The inclusive start position of the range to annotate. This is a zero-based index.
* @param end - The exclusive end position of the range to annotate. This is a zero-based index.
* @param adjust - A map-like object specifying the properties to adjust. Each key-value pair represents a property and its corresponding adjustment to be applied over the range.
* An adjustment is defined by an object containing a `delta` to be added to the current property value, and optional `min` and `max` constraints to limit the adjusted value.
*
* @remarks
* The range is defined by the start and end positions, where the start position is inclusive and the end position is exclusive.
* The properties provided in the adjust parameter will be applied to the specified range. Each adjustment modifies the current value of the property by adding the specified `value`.
* If the current value is not a number, the `delta` will be summed with 0 to compute the new value. The optional `min` and `max` constraints are applied after the adjustment to ensure the final value falls within the specified bounds.
*/
annotateAdjustRange(start: number, end: number, adjust: MapLike<AdjustParams>): void;
/**
* @param start - The inclusive start of the range to remove
* @param end - The exclusive end of the range to remove
@ -521,6 +538,7 @@ export abstract class SharedSegmentSequence<T extends ISegment>
mergeTreeEnableSidedObliterate: (c, n) => c.getBoolean(n),
intervalStickinessEnabled: (c, n) => c.getBoolean(n),
mergeTreeReferencesCanSlideToEndpoint: (c, n) => c.getBoolean(n),
mergeTreeEnableAnnotateAdjust: (c, n) => c.getBoolean(n),
},
dataStoreRuntime.options,
);
@ -605,6 +623,10 @@ export abstract class SharedSegmentSequence<T extends ISegment>
this.guardReentrancy(() => this.client.annotateRangeLocal(start, end, props));
}
public annotateAdjustRange(start: number, end: number, adjust: MapLike<AdjustParams>): void {
this.guardReentrancy(() => this.client.annotateAdjustRangeLocal(start, end, adjust));
}
public getPropertiesAtPosition(pos: number): PropertySet | undefined {
return this.client.getPropertiesAtPosition(pos);
}

Просмотреть файл

@ -24,9 +24,9 @@ import {
type Serializable,
IChannelServices,
} from "@fluidframework/datastore-definitions/internal";
import { PropertySet, Side } from "@fluidframework/merge-tree/internal";
import { PropertySet, Side, type AdjustParams } from "@fluidframework/merge-tree/internal";
import type { SequenceInterval, SharedStringClass } from "../../index.js";
import type { MapLike, SequenceInterval, SharedStringClass } from "../../index.js";
import { type IIntervalCollection } from "../../intervalCollection.js";
import { SharedStringRevertible, revertSharedStringRevertibles } from "../../revertibles.js";
import { SharedStringFactory } from "../../sequenceFactory.js";
@ -68,6 +68,11 @@ export interface AnnotateRange extends RangeSpec {
props: { key: string; value?: Serializable<any> }[];
}
export interface AnnotateAdjustRange extends RangeSpec {
type: "annotateAdjustRange";
adjust: { key: string; value: AdjustParams }[];
}
export interface ObliterateRange extends RangeSpec {
type: "obliterateRange";
}
@ -115,7 +120,12 @@ export interface RevertibleWeights {
export type IntervalOperation = AddInterval | ChangeInterval | DeleteInterval;
export type OperationWithRevert = IntervalOperation | RevertSharedStringRevertibles;
export type TextOperation = AddText | RemoveRange | AnnotateRange | ObliterateRange;
export type TextOperation =
| AddText
| RemoveRange
| AnnotateRange
| AnnotateAdjustRange
| ObliterateRange;
export type ClientOperation = IntervalOperation | TextOperation;
@ -244,6 +254,13 @@ export function makeReducer(
}
client.channel.annotateRange(start, end, propertySet);
},
annotateAdjustRange: async ({ client }, { start, end, adjust }) => {
const adjustRange: MapLike<AdjustParams> = {};
for (const { key, value } of adjust) {
adjustRange[key] = value;
}
client.channel.annotateAdjustRange(start, end, adjustRange);
},
obliterateRange: async ({ client }, { start, end }) => {
client.channel.obliterateRange(start, end);
},
@ -338,6 +355,23 @@ export function createSharedStringGeneratorOperations(
};
}
async function annotateAdjustRange(state: ClientOpState): Promise<AnnotateAdjustRange> {
const { random } = state;
const key = random.pick(options.propertyNamePool);
const max = random.pick([undefined, random.integer(-10, 100)]);
const min = random.pick([undefined, random.integer(-100, 10)]);
const value: AdjustParams = {
delta: random.integer(-5, 5),
max,
min: (min ?? max ?? 0) > (max ?? 0) ? undefined : min,
};
return {
type: "annotateAdjustRange",
...exclusiveRange(state),
adjust: [{ key, value }],
};
}
async function removeRange(state: ClientOpState): Promise<RemoveRange> {
return { type: "removeRange", ...exclusiveRange(state) };
}
@ -360,6 +394,7 @@ export function createSharedStringGeneratorOperations(
addText,
obliterateRange,
annotateRange,
annotateAdjustRange,
removeRange,
removeRangeLeaveChar,
lengthSatisfies,
@ -368,6 +403,11 @@ export function createSharedStringGeneratorOperations(
};
}
function setSharedStringRuntimeOptions(runtime: IFluidDataStoreRuntime) {
runtime.options.intervalStickinessEnabled = true;
runtime.options.mergeTreeEnableObliterate = true;
runtime.options.mergeTreeEnableAnnotateAdjust = true;
}
export class SharedStringFuzzFactory extends SharedStringFactory {
public async load(
runtime: IFluidDataStoreRuntime,
@ -375,14 +415,12 @@ export class SharedStringFuzzFactory extends SharedStringFactory {
services: IChannelServices,
attributes: IChannelAttributes,
): Promise<SharedStringClass> {
runtime.options.intervalStickinessEnabled = true;
runtime.options.mergeTreeEnableObliterate = true;
setSharedStringRuntimeOptions(runtime);
return super.load(runtime, id, services, attributes);
}
public create(document: IFluidDataStoreRuntime, id: string): SharedStringClass {
document.options.intervalStickinessEnabled = true;
document.options.mergeTreeEnableObliterate = true;
setSharedStringRuntimeOptions(document);
return super.create(document, id);
}
}

Просмотреть файл

@ -30,6 +30,7 @@ export function makeSharedStringOperationGenerator(
addText,
removeRange,
annotateRange,
annotateAdjustRange,
removeRangeLeaveChar,
lengthSatisfies,
hasNonzeroLength,
@ -50,6 +51,7 @@ export function makeSharedStringOperationGenerator(
: hasNonzeroLength,
],
[annotateRange, usableWeights.annotateRange, hasNonzeroLength],
[annotateAdjustRange, usableWeights.annotateRange, hasNonzeroLength],
]);
}

Просмотреть файл

@ -136,6 +136,7 @@ declare type current_as_old_for_Class_SequenceMaintenanceEvent = requireAssignab
* typeValidation.broken:
* "Class_SharedSegmentSequence": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_Class_SharedSegmentSequence = requireAssignableTo<TypeOnly<old.SharedSegmentSequence<never>>, TypeOnly<current.SharedSegmentSequence<never>>>
/*
@ -154,6 +155,7 @@ declare type current_as_old_for_Class_SharedSegmentSequence = requireAssignableT
* typeValidation.broken:
* "Class_SharedStringClass": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_Class_SharedStringClass = requireAssignableTo<TypeOnly<old.SharedStringClass>, TypeOnly<current.SharedStringClass>>
/*
@ -635,6 +637,7 @@ declare type current_as_old_for_Interface_ISharedIntervalCollection = requireAss
* typeValidation.broken:
* "Interface_ISharedSegmentSequence": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_Interface_ISharedSegmentSequence = requireAssignableTo<TypeOnly<old.ISharedSegmentSequence<never>>, TypeOnly<current.ISharedSegmentSequence<never>>>
/*
@ -671,6 +674,7 @@ declare type current_as_old_for_Interface_ISharedSegmentSequenceEvents = require
* typeValidation.broken:
* "Interface_ISharedString": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_Interface_ISharedString = requireAssignableTo<TypeOnly<old.ISharedString>, TypeOnly<current.ISharedString>>
/*
@ -862,6 +866,7 @@ declare type current_as_old_for_TypeAlias_SequencePlace = requireAssignableTo<Ty
* typeValidation.broken:
* "TypeAlias_SharedString": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_TypeAlias_SharedString = requireAssignableTo<TypeOnly<old.SharedString>, TypeOnly<current.SharedString>>
/*

Просмотреть файл

@ -756,6 +756,7 @@ export interface ISharedObjectEvents extends IErrorEvent {
// @alpha (undocumented)
export interface ISharedSegmentSequence<T extends ISegment> extends ISharedObject<ISharedSegmentSequenceEvents>, ISharedIntervalCollection<SequenceInterval>, MergeTreeRevertibleDriver {
annotateAdjustRange(start: number, end: number, adjust: MapLike<AdjustParams>): void;
annotateRange(start: number, end: number, props: PropertySet): void;
createLocalReferencePosition(segment: T, offset: number, refType: ReferenceType, properties: PropertySet | undefined, slidingPreference?: SlidingPreference, canSlideToEndpoint?: boolean): LocalReferencePosition;
getContainingSegment(pos: number): {

Просмотреть файл

@ -132,7 +132,11 @@
"typescript": "~5.4.5"
},
"typeValidation": {
"broken": {},
"broken": {
"Class_SharedSegmentSequenceRevertible": {
"forwardCompat": false
}
},
"entrypoint": "internal"
}
}

Просмотреть файл

@ -58,6 +58,7 @@ declare type current_as_old_for_Class_SharedMapUndoRedoHandler = requireAssignab
* typeValidation.broken:
* "Class_SharedSegmentSequenceRevertible": {"forwardCompat": false}
*/
// @ts-expect-error compatibility expected to be broken
declare type old_as_current_for_Class_SharedSegmentSequenceRevertible = requireAssignableTo<TypeOnly<old.SharedSegmentSequenceRevertible>, TypeOnly<current.SharedSegmentSequenceRevertible>>
/*