diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index 1c8cd418eb47..a978520240d1 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -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; @@ -1487,6 +1496,7 @@ export interface TreeBranch extends IDisposable { // @alpha @sealed export interface TreeBranchAlpha extends TreeBranch { + applyChange(change: JsonCompatibleReadOnly): void; readonly events: Listenable_2; // (undocumented) fork(): TreeBranchAlpha; @@ -1497,7 +1507,7 @@ export interface TreeBranchAlpha extends TreeBranch { // @alpha @sealed export interface TreeBranchEvents extends Omit { - changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void; + changed(data: ChangeMetadata, getRevertible?: RevertibleAlphaFactory): void; commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void; } diff --git a/packages/dds/tree/src/core/index.ts b/packages/dds/tree/src/core/index.ts index 96618a30e7b5..75063f75b6cb 100644 --- a/packages/dds/tree/src/core/index.ts +++ b/packages/dds/tree/src/core/index.ts @@ -175,6 +175,7 @@ export { type GraphCommit, CommitKind, type CommitMetadata, + type ChangeMetadata, type RevisionTag, RevisionTagSchema, RevisionTagCodec, diff --git a/packages/dds/tree/src/core/rebase/index.ts b/packages/dds/tree/src/core/rebase/index.ts index 5955a3f087c8..cca8be02f4dd 100644 --- a/packages/dds/tree/src/core/rebase/index.ts +++ b/packages/dds/tree/src/core/rebase/index.ts @@ -12,6 +12,7 @@ export { type GraphCommit, CommitKind, type CommitMetadata, + type ChangeMetadata, type RevisionTag, RevisionTagSchema, type EncodedRevisionTag, diff --git a/packages/dds/tree/src/core/rebase/types.ts b/packages/dds/tree/src/core/rebase/types.ts index 0ec05e74b27b..1a562c8e599b 100644 --- a/packages/dds/tree/src/core/rebase/types.ts +++ b/packages/dds/tree/src/core/rebase/types.ts @@ -13,6 +13,7 @@ import { Type } from "@sinclair/typebox"; import { type Brand, + type JsonCompatibleReadOnly, type NestedMap, RangeMap, brand, @@ -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 diff --git a/packages/dds/tree/src/index.ts b/packages/dds/tree/src/index.ts index c1941cb61002..32dfda5918b9 100644 --- a/packages/dds/tree/src/index.ts +++ b/packages/dds/tree/src/index.ts @@ -9,6 +9,7 @@ export { CommitKind, RevertibleStatus, type CommitMetadata, + type ChangeMetadata, type RevertibleFactory, type RevertibleAlphaFactory, type RevertibleAlpha, diff --git a/packages/dds/tree/src/shared-tree-core/index.ts b/packages/dds/tree/src/shared-tree-core/index.ts index cefa23888091..3a70ec36fd2c 100644 --- a/packages/dds/tree/src/shared-tree-core/index.ts +++ b/packages/dds/tree/src/shared-tree-core/index.ts @@ -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, diff --git a/packages/dds/tree/src/shared-tree/schematizingTreeView.ts b/packages/dds/tree/src/shared-tree/schematizingTreeView.ts index b04d121ecb67..a2d4136783f1 100644 --- a/packages/dds/tree/src/shared-tree/schematizingTreeView.ts +++ b/packages/dds/tree/src/shared-tree/schematizingTreeView.ts @@ -66,6 +66,7 @@ import { type Breakable, breakingClass, disposeSymbol, + type JsonCompatibleReadOnly, type WithBreakable, } from "../util/index.js"; @@ -165,6 +166,10 @@ export class SchematizingSimpleTreeView< ); } + public applyChange(change: JsonCompatibleReadOnly): void { + this.checkout.applySerializedChange(change); + } + public hasRootSchema( schema: TSchema, ): this is TreeViewAlpha { diff --git a/packages/dds/tree/src/shared-tree/treeCheckout.ts b/packages/dds/tree/src/shared-tree/treeCheckout.ts index ca8c2c07100c..e2da2d497cde 100644 --- a/packages/dds/tree/src/shared-tree/treeCheckout.ts +++ b/packages/dds/tree/src/shared-tree/treeCheckout.ts @@ -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, @@ -20,7 +20,6 @@ import { type AnchorSetRootEvents, type ChangeFamily, CommitKind, - type CommitMetadata, type DeltaVisitor, type DetachedFieldIndex, type IEditableForest, @@ -48,6 +47,8 @@ import { type TreeNodeStoredSchema, LeafNodeStoredSchema, diffHistories, + type ChangeMetadata, + type ChangeEncodingContext, type ReadOnlyDetachedFieldIndex, } from "../core/index.js"; import { @@ -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"; @@ -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}. @@ -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. @@ -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)) { @@ -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. @@ -1210,3 +1264,24 @@ function verboseFromCursor( fields: fields as CustomTreeNode, }; } + +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; + return ( + change.version === 1 && + (change.revision === "root" || typeof change.revision === "number") && + typeof change.originatorId === "string" && + isStableId(change.originatorId) && + change.change !== undefined + ); +} diff --git a/packages/dds/tree/src/simple-tree/api/tree.ts b/packages/dds/tree/src/simple-tree/api/tree.ts index 02adcaeb29e9..2383d639c147 100644 --- a/packages/dds/tree/src/simple-tree/api/tree.ts +++ b/packages/dds/tree/src/simple-tree/api/tree.ts @@ -7,6 +7,7 @@ import type { IFluidLoadable, IDisposable, Listenable } from "@fluidframework/co import type { CommitMetadata, + ChangeMetadata, RevertibleAlphaFactory, RevertibleFactory, } from "../../core/index.js"; @@ -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. @@ -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; } /** @@ -508,7 +523,7 @@ export interface TreeBranchEvents extends Omit * @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: diff --git a/packages/dds/tree/src/test/simple-tree/api/tree.spec.ts b/packages/dds/tree/src/test/simple-tree/api/tree.spec.ts index 0f79e644e115..6c7d3b1d0272 100644 --- a/packages/dds/tree/src/test/simple-tree/api/tree.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/tree.spec.ts @@ -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"); @@ -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); + }); + }); }); diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md index 6f4371c8c59b..bf092f6148f5 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md @@ -141,6 +141,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; @@ -1879,6 +1888,7 @@ export interface TreeBranch extends IDisposable { // @alpha @sealed export interface TreeBranchAlpha extends TreeBranch { + applyChange(change: JsonCompatibleReadOnly): void; readonly events: Listenable; // (undocumented) fork(): TreeBranchAlpha; @@ -1889,7 +1899,7 @@ export interface TreeBranchAlpha extends TreeBranch { // @alpha @sealed export interface TreeBranchEvents extends Omit { - changed(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void; + changed(data: ChangeMetadata, getRevertible?: RevertibleAlphaFactory): void; commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void; }