Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/dds/tree/src/core/rebase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,6 @@ export interface LocalChangeMetadata extends CommitMetadata {
* Returns a serializable object that encodes the change.
* @remarks This is only available for local changes.
* 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export class DefaultEditBuilder implements ChangeFamilyEditor, IDefaultEditBuild
fieldKinds,
changeReceiver,
codecOptions,
mintRevisionTag,
);
}

Expand All @@ -214,6 +215,10 @@ export class DefaultEditBuilder implements ChangeFamilyEditor, IDefaultEditBuild
this.modularBuilder.exitTransaction();
}

public applyExternalChange(change: DefaultChangeset): void {
this.modularBuilder.applyExternalChange(change);
}

public addNodeExistsConstraint(path: UpPath): void {
this.modularBuilder.addNodeExistsConstraint(path, this.mintRevisionTag());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
brand,
brandConst,
newIntegerRangeMap,
type IdAllocator,
type RangeMap,
type Mutable,
} from "../../util/index.js";
Expand All @@ -40,10 +41,25 @@ export class DefaultRevisionReplacer implements RevisionReplacer {
*/
private maxSeen: ChangesetLocalId = brandConst(-1)();

/**
* @param updatedRevision - The revision to assign to all replaced IDs.
* @param obsoleteRevisions - The set of revisions that should be replaced.
* @param idAllocator - If provided, IDs already allocated by this allocator will be reserved (avoided by the replacer),
* and the allocator will be updated to account for any new IDs allocated during replacement.
*/
public constructor(
public readonly updatedRevision: RevisionTag,
private readonly obsoleteRevisions: Set<RevisionTag | undefined>,
) {}
private readonly idAllocator?: IdAllocator,
) {
if (idAllocator !== undefined) {
const reservedIdCount = idAllocator.getMaxId() + 1;
if (reservedIdCount > 0) {
this.localIds.set(brand(0), reservedIdCount, true);
this.maxSeen = brand(reservedIdCount - 1);
}
}
}

public isObsolete(revision: RevisionTag | undefined): boolean {
return this.obsoleteRevisions.has(revision);
Expand Down Expand Up @@ -89,6 +105,12 @@ export class DefaultRevisionReplacer implements RevisionReplacer {
remainderStart = offsetChangeAtomId(remainderStart, prior.length);
remainderCount -= prior.length;
}

// Keep the allocator in sync with any new IDs we've allocated
if (this.idAllocator !== undefined && this.maxSeen >= this.idAllocator.getMaxId() + 1) {
this.idAllocator.allocate(this.maxSeen - this.idAllocator.getMaxId());
}

return updated;
}
return id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
getFirstFromCrossFieldMap,
setInCrossFieldMap,
} from "./crossFieldQueries.js";
import { DefaultRevisionReplacer } from "./defaultRevisionReplacer.js";
import {
type FieldChangeHandler,
NodeAttachState,
Expand Down Expand Up @@ -2686,6 +2687,7 @@ export class ModularEditBuilder extends EditBuilder<ModularChangeset> {
private readonly fieldKinds: ReadonlyMap<FieldKindIdentifier, FlexFieldKind>,
changeReceiver: (change: TaggedChange<ModularChangeset>) => void,
codecOptions: CodecWriteOptions,
private readonly mintRevisionTag?: () => RevisionTag,
) {
super(family, changeReceiver);
this.idAllocator = idAllocatorFromMaxId();
Expand All @@ -2707,6 +2709,27 @@ export class ModularEditBuilder extends EditBuilder<ModularChangeset> {
}
}

/**
* Apply a changeset that originated from a different editor.
* @remarks
* The revision for the change will be changed to a new revision (using `mintRevisionTag`) before application.
* The change will also have its local IDs replaced to avoid collisions with any IDs produced by this editor.
*/
public applyExternalChange(change: ModularChangeset): void {
assert(
this.mintRevisionTag !== undefined,
"mintRevisionTag is required to apply external changes",
);
const revision = this.mintRevisionTag();
Comment on lines +2719 to +2723
Copy link
Contributor

Choose a reason for hiding this comment

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

All the other editing methods take in a revision tag instead of minting one. Seems like we should be consistent here. Is it inconvenient to mint a revision at the call site?

const replacer = new DefaultRevisionReplacer(
revision,
this.changeFamily.rebaser.getRevisions(change),
this.idAllocator,
);
const newChange = this.changeFamily.rebaser.changeRevision(change, replacer);
this.applyChange(tagChange(newChange, revision));
}

/**
* Builds a new tree to use in an edit.
*
Expand Down
22 changes: 14 additions & 8 deletions packages/dds/tree/src/shared-tree/treeCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,7 @@ export class TreeCheckout implements ITreeCheckoutFork {
idCompressor: this.idCompressor,
originatorId: this.idCompressor.localSessionId,
revision,
schema: undefined, // By not passing the schema, we avoid compressing identifiers in identifier fields
};
const encodedChange = this.changeFamily.codecs.resolve(4).encode(change, context);

Expand Down Expand Up @@ -775,19 +776,24 @@ export class TreeCheckout implements ITreeCheckoutFork {
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,
originatorId,
revision,
Copy link
Contributor

Choose a reason for hiding this comment

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

This probably all works, but it's kludgy that we use revision without decoding it.

};
const decodedChange = this.changeFamily.codecs.resolve(4).decode(change, context);
// Apply the change to the branch, but _not_ the `activeBranch` - we do not support squashing serialized commits in a transaction.
this.#transaction.branch.apply(tagChange(decodedChange, revision));

// Extract the inner data change from the SharedTreeChange envelope.
// Serialized changes are always single data changes (not schema changes).
const innerChange = decodedChange.changes[0];
assert(
decodedChange.changes.length === 1 && innerChange?.type === "data",
0x1b2 /* Expected a single data change in serialized change */,
);

// Delegate to the editor, which will replace the revision, shift local IDs to avoid
// collisions with other changes in the same transaction, and apply the change.
this.#transaction.activeBranchEditor.applyExternalChange(innerChange.innerChange);
}

// Revision is the revision of the commit, if any, which caused this change.
Expand Down
9 changes: 4 additions & 5 deletions packages/dds/tree/src/simple-tree/api/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,11 @@ export interface TreeBranchAlpha extends TreeBranch, TreeContextAlpha {
* 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.
* @remarks Changes may only be applied to a SharedTree with the same 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, respectively.
Comment on lines +335 to +336
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The remarks currently say branches must "share the same history". The new tests demonstrate applying serialized changes between two independently created views (same content state but not a shared commit history). Please reword this to match the actual constraint (e.g., requires equivalent branch/content state and compatible schema), or clarify what “same history” means here.

Suggested change
* @remarks Changes may only be applied to a SharedTree with the same 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, respectively.
* @remarks Changes may only be applied to a branch whose content/branch state (and compatible schema) matches the state from which they were generated.
* They may be created by one branch and applied to another, including independently created views, as long as both branches are in an equivalent logical state at the time of creation and application (for example, by having applied the same sequence of edits or otherwise converged to the same content).

Copilot uses AI. Check for mistakes.
* The two branches may use different IdCompressor instances (e.g. across different runtimes or processes).
*
* @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.
* Applying changes is not idempotent, that is, the same serialized change applied twice will have two effects - it will not be deduplicated.
*/
applyChange(change: JsonCompatibleReadOnly): void;
}
Expand Down
Loading
Loading