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 @@ -1470,6 +1479,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 @@ -1480,7 +1490,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
);
}
13 changes: 12 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,15 @@ 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 session ID and the same branch state from which they were generated.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "the same IdCompressor session ID" really enough? It seems that the implementation relies on the ID compressor instance being shared by the two branches, which seems like a stricter requirement.

I'm also curious how much value this new API is adding if the restriction is so tight. How will this be used in a way that normal branching and merging couldn't be used?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this api is to allow changes to be passed over a sandbox boundary, where normal branching and merging wouldn't be possible. The requirement for same id compressor session id because the approach we're planning is to essentially recreate an idcompressor in the sandbox for allocating ids and burning some ids on the original to allow for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "the same IdCompressor session ID" really enough though?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be, but we can also make sure with Noah when he's back. The session id will be shared between the id compressor instances and I don't think there is any other information that needs to be kept consistent between the original and sandboxed views when it comes to changes. If a change is passed in that has the same idCompressor session id but is an invalid change, the application itself should fail. If the id is the same and the change is valid, there's no reason we can't apply it, is there?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The potential reason we wouldn't be able to apply is if the ID compressor instance on the view where the change needs to be applied needs to first be notified so that in can adopt the new IDs generated by the other view.

If sharing the session ID is enough then it would be good to update the test so that it uses different compressor ID instances with the same session ID. I'm not sure how to do that, be presumably the Boards sandbox code will be doing that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, this may not be sufficient then. That's why we're planning to burn ids in advance but there's nothing about this that actually checks that the appropriate ids have been burned and I'm not sure exactly what will happen in that case. I would expect that change application to just fail but it probably wouldn't fail with a super useful message. I'll look into this and probably add a note but I don't think the pr needs to be blocked on this, as long as we do error in some way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems bad to have the API state a weaker requirement than what is really needed, so I think we should update the docs here to say "same ID Compressor instance".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to update the docs but "same instance" isn't entirely accurate since we're going to be generating from a different instance in most cases. How would you feel about "same ID Compressor state"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know enough about the ID compressor to know what will or won't work, but if you're going to be generating from a different instance then you're going to be doing something that isn't covered by the current test in this PR. So, either the current test needs to be updated so that the views use different ID compressor instances, or the requirements need be tightened to say that the same ID compressor instance must be used. Based on what you're saying, it sounds like the test needs to be updated.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll update the comment for now and leave a todo. I don't think updating the test is currently possible because the scenario I'm describing is what I'm going to be implementing next. I'll update the test and revert the comment along with that work.

* 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.
*/
applyChange(change: JsonCompatibleReadOnly): void;
}

/**
Expand Down Expand Up @@ -508,7 +519,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