Skip to content

Commit 50b5ecd

Browse files
noenckeCopilotjenn-le
authored
Add serializable SharedTree change (#25992)
This introduces an alpha API that allows a user to capture a serialized version of tree changes whenever the view is changed. That (opaque) object can then be applied to a different tree branch later, as long as that branch has the same head commit as the originating branch. This API is necessary to support AI sandboxing scenarios using the tree-agent package. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: jenn le <jennle@microsoft.com>
1 parent 5199e2d commit 50b5ecd

File tree

11 files changed

+204
-8
lines changed

11 files changed

+204
-8
lines changed

packages/dds/tree/api-report/tree.alpha.api.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ export interface BranchableTree extends ViewableTree {
134134
rebase(branch: TreeBranchFork): void;
135135
}
136136

137+
// @alpha @sealed
138+
export type ChangeMetadata = CommitMetadata & ({
139+
readonly isLocal: true;
140+
getChange(): JsonCompatibleReadOnly;
141+
} | {
142+
readonly isLocal: false;
143+
readonly getChange?: undefined;
144+
});
145+
137146
// @alpha
138147
export function checkCompatibility(viewWhichCreatedStoredSchema: TreeViewConfiguration, view: TreeViewConfiguration): Omit<SchemaCompatibilityStatus, "canInitialize">;
139148

@@ -1487,6 +1496,7 @@ export interface TreeBranch extends IDisposable {
14871496

14881497
// @alpha @sealed
14891498
export interface TreeBranchAlpha extends TreeBranch {
1499+
applyChange(change: JsonCompatibleReadOnly): void;
14901500
readonly events: Listenable_2<TreeBranchEvents>;
14911501
// (undocumented)
14921502
fork(): TreeBranchAlpha;
@@ -1497,7 +1507,7 @@ export interface TreeBranchAlpha extends TreeBranch {
14971507

14981508
// @alpha @sealed
14991509
export interface TreeBranchEvents extends Omit<TreeViewEvents, "commitApplied"> {
1500-
changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
1510+
changed(data: ChangeMetadata, getRevertible?: RevertibleAlphaFactory): void;
15011511
commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
15021512
}
15031513

packages/dds/tree/src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export {
175175
type GraphCommit,
176176
CommitKind,
177177
type CommitMetadata,
178+
type ChangeMetadata,
178179
type RevisionTag,
179180
RevisionTagSchema,
180181
RevisionTagCodec,

packages/dds/tree/src/core/rebase/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
type GraphCommit,
1313
CommitKind,
1414
type CommitMetadata,
15+
type ChangeMetadata,
1516
type RevisionTag,
1617
RevisionTagSchema,
1718
type EncodedRevisionTag,

packages/dds/tree/src/core/rebase/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Type } from "@sinclair/typebox";
1313

1414
import {
1515
type Brand,
16+
type JsonCompatibleReadOnly,
1617
type NestedMap,
1718
RangeMap,
1819
brand,
@@ -183,6 +184,30 @@ export interface CommitMetadata {
183184
readonly isLocal: boolean;
184185
}
185186

187+
/**
188+
* Information about a commit that has been applied.
189+
*
190+
* @sealed @alpha
191+
*/
192+
export type ChangeMetadata = CommitMetadata &
193+
(
194+
| {
195+
readonly isLocal: true;
196+
/**
197+
* A serializable object that encodes the change.
198+
* @remarks This change object can be {@link TreeBranchAlpha.applyChange | applied to another branch} in the same state as the one which generated it.
199+
* The change object must be applied to a SharedTree with the same IdCompressor session ID as it was created from.
200+
* @privateRemarks
201+
* This is a `SerializedChange` from treeCheckout.ts.
202+
*/
203+
getChange(): JsonCompatibleReadOnly;
204+
}
205+
| {
206+
readonly isLocal: false;
207+
readonly getChange?: undefined;
208+
}
209+
);
210+
186211
/**
187212
* Creates a new graph commit object. This is useful for creating copies of commits with different parentage.
188213
* @param parent - the parent of the new commit

packages/dds/tree/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
CommitKind,
1010
RevertibleStatus,
1111
type CommitMetadata,
12+
type ChangeMetadata,
1213
type RevertibleFactory,
1314
type RevertibleAlphaFactory,
1415
type RevertibleAlpha,

packages/dds/tree/src/shared-tree-core/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,13 @@ export type {
7171
EncodedCommit,
7272
} from "./editManagerFormatCommons.js";
7373

74+
export type { DecodedMessage } from "./messageTypes.js";
7475
export {
7576
getCodecTreeForMessageFormatWithChange,
7677
clientVersionToMessageFormatVersion,
7778
messageFormatVersionSelectorForSharedBranches,
79+
makeMessageCodec,
80+
type MessageEncodingContext,
7881
} from "./messageCodecs.js";
7982
export {
8083
MessageFormatVersion,

packages/dds/tree/src/shared-tree/schematizingTreeView.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
type Breakable,
6767
breakingClass,
6868
disposeSymbol,
69+
type JsonCompatibleReadOnly,
6970
type WithBreakable,
7071
} from "../util/index.js";
7172

@@ -165,6 +166,10 @@ export class SchematizingSimpleTreeView<
165166
);
166167
}
167168

169+
public applyChange(change: JsonCompatibleReadOnly): void {
170+
this.checkout.applySerializedChange(change);
171+
}
172+
168173
public hasRootSchema<TSchema extends ImplicitFieldSchema>(
169174
schema: TSchema,
170175
): this is TreeViewAlpha<TSchema> {

packages/dds/tree/src/shared-tree/treeCheckout.ts

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { assert, unreachableCase, fail } from "@fluidframework/core-utils/internal";
77
import type { IFluidHandle, Listenable } from "@fluidframework/core-interfaces/internal";
88
import { createEmitter } from "@fluid-internal/client-utils";
9-
import type { IIdCompressor } from "@fluidframework/id-compressor";
9+
import type { IIdCompressor, SessionId } from "@fluidframework/id-compressor";
1010
import {
1111
UsageError,
1212
type ITelemetryLoggerExt,
@@ -20,7 +20,6 @@ import {
2020
type AnchorSetRootEvents,
2121
type ChangeFamily,
2222
CommitKind,
23-
type CommitMetadata,
2423
type DeltaVisitor,
2524
type DetachedFieldIndex,
2625
type IEditableForest,
@@ -48,6 +47,8 @@ import {
4847
type TreeNodeStoredSchema,
4948
LeafNodeStoredSchema,
5049
diffHistories,
50+
type ChangeMetadata,
51+
type ChangeEncodingContext,
5152
type ReadOnlyDetachedFieldIndex,
5253
} from "../core/index.js";
5354
import {
@@ -69,7 +70,13 @@ import {
6970
type SharedTreeBranchChange,
7071
type Transactor,
7172
} from "../shared-tree-core/index.js";
72-
import { Breakable, disposeSymbol, getOrCreate, type WithBreakable } from "../util/index.js";
73+
import {
74+
Breakable,
75+
disposeSymbol,
76+
getOrCreate,
77+
type JsonCompatibleReadOnly,
78+
type WithBreakable,
79+
} from "../util/index.js";
7380

7481
import { SharedTreeChangeFamily, hasSchemaChange } from "./sharedTreeChangeFamily.js";
7582
import type { SharedTreeChange } from "./sharedTreeChangeTypes.js";
@@ -90,6 +97,7 @@ import {
9097
type CustomTreeNode,
9198
} from "../simple-tree/index.js";
9299
import { getCheckout, SchematizingSimpleTreeView } from "./schematizingTreeView.js";
100+
import { isStableId } from "@fluidframework/id-compressor/internal";
93101

94102
/**
95103
* Events for {@link ITreeCheckout}.
@@ -123,7 +131,7 @@ export interface CheckoutEvents {
123131
* @param getRevertible - a function provided that allows users to get a revertible for the change. If not provided,
124132
* this change is not revertible.
125133
*/
126-
changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
134+
changed(data: ChangeMetadata, getRevertible?: RevertibleAlphaFactory): void;
127135

128136
/**
129137
* Fired when a new branch is created from this checkout.
@@ -537,7 +545,31 @@ export class TreeCheckout implements ITreeCheckoutFork {
537545
};
538546

539547
let withinEventContext = true;
540-
this.#events.emit("changed", { isLocal: true, kind }, getRevertible);
548+
549+
const metadata: ChangeMetadata = {
550+
kind,
551+
isLocal: true,
552+
getChange: () => {
553+
const context: ChangeEncodingContext = {
554+
idCompressor: this.idCompressor,
555+
originatorId: this.idCompressor.localSessionId,
556+
revision,
557+
};
558+
const encodedChange = this.changeFamily.codecs
559+
.resolve(4)
560+
.json.encode(change, context);
561+
562+
assert(commit.parent !== undefined, "Expected applied commit to be parented");
563+
return {
564+
version: 1,
565+
revision,
566+
originatorId: this.idCompressor.localSessionId,
567+
change: encodedChange,
568+
} satisfies SerializedChange;
569+
},
570+
};
571+
572+
this.#events.emit("changed", metadata, getRevertible);
541573
withinEventContext = false;
542574
}
543575
} else if (this.isRemoteChangeEvent(event)) {
@@ -564,6 +596,28 @@ export class TreeCheckout implements ITreeCheckoutFork {
564596
}
565597
};
566598

599+
/**
600+
* Applies the given serialized change (as was produced via a `"changed"` event of another checkout) to this checkout.
601+
*/
602+
public applySerializedChange(serializedChange: JsonCompatibleReadOnly): void {
603+
if (!isSerializedChange(serializedChange)) {
604+
throw new UsageError(`Cannot apply change. Invalid serialized change format.`);
605+
}
606+
const { revision, originatorId, change } = serializedChange;
607+
if (originatorId !== this.idCompressor.localSessionId) {
608+
throw new UsageError(
609+
`Cannot apply change. A serialized changed must be applied to the same SharedTree as it was created from.`,
610+
);
611+
}
612+
const context: ChangeEncodingContext = {
613+
idCompressor: this.idCompressor,
614+
originatorId: this.idCompressor.localSessionId,
615+
revision,
616+
};
617+
const decodedChange = this.changeFamily.codecs.resolve(4).json.decode(change, context);
618+
this.applyChange(decodedChange, revision);
619+
}
620+
567621
// Revision is the revision of the commit, if any, which caused this change.
568622
private applyChange(change: SharedTreeChange, revision?: RevisionTag): void {
569623
// Conflicts due to schema will be empty and thus are not applied.
@@ -1210,3 +1264,24 @@ function verboseFromCursor(
12101264
fields: fields as CustomTreeNode<IFluidHandle>,
12111265
};
12121266
}
1267+
1268+
interface SerializedChange {
1269+
version: 1;
1270+
revision: RevisionTag;
1271+
change: JsonCompatibleReadOnly;
1272+
originatorId: SessionId;
1273+
}
1274+
1275+
function isSerializedChange(value: unknown): value is SerializedChange {
1276+
if (typeof value !== "object" || value === null) {
1277+
return false;
1278+
}
1279+
const change = value as Partial<SerializedChange>;
1280+
return (
1281+
change.version === 1 &&
1282+
(change.revision === "root" || typeof change.revision === "number") &&
1283+
typeof change.originatorId === "string" &&
1284+
isStableId(change.originatorId) &&
1285+
change.change !== undefined
1286+
);
1287+
}

packages/dds/tree/src/simple-tree/api/tree.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { IFluidLoadable, IDisposable, Listenable } from "@fluidframework/co
77

88
import type {
99
CommitMetadata,
10+
ChangeMetadata,
1011
RevertibleAlphaFactory,
1112
RevertibleFactory,
1213
} from "../../core/index.js";
@@ -35,6 +36,7 @@ import type {
3536
VoidTransactionCallbackStatus,
3637
} from "./transactionTypes.js";
3738
import type { VerboseTree } from "./verboseTree.js";
39+
import type { JsonCompatibleReadOnly } from "../../util/index.js";
3840

3941
/**
4042
* A tree from which a {@link TreeView} can be created.
@@ -282,6 +284,19 @@ export interface TreeBranchAlpha extends TreeBranch {
282284
transaction: () => VoidTransactionCallbackStatus | void,
283285
params?: RunTransactionParams,
284286
): TransactionResult;
287+
288+
/**
289+
* Apply a serialized change to this branch.
290+
* @param change - the change to apply.
291+
* Changes are acquired via `getChange` in a branch's {@link TreeBranchEvents.changed | "changed"} event.
292+
* @remarks Changes may only be applied to a SharedTree with the same IdCompressor instance and branch state from which they were generated.
293+
* They may be created by one branch and applied to another, but only if both branches share the same history at the time of creation and application.
294+
*
295+
* @privateRemarks
296+
* TODO: This method will support applying changes from different IdCompressor instances as long as they have the same local session ID.
297+
* Update the tests and docs to match when that is done.
298+
*/
299+
applyChange(change: JsonCompatibleReadOnly): void;
285300
}
286301

287302
/**
@@ -508,7 +523,7 @@ export interface TreeBranchEvents extends Omit<TreeViewEvents, "commitApplied">
508523
* @param getRevertible - a function that allows users to get a revertible for the change. If not provided,
509524
* this change is not revertible.
510525
*/
511-
changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
526+
changed(data: ChangeMetadata, getRevertible?: RevertibleAlphaFactory): void;
512527

513528
/**
514529
* Fired when:

packages/dds/tree/src/test/simple-tree/api/tree.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from "../../../simple-tree/fieldSchema.js";
2727
// eslint-disable-next-line import-x/no-internal-modules
2828
import type { UnhydratedFlexTreeNode } from "../../../simple-tree/core/index.js";
29+
import type { JsonCompatibleReadOnly } from "../../../util/index.js";
2930

3031
const schema = new SchemaFactory("com.example");
3132

@@ -287,4 +288,53 @@ describe("simple-tree tree", () => {
287288
assert.equal(view.root, "beefbeef-beef-4000-8000-000000000001");
288289
});
289290
});
291+
292+
describe("Serialized changes", () => {
293+
it("can be applied to a different branch", () => {
294+
const config = new TreeViewConfiguration({ schema: schema.number });
295+
const viewA = getView(config);
296+
viewA.initialize(3);
297+
const viewB = viewA.fork();
298+
299+
let change: JsonCompatibleReadOnly | undefined;
300+
viewB.events.on("changed", (metadata) => {
301+
assert(metadata.isLocal);
302+
change = metadata.getChange();
303+
});
304+
305+
viewB.root = 4;
306+
assert(change !== undefined);
307+
viewA.applyChange(change);
308+
assert.equal(viewA.root, 4);
309+
});
310+
311+
it("fail to apply to a branch in another session", () => {
312+
const config = new TreeViewConfiguration({ schema: schema.number });
313+
const viewA = getView(config);
314+
viewA.initialize(3);
315+
const viewB = getView(config);
316+
viewB.initialize(3);
317+
318+
let change: JsonCompatibleReadOnly | undefined;
319+
viewA.events.on("changed", (metadata) => {
320+
assert(metadata.isLocal);
321+
change = metadata.getChange();
322+
});
323+
viewA.root = 4;
324+
325+
const c = change ?? assert.fail("change not captured");
326+
assert.throws(() => {
327+
viewB.applyChange(c);
328+
}, /cannot apply change.*same sharedtree/i);
329+
});
330+
331+
it("error if malformed", () => {
332+
const config = new TreeViewConfiguration({ schema: schema.number });
333+
const viewA = getView(config);
334+
viewA.initialize(3);
335+
assert.throws(() => {
336+
viewA.applyChange({ invalid: "bogus" });
337+
}, /cannot apply change.*invalid.*format/i);
338+
});
339+
});
290340
});

0 commit comments

Comments
 (0)