Skip to content
12 changes: 11 additions & 1 deletion packages/dds/tree/api-report/tree.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ export interface BranchableTree extends ViewableTree {
rebase(branch: TreeBranchFork): void;
}

// @alpha @sealed
export type ChangeMetadata = CommitMetadata & ({
readonly isLocal: true;
getChange(): JsonCompatibleReadOnly;
} | {
readonly isLocal: false;
readonly getChange?: undefined;
});

// @alpha
export function checkCompatibility(viewWhichCreatedStoredSchema: TreeViewConfiguration, view: TreeViewConfiguration): Omit<SchemaCompatibilityStatus, "canInitialize">;

Expand Down Expand Up @@ -1487,6 +1496,7 @@ export interface TreeBranch extends IDisposable {

// @alpha @sealed
export interface TreeBranchAlpha extends TreeBranch {
applyChange(change: JsonCompatibleReadOnly): void;
readonly events: Listenable_2<TreeBranchEvents>;
// (undocumented)
fork(): TreeBranchAlpha;
Expand All @@ -1497,7 +1507,7 @@ export interface TreeBranchAlpha extends TreeBranch {

// @alpha @sealed
export interface TreeBranchEvents extends Omit<TreeViewEvents, "commitApplied"> {
changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
changed(data: ChangeMetadata, getRevertible?: RevertibleAlphaFactory): void;
commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void;
}

Expand Down
1 change: 1 addition & 0 deletions packages/dds/tree/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export {
type GraphCommit,
CommitKind,
type CommitMetadata,
type ChangeMetadata,
type RevisionTag,
RevisionTagSchema,
RevisionTagCodec,
Expand Down
1 change: 1 addition & 0 deletions packages/dds/tree/src/core/rebase/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
type GraphCommit,
CommitKind,
type CommitMetadata,
type ChangeMetadata,
type RevisionTag,
RevisionTagSchema,
type EncodedRevisionTag,
Expand Down
25 changes: 25 additions & 0 deletions packages/dds/tree/src/core/rebase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Type } from "@sinclair/typebox";

import {
type Brand,
type JsonCompatibleReadOnly,
type NestedMap,
RangeMap,
brand,
Expand Down Expand Up @@ -183,6 +184,30 @@ export interface CommitMetadata {
readonly isLocal: boolean;
}

/**
* Information about a commit that has been applied.
*
* @sealed @alpha
*/
export type ChangeMetadata = CommitMetadata &
(
| {
readonly isLocal: true;
/**
* A serializable object that encodes the change.
* @remarks This change object can be {@link TreeBranchAlpha.applyChange | applied to another branch} in the same state as the one which generated it.
* The change object must be applied to a SharedTree with the same IdCompressor session ID as it was created from.
* @privateRemarks
* This is a `SerializedChange` from treeCheckout.ts.
*/
getChange(): JsonCompatibleReadOnly;
}
| {
readonly isLocal: false;
readonly getChange?: undefined;
}
);

/**
* Creates a new graph commit object. This is useful for creating copies of commits with different parentage.
* @param parent - the parent of the new commit
Expand Down
1 change: 1 addition & 0 deletions packages/dds/tree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
CommitKind,
RevertibleStatus,
type CommitMetadata,
type ChangeMetadata,
type RevertibleFactory,
type RevertibleAlphaFactory,
type RevertibleAlpha,
Expand Down
3 changes: 3 additions & 0 deletions packages/dds/tree/src/shared-tree-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,13 @@ export type {
EncodedCommit,
} from "./editManagerFormatCommons.js";

export type { DecodedMessage } from "./messageTypes.js";
export {
getCodecTreeForMessageFormatWithChange,
clientVersionToMessageFormatVersion,
messageFormatVersionSelectorForSharedBranches,
makeMessageCodec,
type MessageEncodingContext,
} from "./messageCodecs.js";
export {
MessageFormatVersion,
Expand Down
5 changes: 5 additions & 0 deletions packages/dds/tree/src/shared-tree/schematizingTreeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
type Breakable,
breakingClass,
disposeSymbol,
type JsonCompatibleReadOnly,
type WithBreakable,
} from "../util/index.js";

Expand Down Expand Up @@ -165,6 +166,10 @@ export class SchematizingSimpleTreeView<
);
}

public applyChange(change: JsonCompatibleReadOnly): void {
this.checkout.applySerializedChange(change);
}

public hasRootSchema<TSchema extends ImplicitFieldSchema>(
schema: TSchema,
): this is TreeViewAlpha<TSchema> {
Expand Down
85 changes: 80 additions & 5 deletions packages/dds/tree/src/shared-tree/treeCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { assert, unreachableCase, fail } from "@fluidframework/core-utils/internal";
import type { IFluidHandle, Listenable } from "@fluidframework/core-interfaces/internal";
import { createEmitter } from "@fluid-internal/client-utils";
import type { IIdCompressor } from "@fluidframework/id-compressor";
import type { IIdCompressor, SessionId } from "@fluidframework/id-compressor";
import {
UsageError,
type ITelemetryLoggerExt,
Expand All @@ -20,7 +20,6 @@ import {
type AnchorSetRootEvents,
type ChangeFamily,
CommitKind,
type CommitMetadata,
type DeltaVisitor,
type DetachedFieldIndex,
type IEditableForest,
Expand Down Expand Up @@ -48,6 +47,8 @@ import {
type TreeNodeStoredSchema,
LeafNodeStoredSchema,
diffHistories,
type ChangeMetadata,
type ChangeEncodingContext,
type ReadOnlyDetachedFieldIndex,
} from "../core/index.js";
import {
Expand All @@ -69,7 +70,13 @@ import {
type SharedTreeBranchChange,
type Transactor,
} from "../shared-tree-core/index.js";
import { Breakable, disposeSymbol, getOrCreate, type WithBreakable } from "../util/index.js";
import {
Breakable,
disposeSymbol,
getOrCreate,
type JsonCompatibleReadOnly,
type WithBreakable,
} from "../util/index.js";

import { SharedTreeChangeFamily, hasSchemaChange } from "./sharedTreeChangeFamily.js";
import type { SharedTreeChange } from "./sharedTreeChangeTypes.js";
Expand All @@ -90,6 +97,7 @@ import {
type CustomTreeNode,
} from "../simple-tree/index.js";
import { getCheckout, SchematizingSimpleTreeView } from "./schematizingTreeView.js";
import { isStableId } from "@fluidframework/id-compressor/internal";

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

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

let withinEventContext = true;
this.#events.emit("changed", { isLocal: true, kind }, getRevertible);

const metadata: ChangeMetadata = {
kind,
isLocal: true,
getChange: () => {
const context: ChangeEncodingContext = {
idCompressor: this.idCompressor,
originatorId: this.idCompressor.localSessionId,
revision,
};
const encodedChange = this.changeFamily.codecs
.resolve(4)
.json.encode(change, context);

assert(commit.parent !== undefined, "Expected applied commit to be parented");
return {
version: 1,
revision,
originatorId: this.idCompressor.localSessionId,
change: encodedChange,
} satisfies SerializedChange;
},
};

this.#events.emit("changed", metadata, getRevertible);
withinEventContext = false;
}
} else if (this.isRemoteChangeEvent(event)) {
Expand All @@ -564,6 +596,28 @@ export class TreeCheckout implements ITreeCheckoutFork {
}
};

/**
* Applies the given serialized change (as was produced via a `"changed"` event of another checkout) to this checkout.
*/
public applySerializedChange(serializedChange: JsonCompatibleReadOnly): void {
if (!isSerializedChange(serializedChange)) {
throw new UsageError(`Cannot apply change. Invalid serialized change format.`);
}
const { revision, originatorId, change } = serializedChange;
if (originatorId !== this.idCompressor.localSessionId) {
throw new UsageError(
`Cannot apply change. A serialized changed must be applied to the same SharedTree as it was created from.`,
);
}
const context: ChangeEncodingContext = {
idCompressor: this.idCompressor,
originatorId: this.idCompressor.localSessionId,
revision,
};
const decodedChange = this.changeFamily.codecs.resolve(4).json.decode(change, context);
this.applyChange(decodedChange, revision);
}

// Revision is the revision of the commit, if any, which caused this change.
private applyChange(change: SharedTreeChange, revision?: RevisionTag): void {
// Conflicts due to schema will be empty and thus are not applied.
Expand Down Expand Up @@ -1210,3 +1264,24 @@ function verboseFromCursor(
fields: fields as CustomTreeNode<IFluidHandle>,
};
}

interface SerializedChange {
version: 1;
revision: RevisionTag;
change: JsonCompatibleReadOnly;
originatorId: SessionId;
}

function isSerializedChange(value: unknown): value is SerializedChange {
if (typeof value !== "object" || value === null) {
return false;
}
const change = value as Partial<SerializedChange>;
return (
change.version === 1 &&
(change.revision === "root" || typeof change.revision === "number") &&
typeof change.originatorId === "string" &&
isStableId(change.originatorId) &&
change.change !== undefined
);
}
17 changes: 16 additions & 1 deletion packages/dds/tree/src/simple-tree/api/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { IFluidLoadable, IDisposable, Listenable } from "@fluidframework/co

import type {
CommitMetadata,
ChangeMetadata,
RevertibleAlphaFactory,
RevertibleFactory,
} from "../../core/index.js";
Expand Down Expand Up @@ -35,6 +36,7 @@ import type {
VoidTransactionCallbackStatus,
} from "./transactionTypes.js";
import type { VerboseTree } from "./verboseTree.js";
import type { JsonCompatibleReadOnly } from "../../util/index.js";

/**
* A tree from which a {@link TreeView} can be created.
Expand Down Expand Up @@ -282,6 +284,19 @@ export interface TreeBranchAlpha extends TreeBranch {
transaction: () => VoidTransactionCallbackStatus | void,
params?: RunTransactionParams,
): TransactionResult;

/**
* Apply a serialized change to this branch.
* @param change - the change to apply.
* Changes are acquired via `getChange` in a branch's {@link TreeBranchEvents.changed | "changed"} event.
* @remarks Changes may only be applied to a SharedTree with the same IdCompressor instance and branch state from which they were generated.
* 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.
*
* @privateRemarks
* TODO: This method will support applying changes from different IdCompressor instances as long as they have the same local session ID.
* Update the tests and docs to match when that is done.
*/
applyChange(change: JsonCompatibleReadOnly): void;
}

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

/**
* Fired when:
Expand Down
50 changes: 50 additions & 0 deletions packages/dds/tree/src/test/simple-tree/api/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../../../simple-tree/fieldSchema.js";
// eslint-disable-next-line import-x/no-internal-modules
import type { UnhydratedFlexTreeNode } from "../../../simple-tree/core/index.js";
import type { JsonCompatibleReadOnly } from "../../../util/index.js";

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

Expand Down Expand Up @@ -287,4 +288,53 @@ describe("simple-tree tree", () => {
assert.equal(view.root, "beefbeef-beef-4000-8000-000000000001");
});
});

describe("Serialized changes", () => {
it("can be applied to a different branch", () => {
const config = new TreeViewConfiguration({ schema: schema.number });
const viewA = getView(config);
viewA.initialize(3);
const viewB = viewA.fork();

let change: JsonCompatibleReadOnly | undefined;
viewB.events.on("changed", (metadata) => {
assert(metadata.isLocal);
change = metadata.getChange();
});

viewB.root = 4;
assert(change !== undefined);
viewA.applyChange(change);
assert.equal(viewA.root, 4);
});

it("fail to apply to a branch in another session", () => {
const config = new TreeViewConfiguration({ schema: schema.number });
const viewA = getView(config);
viewA.initialize(3);
const viewB = getView(config);
viewB.initialize(3);

let change: JsonCompatibleReadOnly | undefined;
viewA.events.on("changed", (metadata) => {
assert(metadata.isLocal);
change = metadata.getChange();
});
viewA.root = 4;

const c = change ?? assert.fail("change not captured");
assert.throws(() => {
viewB.applyChange(c);
}, /cannot apply change.*same sharedtree/i);
});

it("error if malformed", () => {
const config = new TreeViewConfiguration({ schema: schema.number });
const viewA = getView(config);
viewA.initialize(3);
assert.throws(() => {
viewA.applyChange({ invalid: "bogus" });
}, /cannot apply change.*invalid.*format/i);
});
});
});
Loading
Loading