diff --git a/packages/dds/tree/src/core/tree/visitDelta.ts b/packages/dds/tree/src/core/tree/visitDelta.ts index 799a051211d0..bf1e5e70e078 100644 --- a/packages/dds/tree/src/core/tree/visitDelta.ts +++ b/packages/dds/tree/src/core/tree/visitDelta.ts @@ -5,7 +5,10 @@ import { assert } from "@fluidframework/core-utils"; +import { NestedMap } from "../../index.js"; +import { setInNestedMap, tryGetFromNestedMap } from "../../util/index.js"; import { FieldKey } from "../schema-stored/index.js"; +import { ITreeCursorSynchronous } from "./cursor.js"; import * as Delta from "./delta.js"; import { ProtoNodes } from "./delta.js"; import { @@ -16,6 +19,7 @@ import { offsetDetachId, } from "./deltaUtil.js"; import { DetachedFieldIndex, ForestRootId } from "./detachedFieldIndex.js"; +import { Major, Minor } from "./detachedFieldIndexTypes.js"; import { NodeIndex, PlaceIndex, Range } from "./pathTree.js"; /** @@ -70,8 +74,16 @@ export function visitDelta( const attachPassRoots: Map = new Map(); const rootTransfers: Delta.DetachedNodeRename[] = []; const rootDestructions: Delta.DetachedNodeDestruction[] = []; + const refreshers: NestedMap = new Map(); + delta.refreshers?.forEach(({ id: { major, minor }, trees }) => { + for (let i = 0; i < trees.length; i += 1) { + const offsettedId = minor + i; + setInNestedMap(refreshers, major, offsettedId, trees[i]); + } + }); const detachConfig: PassConfig = { func: detachPass, + refreshers, detachedFieldIndex, detachPassRoots, attachPassRoots, @@ -84,6 +96,7 @@ export function visitDelta( transferRoots(rootTransfers, attachPassRoots, detachedFieldIndex, visitor); const attachConfig: PassConfig = { func: attachPass, + refreshers, detachedFieldIndex, detachPassRoots, attachPassRoots, @@ -289,7 +302,11 @@ export interface DeltaVisitor { interface PassConfig { readonly func: Pass; readonly detachedFieldIndex: DetachedFieldIndex; - + /** + * A mapping between forest root id and trees that represent refresher data. Each entry is only + * created in the forest once needed. + */ + readonly refreshers: NestedMap; /** * Nested changes on roots that need to be visited as part of the detach pass. * Each entry is removed when its associated changes are visited. @@ -359,7 +376,13 @@ function visitNode( function detachPass(delta: Delta.FieldChanges, visitor: DeltaVisitor, config: PassConfig): void { if (delta.global !== undefined) { for (const { id, fields } of delta.global) { - const root = config.detachedFieldIndex.getEntry(id); + let root = config.detachedFieldIndex.tryGetEntry(id); + if (root === undefined) { + const tree = tryGetFromNestedMap(config.refreshers, id.major, id.minor); + assert(tree !== undefined, "refresher data not found"); + buildTrees(id, [tree], config, visitor); + root = config.detachedFieldIndex.getEntry(id); + } config.detachPassRoots.set(root, fields); config.attachPassRoots.set(root, fields); } @@ -396,6 +419,25 @@ function detachPass(delta: Delta.FieldChanges, visitor: DeltaVisitor, config: Pa } } +function buildTrees( + id: Delta.DetachedNodeId, + trees: readonly ITreeCursorSynchronous[], + config: PassConfig, + visitor: DeltaVisitor, +) { + for (let i = 0; i < trees.length; i += 1) { + const offsettedId = offsetDetachId(id, i); + let root = config.detachedFieldIndex.tryGetEntry(offsettedId); + // Tree building is idempotent. We can therefore ignore build instructions for trees that already exist. + // The idempotence is leveraged by undo/redo as well as sandwich rebasing. + if (root === undefined) { + root = config.detachedFieldIndex.createEntry(offsettedId); + const field = config.detachedFieldIndex.toFieldKey(root); + visitor.create([trees[i]], field); + } + } +} + function processBuilds( builds: readonly Delta.DetachedNodeBuild[] | undefined, config: PassConfig, @@ -403,17 +445,7 @@ function processBuilds( ) { if (builds !== undefined) { for (const { id, trees } of builds) { - for (let i = 0; i < trees.length; i += 1) { - const offsettedId = offsetDetachId(id, i); - let root = config.detachedFieldIndex.tryGetEntry(offsettedId); - // Tree building is idempotent. We can therefore ignore build instructions for trees that already exist. - // The idempotence is leveraged by undo/redo as well as sandwich rebasing. - if (root === undefined) { - root = config.detachedFieldIndex.createEntry(offsettedId); - const field = config.detachedFieldIndex.toFieldKey(root); - visitor.create([trees[i]], field); - } - } + buildTrees(id, trees, config, visitor); } } } @@ -441,7 +473,17 @@ function attachPass(delta: Delta.FieldChanges, visitor: DeltaVisitor, config: Pa for (let i = 0; i < mark.count; i += 1) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const offsetAttachId = offsetDetachId(mark.attach!, i); - const sourceRoot = config.detachedFieldIndex.getEntry(offsetAttachId); + let sourceRoot = config.detachedFieldIndex.tryGetEntry(offsetAttachId); + if (sourceRoot === undefined) { + const tree = tryGetFromNestedMap( + config.refreshers, + offsetAttachId.major, + offsetAttachId.minor, + ); + assert(tree !== undefined, "refresher data not found"); + buildTrees(offsetAttachId, [tree], config, visitor); + sourceRoot = config.detachedFieldIndex.getEntry(offsetAttachId); + } const sourceField = config.detachedFieldIndex.toFieldKey(sourceRoot); const offsetIndex = index + i; if (isReplaceMark(mark)) { diff --git a/packages/dds/tree/src/test/tree/visitDelta.spec.ts b/packages/dds/tree/src/test/tree/visitDelta.spec.ts index 89d3937d959a..68b0387de627 100644 --- a/packages/dds/tree/src/test/tree/visitDelta.spec.ts +++ b/packages/dds/tree/src/test/tree/visitDelta.spec.ts @@ -990,6 +990,202 @@ describe("visitDelta", () => { { id: node1, root: 3 }, ]); }); + + describe("refreshers", () => { + it("for restores at the root", () => { + const index = makeDetachedFieldIndex("", testRevisionTagCodec); + const node = { minor: 42 }; + const rootFieldDelta: DeltaFieldChanges = { + local: [{ count: 1, attach: node }], + }; + const delta: DeltaRoot = { + refreshers: [{ id: node, trees: [content] }], + fields: new Map([[rootKey, rootFieldDelta]]), + }; + const expected: VisitScript = [ + ["enterField", rootKey], + ["exitField", rootKey], + ["enterField", rootKey], + ["create", [content], field0], + ["attach", field0, 1, 0], + ["exitField", rootKey], + ]; + testVisit(delta, expected, index); + assert.equal(index.entries().next().done, true); + }); + + it("for restores under a child", () => { + const index = makeDetachedFieldIndex("", testRevisionTagCodec); + const buildId = { minor: 42 }; + const rootFieldDelta: DeltaFieldChanges = { + local: [ + { + count: 1, + fields: new Map([[fooKey, { local: [{ count: 1, attach: buildId }] }]]), + }, + ], + }; + const expected: VisitScript = [ + ["enterField", rootKey], + ["enterNode", 0], + ["enterField", fooKey], + ["exitField", fooKey], + ["exitNode", 0], + ["exitField", rootKey], + ["enterField", rootKey], + ["enterNode", 0], + ["enterField", fooKey], + ["create", [content], field0], + ["attach", field0, 1, 0], + ["exitField", fooKey], + ["exitNode", 0], + ["exitField", rootKey], + ]; + const delta: DeltaRoot = { + refreshers: [{ id: buildId, trees: [content] }], + fields: new Map([[rootKey, rootFieldDelta]]), + }; + testVisit(delta, expected, index); + assert.equal(index.entries().next().done, true); + }); + + it("for partial restores", () => { + const index = makeDetachedFieldIndex("", testRevisionTagCodec); + const node = { minor: 42 }; + const rootFieldDelta: DeltaFieldChanges = { + local: [{ count: 1, attach: { minor: 43 } }], + }; + const delta: DeltaRoot = { + refreshers: [{ id: node, trees: [content, content] }], + fields: new Map([[rootKey, rootFieldDelta]]), + }; + const expected: VisitScript = [ + ["enterField", rootKey], + ["exitField", rootKey], + ["enterField", rootKey], + ["create", [content], field0], + ["attach", field0, 1, 0], + ["exitField", rootKey], + ]; + testVisit(delta, expected, index); + assert.equal(index.entries().next().done, true); + }); + + it("for changes to repair data", () => { + const index = makeDetachedFieldIndex("", testRevisionTagCodec); + const refresherId = { minor: 42 }; + const buildId = { minor: 43 }; + const rootFieldDelta: DeltaFieldChanges = { + global: [ + { + id: refresherId, + fields: new Map([[fooKey, { local: [{ count: 1, attach: buildId }] }]]), + }, + ], + }; + const expected: VisitScript = [ + ["create", [content], field0], + ["enterField", rootKey], + ["create", [content], field1], + ["exitField", rootKey], + ["enterField", field1], + ["enterNode", 0], + ["enterField", fooKey], + ["exitField", fooKey], + ["exitNode", 0], + ["exitField", field1], + ["enterField", rootKey], + ["exitField", rootKey], + ["enterField", field1], + ["enterNode", 0], + ["enterField", fooKey], + ["attach", field0, 1, 0], + ["exitField", fooKey], + ["exitNode", 0], + ["exitField", field1], + ]; + const delta: DeltaRoot = { + refreshers: [{ id: refresherId, trees: [content] }], + build: [{ id: buildId, trees: [content] }], + fields: new Map([[rootKey, rootFieldDelta]]), + }; + testVisit(delta, expected, index); + }); + }); + + describe("tolerates superfluous refreshers", () => { + it("when the delta can be applied without the refresher", () => { + const index = makeDetachedFieldIndex("", testRevisionTagCodec); + const node = { minor: 42 }; + const node2 = { minor: 43 }; + const rootFieldDelta: DeltaFieldChanges = { + local: [{ count: 1, attach: node2 }], + }; + const delta: DeltaRoot = { + refreshers: [ + { id: node, trees: [content] }, + { id: node2, trees: [content] }, + ], + fields: new Map([[rootKey, rootFieldDelta]]), + }; + const expected: VisitScript = [ + ["enterField", rootKey], + ["exitField", rootKey], + ["enterField", rootKey], + ["create", [content], field0], + ["attach", field0, 1, 0], + ["exitField", rootKey], + ]; + testVisit(delta, expected, index); + assert.equal(index.entries().next().done, true); + }); + + it("when the refreshed tree already exists in the forest", () => { + const index = makeDetachedFieldIndex("", testRevisionTagCodec); + const node = { minor: 42 }; + index.createEntry(node, 1); + const rootFieldDelta: DeltaFieldChanges = { + local: [{ count: 1, attach: node }], + }; + const delta: DeltaRoot = { + refreshers: [{ id: node, trees: [content] }], + fields: new Map([[rootKey, rootFieldDelta]]), + }; + const expected: VisitScript = [ + ["enterField", rootKey], + ["exitField", rootKey], + ["enterField", rootKey], + ["attach", field0, 1, 0], + ["exitField", rootKey], + ]; + testVisit(delta, expected, index); + assert.equal(index.entries().next().done, true); + }); + + it("when the refreshed tree is included in the builds", () => { + const index = makeDetachedFieldIndex("", testRevisionTagCodec); + const node = { minor: 42 }; + const rootFieldDelta: DeltaFieldChanges = { + local: [{ count: 1, attach: node }], + }; + const delta: DeltaRoot = { + build: [{ id: node, trees: [content] }], + refreshers: [{ id: node, trees: [content] }], + fields: new Map([[rootKey, rootFieldDelta]]), + }; + const expected: VisitScript = [ + ["create", [content], field0], + ["enterField", rootKey], + ["exitField", rootKey], + ["enterField", rootKey], + ["attach", field0, 1, 0], + ["exitField", rootKey], + ]; + testVisit(delta, expected, index); + assert.equal(index.entries().next().done, true); + }); + }); + describe("rename chains", () => { const pointA = { minor: 1 }; for (const cycle of [false, true]) {