diff --git a/packages/dds/tree/src/shared-tree/branchCheckout.ts b/packages/dds/tree/src/shared-tree/branchCheckout.ts new file mode 100644 index 000000000000..103ac7ffb662 --- /dev/null +++ b/packages/dds/tree/src/shared-tree/branchCheckout.ts @@ -0,0 +1,149 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { assert } from "@fluidframework/core-utils/internal"; +import type { IIdCompressor } from "@fluidframework/id-compressor"; +import { + UsageError, + type ITelemetryLoggerExt, +} from "@fluidframework/telemetry-utils/internal"; + +import type { + ChangeFamily, + DetachedFieldIndex, + IEditableForest, + RevisionTag, + RevisionTagCodec, + TreeStoredSchemaRepository, +} from "../core/index.js"; +import type { SharedTreeBranch } from "../shared-tree-core/index.js"; +import type { Breakable } from "../util/index.js"; +import { disposeSymbol } from "../util/index.js"; + +import type { SharedTreeChange } from "./sharedTreeChangeTypes.js"; +import type { SharedTreeEditBuilder } from "./sharedTreeEditBuilder.js"; +import { TreeCheckout } from "./treeCheckout.js"; + +/** + * Maps each {@link SharedTreeBranch} to its canonical {@link BranchCheckout}, if any. + * + * @remarks + * Keyed weakly by branch so the entry is collected once the branch is unreachable. + * The {@link BranchCheckout} constructor populates this on creation; {@link BranchCheckout.dispose} + * removes it so {@link getBranchCheckout} never returns a disposed instance. + */ +const branchCheckoutMap = new WeakMap< + SharedTreeBranch, + BranchCheckout +>(); + +/** + * Returns the live {@link BranchCheckout} bound to the given branch, or `undefined` if none exists + * (the branch was never wrapped in a `BranchCheckout`, or its `BranchCheckout` has been disposed). + * + * @internal + * @alpha + */ +export function getBranchCheckout( + branch: SharedTreeBranch, +): BranchCheckout | undefined { + return branchCheckoutMap.get(branch); +} + +/** + * A viewless checkout that is permanently bound to the {@link SharedTreeBranch} it was created over. + * + * @remarks + * Unlike {@link TreeCheckout}, a `BranchCheckout` cannot be retargeted to a different branch via `switchBranch` — + * calling it always throws a {@link UsageError}. + * + * It is "viewless" in the sense that no {@link SchematizingSimpleTreeView} is attached at construction time — + * a view can still be materialized on demand via the inherited `viewWith`. + * + * Lifecycle: a `BranchCheckout` is independent from any other checkout that observes the same data. + * Disposing the parent of a {@link forkAsBranchCheckout} does not dispose the child, and vice versa; + * merging is explicit via {@link TreeCheckout.merge}. + * + * @internal + * @alpha + */ +export class BranchCheckout extends TreeCheckout { + public constructor( + branch: SharedTreeBranch, + isSharedBranch: boolean, + changeFamily: ChangeFamily, + storedSchema: TreeStoredSchemaRepository, + forest: IEditableForest, + mintRevisionTag: () => RevisionTag, + revisionTagCodec: RevisionTagCodec, + idCompressor: IIdCompressor, + removedRoots?: DetachedFieldIndex, + logger?: ITelemetryLoggerExt, + breaker?: Breakable, + disposeForksAfterTransaction?: boolean, + ) { + // `isSharedBranch` is required by the base constructor signature (and by `forkWith`'s checkoutConstructor type), + // so we accept it positionally and reject the only invalid value here. + assert(!isSharedBranch, "BranchCheckout cannot represent a shared branch"); + super( + branch, + isSharedBranch, + changeFamily, + storedSchema, + forest, + mintRevisionTag, + revisionTagCodec, + idCompressor, + removedRoots, + logger, + breaker, + disposeForksAfterTransaction, + ); + branchCheckoutMap.set(branch, this); + } + + public override fork(): BranchCheckout { + return this.forkWith(BranchCheckout); + } + + /** + * Always throws — `BranchCheckout` is permanently bound to its branch. + * + * @remarks + * The parameter is preserved (and ignored) so this override is signature-compatible with + * {@link TreeCheckout.switchBranch}: substituting a `BranchCheckout` where a `TreeCheckout` is expected + * is type-safe, and the call still fails fast at runtime with a {@link UsageError}. + */ + public override switchBranch( + _branch: SharedTreeBranch, + ): never { + throw new UsageError("switchBranch is not supported on BranchCheckout"); + } + + public override [disposeSymbol](): void { + // Override the symbol-based entry point (not `dispose()`) because internal cleanup paths + // — notably the merge auto-dispose at `treeCheckout.ts` — call `checkout[disposeSymbol]()` + // directly. Hooking the symbol catches every disposal route. + super[disposeSymbol](); + // Only reached if super did not throw (e.g. double-dispose). + // Removing the entry here keeps `getBranchCheckout` from ever returning a disposed instance. + branchCheckoutMap.delete(this.mainBranch); + } +} + +/** + * Forks {@link parent} and wraps the new branch in a viewless {@link BranchCheckout}. + * + * @remarks + * Used to answer "give me the branch of this checkout, as its own checkout." + * The returned `BranchCheckout` is independent: edits do not affect {@link parent}, merging back must be explicit, + * and disposing either side does not dispose the other. + * + * @internal + * @alpha + */ +export function forkAsBranchCheckout(parent: TreeCheckout): BranchCheckout { + return parent.forkWith(BranchCheckout); +} diff --git a/packages/dds/tree/src/shared-tree/index.ts b/packages/dds/tree/src/shared-tree/index.ts index 17348fda2ae6..6a9333ca6254 100644 --- a/packages/dds/tree/src/shared-tree/index.ts +++ b/packages/dds/tree/src/shared-tree/index.ts @@ -34,6 +34,12 @@ export { export { SchematizingSimpleTreeView } from "./schematizingTreeView.js"; +export { + BranchCheckout, + forkAsBranchCheckout, + getBranchCheckout, +} from "./branchCheckout.js"; + export { initialize, initializerFromChunk } from "./schematizeTree.js"; export type { diff --git a/packages/dds/tree/src/shared-tree/treeCheckout.ts b/packages/dds/tree/src/shared-tree/treeCheckout.ts index 13ba631cafae..c8e0bd57ecc4 100644 --- a/packages/dds/tree/src/shared-tree/treeCheckout.ts +++ b/packages/dds/tree/src/shared-tree/treeCheckout.ts @@ -1131,8 +1131,34 @@ export class TreeCheckout implements ITreeCheckout { */ #transaction: SquashingTransactionStack; - @throwIfBroken public fork(): TreeCheckout { + return this.forkWith(TreeCheckout); + } + + /** + * Forks this checkout, constructing the resulting checkout via {@link checkoutConstructor}. + * @remarks + * Allows subclasses (e.g. `BranchCheckout`) to participate in forking without duplicating the fork machinery. + * The `@throwIfBroken` decorator is intentionally on this method (not on {@link TreeCheckout.fork}) so every + * entry point — including subclass overrides that call `forkWith` directly — gets the broken-state guard. + */ + @throwIfBroken + public forkWith( + checkoutConstructor: new ( + branch: SharedTreeBranch, + isSharedBranch: boolean, + changeFamily: ChangeFamily, + storedSchema: TreeStoredSchemaRepository, + forest: IEditableForest, + mintRevisionTag: () => RevisionTag, + revisionTagCodec: RevisionTagCodec, + idCompressor: IIdCompressor, + removedRoots?: DetachedFieldIndex, + logger?: ITelemetryLoggerExt, + breaker?: Breakable, + disposeForksAfterTransaction?: boolean, + ) => T, + ): T { this.checkNotDisposed( "The parent branch has already been disposed and can no longer create new branches.", ); @@ -1146,7 +1172,7 @@ export class TreeCheckout implements ITreeCheckout { const storedSchema = this.storedSchema.clone(); const forkBreaker = new Breakable("TreeCheckout"); const forest = this.forest.clone(storedSchema, forkBreaker); - const checkout = new TreeCheckout( + const checkout = new checkoutConstructor( branch, false, this.changeFamily, diff --git a/packages/dds/tree/src/test/shared-tree/branchCheckout.spec.ts b/packages/dds/tree/src/test/shared-tree/branchCheckout.spec.ts new file mode 100644 index 000000000000..d6498abbe250 --- /dev/null +++ b/packages/dds/tree/src/test/shared-tree/branchCheckout.spec.ts @@ -0,0 +1,237 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { validateUsageError } from "@fluidframework/test-runtime-utils/internal"; + +import { + BranchCheckout, + TreeCheckout, + forkAsBranchCheckout, + getBranchCheckout, +} from "../../shared-tree/index.js"; +import { SchemaFactory, TreeViewConfiguration } from "../../simple-tree/index.js"; +import { getView } from "../utils.js"; + +const enableSchemaValidation = true; + +describe("BranchCheckout", () => { + const schemaFactory = new SchemaFactory("BranchCheckout test schema"); + const Root = schemaFactory.object("Root", { x: schemaFactory.number }); + const config = new TreeViewConfiguration({ enableSchemaValidation, schema: Root }); + + function makeView() { + const view = getView(config); + view.initialize({ x: 0 }); + return view; + } + + describe("construction", () => { + it("returns a BranchCheckout that is also a TreeCheckout", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + assert.ok(branchCheckout instanceof BranchCheckout); + assert.ok(branchCheckout instanceof TreeCheckout); + }); + + it("the forked checkout is independent from the parent", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + assert.notStrictEqual(branchCheckout, view.checkout); + assert.notStrictEqual(branchCheckout.forest, view.checkout.forest); + assert.notStrictEqual(branchCheckout.storedSchema, view.checkout.storedSchema); + }); + + it("registers the new BranchCheckout in the canonical map", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + assert.strictEqual(getBranchCheckout(branchCheckout.mainBranch), branchCheckout); + }); + + it("isSharedBranch is false", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + assert.strictEqual(branchCheckout.isSharedBranch, false); + }); + + it("rejects construction over a shared branch", () => { + // The constructor's only invariant is `!isSharedBranch`. The assert fires before super() + // runs, so the remaining params are unreachable — `as never` reflects that intent without + // requiring real values. + assert.throws( + () => + new BranchCheckout( + undefined as never, + true, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + ), + /BranchCheckout cannot represent a shared branch/, + ); + }); + + it("forkAsBranchCheckout throws if the parent is in a broken state", () => { + // Regression guard: forkWith must apply the broken-state check, even though the + // `BranchCheckout.fork` override (and `forkAsBranchCheckout`) bypass `TreeCheckout.fork`. + const view = makeView(); + assert.throws(() => view.checkout.breaker.break(new Error("broken parent"))); + assert.throws( + () => forkAsBranchCheckout(view.checkout), + validateUsageError(/broken parent/), + ); + }); + + it("inherits disposeForksAfterTransaction from the parent", () => { + // `forkWith` propagates the parent's flag verbatim; without per-call configuration, + // the strongest invariant we can assert is structural equality with the parent's value. + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + assert.strictEqual( + branchCheckout.disposeForksAfterTransaction, + view.checkout.disposeForksAfterTransaction, + ); + // And it survives one more hop through `forkWith`: + const grandchild = branchCheckout.fork(); + assert.strictEqual( + grandchild.disposeForksAfterTransaction, + view.checkout.disposeForksAfterTransaction, + ); + }); + }); + + describe("viewless", () => { + it("can materialize a view on demand via viewWith", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + const branchView = branchCheckout.viewWith(config); + assert.strictEqual(branchView.root.x, 0); + }); + + it("each forkAsBranchCheckout has its own forest, so each can be viewed independently", () => { + const view = makeView(); + const a = forkAsBranchCheckout(view.checkout); + const b = forkAsBranchCheckout(view.checkout); + const viewA = a.viewWith(config); + const viewB = b.viewWith(config); + assert.notStrictEqual(a.forest, b.forest); + viewA.root.x = 1; + viewB.root.x = 2; + assert.strictEqual(viewA.root.x, 1); + assert.strictEqual(viewB.root.x, 2); + }); + }); + + describe("permanently bound to its branch", () => { + it("switchBranch throws regardless of which branch is passed", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + // The override preserves the base signature for LSP compatibility but ignores the arg. + // Pass the BranchCheckout's own branch — any value should produce the same UsageError. + assert.throws( + () => branchCheckout.switchBranch(branchCheckout.mainBranch), + validateUsageError(/switchBranch is not supported on BranchCheckout/), + ); + }); + + it("fork() returns another BranchCheckout (not a plain TreeCheckout)", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + const forked = branchCheckout.fork(); + assert.ok(forked instanceof BranchCheckout); + assert.strictEqual(getBranchCheckout(forked.mainBranch), forked); + }); + + it("forkAsBranchCheckout chains: forking a BranchCheckout yields an independent BranchCheckout", () => { + // `forkAsBranchCheckout` accepts any TreeCheckout, including a BranchCheckout. + // The resulting child must register under its own branch, not collide with the parent's + // registry entry, and edits must remain isolated. + const view = makeView(); + const parent = forkAsBranchCheckout(view.checkout); + const child = forkAsBranchCheckout(parent); + + assert.ok(child instanceof BranchCheckout); + assert.notStrictEqual(child, parent); + assert.notStrictEqual(child.mainBranch, parent.mainBranch); + assert.notStrictEqual(child.forest, parent.forest); + + // Both branches are registered, and lookups don't cross-contaminate. + assert.strictEqual(getBranchCheckout(child.mainBranch), child); + assert.strictEqual(getBranchCheckout(parent.mainBranch), parent); + + // Edits on the child are invisible to the parent (and to the original view). + const childView = child.viewWith(config); + childView.root.x = 11; + const parentView = parent.viewWith(config); + assert.strictEqual(parentView.root.x, 0); + assert.strictEqual(view.root.x, 0); + assert.strictEqual(childView.root.x, 11); + }); + }); + + describe("edits and merges", () => { + it("edits on the BranchCheckout do not affect the parent view", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + const branchView = branchCheckout.viewWith(config); + branchView.root.x = 42; + assert.strictEqual(branchView.root.x, 42); + assert.strictEqual(view.root.x, 0); + }); + + it("a BranchCheckout can be merged back into the parent view", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + const branchView = branchCheckout.viewWith(config); + branchView.root.x = 7; + view.merge(branchView); + assert.strictEqual(view.root.x, 7); + }); + }); + + describe("disposal", () => { + it("disposing the parent view does not dispose the BranchCheckout", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + view.dispose(); + assert.strictEqual(branchCheckout.disposed, false); + }); + + it("disposing the BranchCheckout does not affect the parent view", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + branchCheckout.dispose(); + assert.strictEqual(branchCheckout.disposed, true); + assert.strictEqual(view.checkout.disposed, false); + assert.strictEqual(view.root.x, 0); + }); + + it("dispose removes the BranchCheckout from the canonical map", () => { + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + const branch = branchCheckout.mainBranch; + assert.strictEqual(getBranchCheckout(branch), branchCheckout); + branchCheckout.dispose(); + assert.strictEqual(getBranchCheckout(branch), undefined); + }); + + it("merge auto-dispose also removes the BranchCheckout from the canonical map", () => { + // `TreeCheckout.merge` auto-disposes the merged checkout via `[disposeSymbol]()`, + // not `dispose()`. The override must hook the symbol to catch this path. + const view = makeView(); + const branchCheckout = forkAsBranchCheckout(view.checkout); + const branch = branchCheckout.mainBranch; + const branchView = branchCheckout.viewWith(config); + branchView.root.x = 9; + view.merge(branchView); + assert.strictEqual(branchCheckout.disposed, true); + assert.strictEqual(getBranchCheckout(branch), undefined); + }); + }); +});