From ab1b8459ac0a33c95b85397eada92688420c0869 Mon Sep 17 00:00:00 2001 From: Navin Agarwal Date: Fri, 14 Nov 2025 10:50:49 -0800 Subject: [PATCH 1/3] (tree) Add versioning to tree's summary --- .../detachedFieldIndexSummarizer.ts | 92 +++++++++++- .../forest-summary/forestSummarizer.ts | 51 ++++--- .../incrementalSummaryBuilder.ts | 35 +++-- .../feature-libraries/forest-summary/index.ts | 3 +- .../forest-summary/summaryTypes.ts | 75 ++++++++++ .../schema-index/schemaSummarizer.ts | 79 ++++++++++- .../shared-tree-core/editManagerSummarizer.ts | 87 +++++++++++- .../dds/tree/src/shared-tree-core/index.ts | 5 + .../src/shared-tree-core/sharedTreeCore.ts | 33 ++++- .../tree/src/shared-tree-core/summaryTypes.ts | 59 ++++++++ .../dds/tree/src/shared-tree/sharedTree.ts | 6 +- .../detachedFieldIndexSummarizer.spec.ts | 120 ++++++++++++++++ .../forest-summary/forestSummarizer.spec.ts | 111 +++++++++++++-- .../incrementalSummaryBuilder.spec.ts | 3 + .../schema-index/schemaSummarizer.spec.ts | 104 +++++++++++++- .../editManagerSummarizer.spec.ts | 132 ++++++++++++++++++ .../shared-tree-core/sharedTreeCore.spec.ts | 93 ++++++++++-- .../tree/src/test/shared-tree-core/utils.ts | 19 ++- packages/dds/tree/src/util/index.ts | 2 + .../dds/tree/src/util/readSnapshotBlob.ts | 23 +++ 20 files changed, 1048 insertions(+), 84 deletions(-) create mode 100644 packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts create mode 100644 packages/dds/tree/src/shared-tree-core/summaryTypes.ts create mode 100644 packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts create mode 100644 packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts create mode 100644 packages/dds/tree/src/util/readSnapshotBlob.ts diff --git a/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts b/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts index 118fce9be33d..9e06175febde 100644 --- a/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts @@ -4,13 +4,15 @@ */ import { bufferToString } from "@fluid-internal/client-utils"; +import { assert } from "@fluidframework/core-utils/internal"; import type { IChannelStorageService } from "@fluidframework/datastore-definitions/internal"; import type { IExperimentalIncrementalSummaryContext, ISummaryTreeWithStats, ITelemetryContext, + MinimumVersionForCollab, } from "@fluidframework/runtime-definitions/internal"; -import { createSingleBlobSummary } from "@fluidframework/shared-object-base/internal"; +import { SummaryTreeBuilder } from "@fluidframework/runtime-utils/internal"; import type { DetachedFieldIndex } from "../core/index.js"; import type { @@ -18,20 +20,80 @@ import type { SummaryElementParser, SummaryElementStringifier, } from "../shared-tree-core/index.js"; -import type { JsonCompatibleReadOnly } from "../util/index.js"; +import { + brand, + readAndParseSnapshotBlob, + type Brand, + type JsonCompatibleReadOnly, +} from "../util/index.js"; +import { FluidClientVersion } from "../codec/index.js"; /** * The storage key for the blob in the summary containing schema data */ const detachedFieldIndexBlobKey = "DetachedFieldIndexBlob"; +/** + * The storage key for the blob containing metadata for the detached field index's summary. + */ +export const detachedFieldIndexMetadataKey = ".metadata"; + +/** + * The versions for the detached field index summary. + */ +export const DetachedFieldIndexSummaryVersion = { + /** + * Version 0 represents summaries before versioning was added. This version is not written. + * It is only used to avoid undefined checks. + */ + v0: 0, + /** + * Version 1 adds metadata to the detached field index summary. + */ + v1: 1, +} as const; +export type DetachedFieldIndexSummaryVersion = Brand< + (typeof DetachedFieldIndexSummaryVersion)[keyof typeof DetachedFieldIndexSummaryVersion], + "DetachedFieldIndexSummaryVersion" +>; + +/** + * The type for the metadata in the detached field index's summary. + * Using type definition instead of interface to make this compatible with JsonCompatible. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type DetachedFieldIndexSummaryMetadata = { + /** The version of the detached field index summary. */ + readonly version: DetachedFieldIndexSummaryVersion; +}; + +/** + * Returns the summary version to use as per the given minimum version for collab. + */ +function minVersionToDetachedFieldIndexSummaryVersion( + version: MinimumVersionForCollab, +): DetachedFieldIndexSummaryVersion { + return version < FluidClientVersion.v2_73 + ? brand(DetachedFieldIndexSummaryVersion.v0) + : brand(DetachedFieldIndexSummaryVersion.v1); +} + /** * Provides methods for summarizing and loading a tree index. */ export class DetachedFieldIndexSummarizer implements Summarizable { public readonly key = "DetachedFieldIndex"; - public constructor(private readonly detachedFieldIndex: DetachedFieldIndex) {} + // The summary version to write in the metadata for the detached field index summary. + private readonly summaryWriteVersion: DetachedFieldIndexSummaryVersion; + + public constructor( + private readonly detachedFieldIndex: DetachedFieldIndex, + minVersionForCollab: MinimumVersionForCollab, + ) { + this.summaryWriteVersion = + minVersionToDetachedFieldIndexSummaryVersion(minVersionForCollab); + } public summarize(props: { stringify: SummaryElementStringifier; @@ -41,13 +103,35 @@ export class DetachedFieldIndexSummarizer implements Summarizable { incrementalSummaryContext?: IExperimentalIncrementalSummaryContext; }): ISummaryTreeWithStats { const data = this.detachedFieldIndex.encode(); - return createSingleBlobSummary(detachedFieldIndexBlobKey, props.stringify(data)); + const builder = new SummaryTreeBuilder(); + builder.addBlob(detachedFieldIndexBlobKey, props.stringify(data)); + + if (this.summaryWriteVersion >= DetachedFieldIndexSummaryVersion.v1) { + const metadata: DetachedFieldIndexSummaryMetadata = { + version: this.summaryWriteVersion, + }; + builder.addBlob(detachedFieldIndexMetadataKey, JSON.stringify(metadata)); + } + + return builder.getSummaryTree(); } public async load( services: IChannelStorageService, parse: SummaryElementParser, ): Promise { + if (await services.contains(detachedFieldIndexMetadataKey)) { + const metadata = await readAndParseSnapshotBlob( + detachedFieldIndexMetadataKey, + services, + parse, + ); + assert( + metadata.version >= DetachedFieldIndexSummaryVersion.v1, + "Unsupported detached field index summary", + ); + } + if (await services.contains(detachedFieldIndexBlobKey)) { const detachedFieldIndexBuffer = await services.readBlob(detachedFieldIndexBlobKey); const treeBufferString = bufferToString(detachedFieldIndexBuffer, "utf8"); diff --git a/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts b/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts index 4b43114baa1c..406a2bc2e1a4 100644 --- a/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. */ -import { bufferToString } from "@fluid-internal/client-utils"; import { assert } from "@fluidframework/core-utils/internal"; import type { IChannelStorageService } from "@fluidframework/datastore-definitions/internal"; import type { IIdCompressor } from "@fluidframework/id-compressor"; @@ -32,7 +31,7 @@ import type { SummaryElementParser, SummaryElementStringifier, } from "../../shared-tree-core/index.js"; -import { idAllocatorFromMaxId, type JsonCompatible } from "../../util/index.js"; +import { idAllocatorFromMaxId, readAndParseSnapshotBlob } from "../../util/index.js"; // eslint-disable-next-line import-x/no-internal-modules import { chunkFieldSingle, defaultChunkPolicy } from "../chunked-forest/chunkTree.js"; import { @@ -46,17 +45,16 @@ import { type ForestCodec, makeForestSummarizerCodec } from "./codec.js"; import { ForestIncrementalSummaryBehavior, ForestIncrementalSummaryBuilder, - forestSummaryContentKey, } from "./incrementalSummaryBuilder.js"; import { TreeCompressionStrategyExtended } from "../treeCompressionUtils.js"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; - -/** - * The key for the tree that contains the overall forest's summary tree. - * This tree is added by the parent of the forest summarizer. - * See {@link ForestIncrementalSummaryBuilder} for details on the summary structure. - */ -export const forestSummaryKey = "Forest"; +import { + forestSummaryContentKey, + forestSummaryKey, + forestSummaryMetadataKey, + ForestSummaryVersion, + minVersionToForestSummaryVersion, + type ForestSummaryMetadata, +} from "./summaryTypes.js"; /** * Provides methods for summarizing and loading a forest. @@ -89,6 +87,7 @@ export class ForestSummarizer implements Summarizable { (cursor: ITreeCursorSynchronous) => this.forest.chunkField(cursor), shouldEncodeIncrementally, initialSequenceNumber, + minVersionToForestSummaryVersion(options.minVersionForCollab), ); } @@ -164,24 +163,30 @@ export class ForestSummarizer implements Summarizable { 0xc21 /* Forest summary content missing in snapshot */, ); - const readAndParseBlob = async >( - id: string, - ): Promise => { - const treeBuffer = await services.readBlob(id); - const treeBufferString = bufferToString(treeBuffer, "utf8"); - return parse(treeBufferString) as T; - }; + if (await services.contains(forestSummaryMetadataKey)) { + const metadata = await readAndParseSnapshotBlob( + forestSummaryMetadataKey, + services, + parse, + ); + assert(metadata.version >= ForestSummaryVersion.v1, "Unsupported forest summary"); + } // Load the incremental summary builder so that it can download any incremental chunks in the // snapshot. - await this.incrementalSummaryBuilder.load(services, readAndParseBlob); + await this.incrementalSummaryBuilder.load(services, async (id: string) => + readAndParseSnapshotBlob(id, services, parse), + ); // TODO: this code is parsing data without an optional validator, this should be defined in a typebox schema as part of the // forest summary format. - const fields = this.codec.decode(await readAndParseBlob(forestSummaryContentKey), { - ...this.encoderContext, - incrementalEncoderDecoder: this.incrementalSummaryBuilder, - }); + const fields = this.codec.decode( + await readAndParseSnapshotBlob(forestSummaryContentKey, services, parse), + { + ...this.encoderContext, + incrementalEncoderDecoder: this.incrementalSummaryBuilder, + }, + ); const allocator = idAllocatorFromMaxId(); const fieldChanges: [FieldKey, DeltaFieldChanges][] = []; const build: DeltaDetachedNodeBuild[] = []; diff --git a/packages/dds/tree/src/feature-libraries/forest-summary/incrementalSummaryBuilder.ts b/packages/dds/tree/src/feature-libraries/forest-summary/incrementalSummaryBuilder.ts index 391e44338c97..a1336ca056ba 100644 --- a/packages/dds/tree/src/feature-libraries/forest-summary/incrementalSummaryBuilder.ts +++ b/packages/dds/tree/src/feature-libraries/forest-summary/incrementalSummaryBuilder.ts @@ -30,21 +30,13 @@ import type { ISnapshotTree } from "@fluidframework/driver-definitions/internal" import { LoggingError } from "@fluidframework/telemetry-utils/internal"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import type { SummaryElementStringifier } from "../../shared-tree-core/index.js"; - -/** - * The key for the blob under ForestSummarizer's root. - * This blob contains the ForestCodec's output. - * See {@link ForestIncrementalSummaryBuilder} for details on the summary structure. - */ -export const forestSummaryContentKey = "ForestTree"; - -/** - * The contents of an incremental chunk is under a summary tree node with its {@link ChunkReferenceId} as the key. - * The inline portion of the chunk content is encoded with the forest codec is stored in a blob with this key. - * The rest of the chunk contents is stored in the summary tree under the summary tree node. - * See the summary format in {@link ForestIncrementalSummaryBuilder} for more details. - */ -const chunkContentsBlobKey = "contents"; +import { + chunkContentsBlobKey, + forestSummaryContentKey, + forestSummaryMetadataKey, + ForestSummaryVersion, + type ForestSummaryMetadata, +} from "./summaryTypes.js"; /** * State that tells whether a summary is currently being tracked. @@ -291,6 +283,8 @@ export class ForestIncrementalSummaryBuilder implements IncrementalEncoderDecode private readonly getChunkAtCursor: (cursor: ITreeCursorSynchronous) => TreeChunk[], public readonly shouldEncodeIncrementally: IncrementalEncodingPolicy, private readonly initialSequenceNumber: number, + // The summary version to write in the metadata for the detached field index summary. + private readonly summaryWriteVersion: ForestSummaryVersion, ) {} /** @@ -462,6 +456,15 @@ export class ForestIncrementalSummaryBuilder implements IncrementalEncoderDecode return chunkReferenceIds; } + private maybeAddMetadataToSummary(summaryBuilder: SummaryTreeBuilder): void { + if (this.summaryWriteVersion >= ForestSummaryVersion.v1) { + const metadata: ForestSummaryMetadata = { + version: this.summaryWriteVersion, + }; + summaryBuilder.addBlob(forestSummaryMetadataKey, JSON.stringify(metadata)); + } + } + /** * Must be called after summary generation is complete to finish tracking the summary. * It clears any tracking state and deletes the tracking properties for summaries that are older than the @@ -479,10 +482,12 @@ export class ForestIncrementalSummaryBuilder implements IncrementalEncoderDecode if (!this.enableIncrementalSummary || incrementalSummaryContext === undefined) { const summaryBuilder = new SummaryTreeBuilder(); summaryBuilder.addBlob(forestSummaryContentKey, forestSummaryContent); + this.maybeAddMetadataToSummary(summaryBuilder); return summaryBuilder.getSummaryTree(); } validateTrackingSummary(this.forestSummaryState, this.trackedSummaryProperties); + this.maybeAddMetadataToSummary(this.trackedSummaryProperties.parentSummaryBuilder); this.trackedSummaryProperties.parentSummaryBuilder.addBlob( forestSummaryContentKey, diff --git a/packages/dds/tree/src/feature-libraries/forest-summary/index.ts b/packages/dds/tree/src/feature-libraries/forest-summary/index.ts index 46d033022366..5410c3a185e1 100644 --- a/packages/dds/tree/src/feature-libraries/forest-summary/index.ts +++ b/packages/dds/tree/src/feature-libraries/forest-summary/index.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. */ -export { forestSummaryKey, ForestSummarizer } from "./forestSummarizer.js"; +export { ForestSummarizer } from "./forestSummarizer.js"; export { getCodecTreeForForestFormat } from "./codec.js"; export { ForestFormatVersion } from "./format.js"; +export { forestSummaryKey } from "./summaryTypes.js"; diff --git a/packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts b/packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts new file mode 100644 index 000000000000..ff261732aed0 --- /dev/null +++ b/packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts @@ -0,0 +1,75 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; +import { FluidClientVersion } from "../../codec/index.js"; +import { brand, type Brand } from "../../util/index.js"; + +/** + * The key for the tree that contains the overall forest's summary tree. + * This tree is added by the parent of the forest summarizer. + * See {@link ForestIncrementalSummaryBuilder} for details on the summary structure. + */ +export const forestSummaryKey = "Forest"; + +/** + * The key for the blob under ForestSummarizer's root. + * This blob contains the ForestCodec's output. + * See {@link ForestIncrementalSummaryBuilder} for details on the summary structure. + */ +export const forestSummaryContentKey = "ForestTree"; + +/** + * The storage key for the blob containing metadata for the forest's summary. + */ +export const forestSummaryMetadataKey = ".metadata"; + +/** + * The contents of an incremental chunk is under a summary tree node with its {@link ChunkReferenceId} as the key. + * The inline portion of the chunk content is encoded with the forest codec is stored in a blob with this key. + * The rest of the chunk contents is stored in the summary tree under the summary tree node. + * See the summary format in {@link ForestIncrementalSummaryBuilder} for more details. + */ +export const chunkContentsBlobKey = "contents"; + +/** + * The versions for the forest summary. + */ +export const ForestSummaryVersion = { + /** + * Version 0 represents summaries before versioning was added. This version is not written. + * It is only used to avoid undefined checks. + */ + v0: 0, + /** + * Version 1 adds metadata to the forest summary. + */ + v1: 1, +} as const; +export type ForestSummaryVersion = Brand< + (typeof ForestSummaryVersion)[keyof typeof ForestSummaryVersion], + "ForestSummaryVersion" +>; + +/** + * The type for the metadata in forest's summary. + * Using type definition instead of interface to make this compatible with JsonCompatible. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ForestSummaryMetadata = { + /** The version of the forest summary. */ + readonly version: ForestSummaryVersion; +}; + +/** + * Returns the summary version to use as per the given minimum version for collab. + */ +export function minVersionToForestSummaryVersion( + version: MinimumVersionForCollab, +): ForestSummaryVersion { + return version < FluidClientVersion.v2_73 + ? brand(ForestSummaryVersion.v0) + : brand(ForestSummaryVersion.v1); +} diff --git a/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts b/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts index 6c437e23c441..701a37c25759 100644 --- a/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts @@ -11,10 +11,11 @@ import type { IExperimentalIncrementalSummaryContext, ISummaryTreeWithStats, ITelemetryContext, + MinimumVersionForCollab, } from "@fluidframework/runtime-definitions/internal"; import { SummaryTreeBuilder } from "@fluidframework/runtime-utils/internal"; -import type { IJsonCodec } from "../../codec/index.js"; +import { FluidClientVersion, type IJsonCodec } from "../../codec/index.js"; import { type MutableTreeStoredSchema, type SchemaFormatVersion, @@ -26,12 +27,63 @@ import type { SummaryElementParser, SummaryElementStringifier, } from "../../shared-tree-core/index.js"; -import type { JsonCompatible } from "../../util/index.js"; +import { + brand, + readAndParseSnapshotBlob, + type Brand, + type JsonCompatible, +} from "../../util/index.js"; import type { CollabWindow } from "../incrementalSummarizationUtils.js"; import { encodeRepo } from "./codec.js"; const schemaStringKey = "SchemaString"; + +/** + * The storage key for the blob containing metadata for the schema's summary. + */ +export const schemaMetadataKey = ".metadata"; + +/** + * The versions for the schema summary. + */ +export const SchemaSummaryVersion = { + /** + * Version 0 represents summaries before versioning was added. This version is not written. + * It is only used to avoid undefined checks. + */ + v0: 0, + /** + * Version 1 adds metadata to the schema summary. + */ + v1: 1, +} as const; +export type SchemaSummaryVersion = Brand< + (typeof SchemaSummaryVersion)[keyof typeof SchemaSummaryVersion], + "SchemaSummaryVersion" +>; + +/** + * The type for the metadata in schema's summary. + * Using type definition instead of interface to make this compatible with JsonCompatible. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type SchemaSummaryMetadata = { + /** The version of the schema summary. */ + readonly version: SchemaSummaryVersion; +}; + +/** + * Returns the summary version to use as per the given minimum version for collab. + */ +function minVersionToSchemaSummaryVersion( + version: MinimumVersionForCollab, +): SchemaSummaryVersion { + return version < FluidClientVersion.v2_73 + ? brand(SchemaSummaryVersion.v0) + : brand(SchemaSummaryVersion.v1); +} + /** * Provides methods for summarizing and loading a schema repository. */ @@ -40,16 +92,21 @@ export class SchemaSummarizer implements Summarizable { private schemaIndexLastChangedSeq: number | undefined; + /** The summary version to write in the metadata for the schema summary. */ + private readonly summaryWriteVersion: SchemaSummaryVersion; + public constructor( private readonly schema: MutableTreeStoredSchema, collabWindow: CollabWindow, private readonly codec: IJsonCodec, + minVersionForCollab: MinimumVersionForCollab, ) { this.schema.events.on("afterSchemaChange", () => { // Invalidate the cache, as we need to regenerate the blob if the schema changes // We are assuming that schema changes from remote ops are valid, as we are in a summarization context. this.schemaIndexLastChangedSeq = collabWindow.getCurrentSeq(); }); + this.summaryWriteVersion = minVersionToSchemaSummaryVersion(minVersionForCollab); } public summarize(props: { @@ -77,6 +134,14 @@ export class SchemaSummarizer implements Summarizable { const dataString = JSON.stringify(this.codec.encode(this.schema)); builder.addBlob(schemaStringKey, dataString); } + + // Add metadata if the summary version is v1 or higher. + if (this.summaryWriteVersion >= SchemaSummaryVersion.v1) { + const metadata: SchemaSummaryMetadata = { + version: this.summaryWriteVersion, + }; + builder.addBlob(schemaMetadataKey, JSON.stringify(metadata)); + } return builder.getSummaryTree(); } @@ -84,6 +149,16 @@ export class SchemaSummarizer implements Summarizable { services: IChannelStorageService, parse: SummaryElementParser, ): Promise { + // Read the metadata blob if present and validate the version. + if (await services.contains(schemaMetadataKey)) { + const metadata = await readAndParseSnapshotBlob( + schemaMetadataKey, + services, + parse, + ); + assert(metadata.version >= SchemaSummaryVersion.v1, "Unsupported schema summary"); + } + const schemaBuffer: ArrayBufferLike = await services.readBlob(schemaStringKey); // After the awaits, validate that the schema is in a clean state. // This detects any schema that could have been accidentally added through diff --git a/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts b/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts index 139bd1e28619..99001f7271df 100644 --- a/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts +++ b/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts @@ -11,12 +11,18 @@ import type { IExperimentalIncrementalSummaryContext, ISummaryTreeWithStats, ITelemetryContext, + MinimumVersionForCollab, } from "@fluidframework/runtime-definitions/internal"; -import { createSingleBlobSummary } from "@fluidframework/shared-object-base/internal"; +import { SummaryTreeBuilder } from "@fluidframework/runtime-utils/internal"; -import type { IJsonCodec } from "../codec/index.js"; +import { FluidClientVersion, type IJsonCodec } from "../codec/index.js"; import type { ChangeFamily, ChangeFamilyEditor, SchemaAndPolicy } from "../core/index.js"; -import type { JsonCompatibleReadOnly } from "../util/index.js"; +import { + brand, + readAndParseSnapshotBlob, + type Brand, + type JsonCompatibleReadOnly, +} from "../util/index.js"; import type { EditManager, SummaryData } from "./editManager.js"; import type { EditManagerEncodingContext } from "./editManagerCodecs.js"; @@ -28,12 +34,58 @@ import type { const stringKey = "String"; +/** + * The storage key for the blob containing metadata for the edit manager's summary. + */ +export const editManagerMetadataKey = ".metadata"; + +/** + * The summary version of the edit manager. + * + * @remarks + * The metadata does not get written for version v0, this value is only for clarity, and is used for asserting that there is no version property when loading old summaries without a metadata blob. + * v1: Adds a metadata blob to the summary, containing the version of the summary. + */ +export const EditManagerSummaryVersion = { + v0: 0, + v1: 1, +} as const; +export type EditManagerSummaryVersion = Brand< + (typeof EditManagerSummaryVersion)[keyof typeof EditManagerSummaryVersion], + "EditManagerSummaryVersion" +>; + +/** + * The type for the metadata in edit manager's summary. + * Using type definition instead of interface to make this compatible with JsonCompatible. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type EditManagerSummaryMetadata = { + readonly version: EditManagerSummaryVersion; +}; + +/** + * Returns the summary version to use as per the given minimum version for collab. + */ +function minVersionToEditManagerSummaryVersion( + version: MinimumVersionForCollab, +): EditManagerSummaryVersion { + return version < FluidClientVersion.v2_73 + ? brand(EditManagerSummaryVersion.v0) + : brand(EditManagerSummaryVersion.v1); +} + /** * Provides methods for summarizing and loading an `EditManager` */ export class EditManagerSummarizer implements Summarizable { public readonly key = "EditManager"; + /** + * The summary version to write in the metadata for the edit manager summary. + */ + private readonly summaryWriteVersion: EditManagerSummaryVersion; + public constructor( private readonly editManager: EditManager< ChangeFamilyEditor, @@ -47,8 +99,11 @@ export class EditManagerSummarizer implements Summarizable { EditManagerEncodingContext >, private readonly idCompressor: IIdCompressor, + minVersionForCollab: MinimumVersionForCollab, private readonly schemaAndPolicy?: SchemaAndPolicy, - ) {} + ) { + this.summaryWriteVersion = minVersionToEditManagerSummaryVersion(minVersionForCollab); + } public summarize(props: { stringify: SummaryElementStringifier; @@ -67,13 +122,35 @@ export class EditManagerSummarizer implements Summarizable { : { idCompressor: this.idCompressor }; const jsonCompatible = this.codec.encode(this.editManager.getSummaryData(), context); const dataString = stringify(jsonCompatible); - return createSingleBlobSummary(stringKey, dataString); + + const builder = new SummaryTreeBuilder(); + builder.addBlob(stringKey, dataString); + // Add metadata if the summary version is v1 or higher. + if (this.summaryWriteVersion >= EditManagerSummaryVersion.v1) { + const metadata: EditManagerSummaryMetadata = { + version: this.summaryWriteVersion, + }; + builder.addBlob(editManagerMetadataKey, JSON.stringify(metadata)); + } + return builder.getSummaryTree(); } public async load( services: IChannelStorageService, parse: SummaryElementParser, ): Promise { + if (await services.contains(editManagerMetadataKey)) { + const metadata = await readAndParseSnapshotBlob( + editManagerMetadataKey, + services, + parse, + ); + assert( + metadata.version >= EditManagerSummaryVersion.v1, + "Unsupported edit manager summary", + ); + } + const schemaBuffer: ArrayBufferLike = await services.readBlob(stringKey); // After the awaits, validate that the data is in a clean state. diff --git a/packages/dds/tree/src/shared-tree-core/index.ts b/packages/dds/tree/src/shared-tree-core/index.ts index f5bb1b9ae887..ff0d17ed44ae 100644 --- a/packages/dds/tree/src/shared-tree-core/index.ts +++ b/packages/dds/tree/src/shared-tree-core/index.ts @@ -29,6 +29,11 @@ export { type ClonableSchemaAndPolicy, type SharedTreeCoreOptionsInternal as SharedTreCoreOptionsInternal, } from "./sharedTreeCore.js"; +export { + SharedTreeSummaryVersion, + type SharedTreeSummaryMetadata, + treeSummaryMetadataKey, +} from "./summaryTypes.js"; export type { ResubmitMachine } from "./resubmitMachine.js"; export { DefaultResubmitMachine } from "./defaultResubmitMachine.js"; diff --git a/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts b/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts index cc14de227925..e8c4454914b6 100644 --- a/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts +++ b/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts @@ -44,6 +44,7 @@ import { type WithBreakable, throwIfBroken, breakingClass, + readAndParseSnapshotBlob, } from "../util/index.js"; import type { BranchId, SharedTreeBranch } from "./branch.js"; @@ -62,9 +63,13 @@ import { import type { DecodedMessage } from "./messageTypes.js"; import type { ResubmitMachine } from "./resubmitMachine.js"; import type { MessageFormatVersion } from "./messageFormat.js"; - -// TODO: Organize this to be adjacent to persisted types. -const summarizablesTreeKey = "indexes"; +import { + minVersionToSharedTreeSummaryVersion, + SharedTreeSummaryVersion, + summarizablesTreeKey, + treeSummaryMetadataKey, + type SharedTreeSummaryMetadata, +} from "./summaryTypes.js"; export interface ClonableSchemaAndPolicy extends SchemaAndPolicy { schema: TreeStoredSchemaRepository; @@ -113,6 +118,8 @@ export class SharedTreeCore private readonly schemaAndPolicy: ClonableSchemaAndPolicy; + private readonly summaryWriteVersion: SharedTreeSummaryVersion; + /** * @param summarizables - Summarizers for all indexes used by this tree * @param changeFamily - The change family @@ -173,11 +180,15 @@ export class SharedTreeCore revisionTagCodec, options, ); + this.summaryWriteVersion = minVersionToSharedTreeSummaryVersion( + options.minVersionForCollab, + ); this.summarizables = [ new EditManagerSummarizer( this.editManager, editManagerCodec, this.idCompressor, + options.minVersionForCollab, this.schemaAndPolicy, ), ...summarizables, @@ -234,8 +245,14 @@ export class SharedTreeCore }), ); } - builder.addWithStats(summarizablesTreeKey, summarizableBuilder.getSummaryTree()); + + if (this.summaryWriteVersion >= SharedTreeSummaryVersion.v1) { + const metadata: SharedTreeSummaryMetadata = { + version: this.summaryWriteVersion, + }; + builder.addBlob(treeSummaryMetadataKey, JSON.stringify(metadata)); + } return builder.getSummaryTree(); } @@ -244,6 +261,14 @@ export class SharedTreeCore this.getLocalBranch().getHead() === this.editManager.getTrunkHead("main"), 0xaaa /* All local changes should be applied to the trunk before loading from summary */, ); + if (await services.contains(treeSummaryMetadataKey)) { + const metadata = await readAndParseSnapshotBlob( + treeSummaryMetadataKey, + services, + (contents) => this.serializer.parse(contents), + ); + assert(metadata.version >= SharedTreeSummaryVersion.v1, "Unsupported tree summary"); + } const [editManagerSummarizer, ...summarizables] = this.summarizables; const loadEditManager = this.loadSummarizable(editManagerSummarizer, services); const loadSummarizables = summarizables.map(async (s) => diff --git a/packages/dds/tree/src/shared-tree-core/summaryTypes.ts b/packages/dds/tree/src/shared-tree-core/summaryTypes.ts new file mode 100644 index 000000000000..5c3af9a21006 --- /dev/null +++ b/packages/dds/tree/src/shared-tree-core/summaryTypes.ts @@ -0,0 +1,59 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; +import { brand, type Brand } from "../util/index.js"; +import { FluidClientVersion } from "../codec/index.js"; + +// TODO: Organize this to be adjacent to persisted types. +/** + * The storage key for the subtree containing all summarizable indexes in the SharedTree summary. + */ +export const summarizablesTreeKey = "indexes"; + +/** + * The storage key for the blob containing metadata for the SharedTree's summary. + */ +export const treeSummaryMetadataKey = ".metadata"; + +/** + * The versions for the SharedTree summary. + */ +export const SharedTreeSummaryVersion = { + /** + * Version 0 represents summaries before versioning was added. This version is not written. + * It is only used to avoid undefined checks. + */ + v0: 0, + /** + * Version 1 adds metadata to the SharedTree summary. + */ + v1: 1, +} as const; +export type SharedTreeSummaryVersion = Brand< + (typeof SharedTreeSummaryVersion)[keyof typeof SharedTreeSummaryVersion], + "SharedTreeSummaryVersion" +>; + +/** + * The type for the metadata in SharedTree's summary. + * Using type definition instead of interface to make this compatible with JsonCompatible. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type SharedTreeSummaryMetadata = { + /** The version of the SharedTree summary. */ + readonly version: SharedTreeSummaryVersion; +}; + +/** + * Returns the summary version to use as per the given minimum version for collab. + */ +export function minVersionToSharedTreeSummaryVersion( + version: MinimumVersionForCollab, +): SharedTreeSummaryVersion { + return version < FluidClientVersion.v2_73 + ? brand(SharedTreeSummaryVersion.v0) + : brand(SharedTreeSummaryVersion.v1); +} diff --git a/packages/dds/tree/src/shared-tree/sharedTree.ts b/packages/dds/tree/src/shared-tree/sharedTree.ts index 4f4d2975d7a4..acf8fa5ee943 100644 --- a/packages/dds/tree/src/shared-tree/sharedTree.ts +++ b/packages/dds/tree/src/shared-tree/sharedTree.ts @@ -241,6 +241,7 @@ export class SharedTreeKernel getCurrentSeq: lastSequenceNumber, }, schemaCodec, + options.minVersionForCollab, ); const fieldBatchCodec = makeFieldBatchCodec(options); @@ -263,7 +264,10 @@ export class SharedTreeKernel initialSequenceNumber, options.shouldEncodeIncrementally, ); - const removedRootsSummarizer = new DetachedFieldIndexSummarizer(removedRoots); + const removedRootsSummarizer = new DetachedFieldIndexSummarizer( + removedRoots, + options.minVersionForCollab, + ); const innerChangeFamily = new SharedTreeChangeFamily( revisionTagCodec, fieldBatchCodec, diff --git a/packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts b/packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts new file mode 100644 index 000000000000..0f479b275f5a --- /dev/null +++ b/packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts @@ -0,0 +1,120 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; +import { SummaryType, type SummaryObject } from "@fluidframework/driver-definitions/internal"; +import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; +import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; + +import { DetachedFieldIndex, type ForestRootId } from "../../core/index.js"; +import { + detachedFieldIndexMetadataKey, + DetachedFieldIndexSummarizer, + DetachedFieldIndexSummaryVersion, + type DetachedFieldIndexSummaryMetadata, + // eslint-disable-next-line import-x/no-internal-modules +} from "../../feature-libraries/detachedFieldIndexSummarizer.js"; +import { FluidClientVersion } from "../../codec/index.js"; +import { testIdCompressor, testRevisionTagCodec } from "../utils.js"; +import { type IdAllocator, idAllocatorFromMaxId } from "../../util/index.js"; + +function createDetachedFieldIndexSummarizer(options?: { + minVersionForCollab?: MinimumVersionForCollab; +}): { + summarizer: DetachedFieldIndexSummarizer; + index: DetachedFieldIndex; +} { + const index = new DetachedFieldIndex( + "test", + idAllocatorFromMaxId() as IdAllocator, + testRevisionTagCodec, + testIdCompressor, + ); + const summarizer = new DetachedFieldIndexSummarizer( + index, + options?.minVersionForCollab ?? FluidClientVersion.v2_73, + ); + return { summarizer, index }; +} + +describe("DetachedFieldIndexSummarizer", () => { + describe("Metadata blob validation", () => { + it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { + const { summarizer } = createDetachedFieldIndexSummarizer({ + minVersionForCollab: FluidClientVersion.v2_52, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[detachedFieldIndexMetadataKey]; + assert(metadataBlob === undefined, "Metadata blob should not exist"); + }); + + it("writes metadata blob with version 1 for minVersionForCollab 2.73.0", () => { + const { summarizer } = createDetachedFieldIndexSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[detachedFieldIndexMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as DetachedFieldIndexSummaryMetadata; + assert.equal( + metadataContent.version, + DetachedFieldIndexSummaryVersion.v1, + "Metadata version should be 1", + ); + }); + + it("loads with metadata blob with version >= 1", async () => { + const { summarizer } = createDetachedFieldIndexSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Verify metadata exists and has version = 1 + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[detachedFieldIndexMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as DetachedFieldIndexSummaryMetadata; + assert.equal( + metadataContent.version, + DetachedFieldIndexSummaryVersion.v1, + "Metadata version should be 1", + ); + + // Create a new DetachedFieldIndexSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const { summarizer: summarizer2 } = createDetachedFieldIndexSummarizer(); + + // Should load successfully with version >= 1 + await assert.doesNotReject( + async () => summarizer2.load(mockStorage, JSON.parse), + "Should load successfully with metadata version >= 1", + ); + }); + }); +}); diff --git a/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts b/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts index cd8c2736c3bb..9a5e2139d4b1 100644 --- a/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts +++ b/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts @@ -9,7 +9,10 @@ import { type ISummaryTree, type SummaryObject, } from "@fluidframework/driver-definitions"; -import type { IExperimentalIncrementalSummaryContext } from "@fluidframework/runtime-definitions/internal"; +import type { + IExperimentalIncrementalSummaryContext, + MinimumVersionForCollab, +} from "@fluidframework/runtime-definitions/internal"; import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; import { FormatValidatorBasic } from "../../../external-utilities/index.js"; @@ -49,8 +52,13 @@ import { TreeViewConfigurationAlpha, } from "../../../simple-tree/index.js"; import { fieldJsonCursor } from "../../json/index.js"; -// eslint-disable-next-line import-x/no-internal-modules -import { forestSummaryContentKey } from "../../../feature-libraries/forest-summary/incrementalSummaryBuilder.js"; +import { + forestSummaryContentKey, + forestSummaryMetadataKey, + ForestSummaryVersion, + type ForestSummaryMetadata, + // eslint-disable-next-line import-x/no-internal-modules +} from "../../../feature-libraries/forest-summary/summaryTypes.js"; import type { FieldKey, TreeNodeSchemaIdentifier } from "../../../core/index.js"; function createForestSummarizer(args: { @@ -61,6 +69,7 @@ function createForestSummarizer(args: { // The content and schema to initialize the forest with. By default, it is an empty forest. initialContent?: TreeStoredContentStrict; shouldEncodeIncrementally?: IncrementalEncodingPolicy; + minVersionForCollab?: MinimumVersionForCollab; }): { forestSummarizer: ForestSummarizer; checkout: TreeCheckout } { const { initialContent = { @@ -70,10 +79,11 @@ function createForestSummarizer(args: { encodeType, forestType, shouldEncodeIncrementally, + minVersionForCollab = FluidClientVersion.v2_73, } = args; const options: CodecWriteOptions = { jsonValidator: FormatValidatorBasic, - minVersionForCollab: FluidClientVersion.v2_73, + minVersionForCollab, }; const fieldBatchCodec = makeFieldBatchCodec(options); const checkout = checkoutWithContent(initialContent, { @@ -195,7 +205,11 @@ describe("ForestSummarizer", () => { ]; for (const { encodeType, testType, forestType } of testCases) { it(`can summarize empty ${testType} forest and load from it`, async () => { - const { forestSummarizer } = createForestSummarizer({ encodeType, forestType }); + const { forestSummarizer } = createForestSummarizer({ + encodeType, + forestType, + minVersionForCollab: FluidClientVersion.v2_52, + }); const summary = forestSummarizer.summarize({ stringify: JSON.stringify }); assert( Object.keys(summary.summary.tree).length === 1, @@ -213,6 +227,7 @@ describe("ForestSummarizer", () => { const { forestSummarizer: forestSummarizer2 } = createForestSummarizer({ encodeType, forestType, + minVersionForCollab: FluidClientVersion.v2_52, }); await assert.doesNotReject(async () => { await forestSummarizer2.load(mockStorage, JSON.parse); @@ -231,6 +246,7 @@ describe("ForestSummarizer", () => { initialContent, encodeType, forestType, + minVersionForCollab: FluidClientVersion.v2_52, }); const summary = forestSummarizer.summarize({ stringify: JSON.stringify }); assert( @@ -249,6 +265,7 @@ describe("ForestSummarizer", () => { const { forestSummarizer: forestSummarizer2 } = createForestSummarizer({ encodeType, forestType, + minVersionForCollab: FluidClientVersion.v2_52, }); await assert.doesNotReject(async () => { await forestSummarizer2.load(mockStorage, JSON.parse); @@ -262,13 +279,13 @@ describe("ForestSummarizer", () => { function validateSummaryIsIncremental(summary: ISummaryTree) { assert( - Object.keys(summary.tree).length >= 2, + Object.keys(summary.tree).length >= 3, "There should be at least one node for incremental fields", ); for (const [key, value] of Object.entries(summary.tree)) { - if (key === forestSummaryContentKey) { - assert(value.type === SummaryType.Blob, "Forest summary contents not found"); + if (key === forestSummaryContentKey || key === forestSummaryMetadataKey) { + assert(value.type === SummaryType.Blob, "Forest summary blob not as expected"); } else { assert(value.type === SummaryType.Tree, "Incremental summary node should be a tree"); } @@ -663,4 +680,82 @@ describe("ForestSummarizer", () => { }); }); }); + + describe("Metadata blob validation", () => { + it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { + const { forestSummarizer } = createForestSummarizer({ + encodeType: TreeCompressionStrategy.Compressed, + forestType: ForestTypeOptimized, + minVersionForCollab: FluidClientVersion.v2_52, + }); + + const summary = forestSummarizer.summarize({ stringify: JSON.stringify }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[forestSummaryMetadataKey]; + assert(metadataBlob === undefined, "Metadata blob should not exist"); + }); + + it("writes metadata blob with version 1 for minVersionForCollab 2.73.0", () => { + const { forestSummarizer } = createForestSummarizer({ + encodeType: TreeCompressionStrategy.Compressed, + forestType: ForestTypeOptimized, + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = forestSummarizer.summarize({ stringify: JSON.stringify }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[forestSummaryMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as ForestSummaryMetadata; + assert.equal( + metadataContent.version, + ForestSummaryVersion.v1, + "Metadata version should be 1", + ); + }); + + it("loads with metadata blob with version >= 1", async () => { + const { forestSummarizer } = createForestSummarizer({ + encodeType: TreeCompressionStrategy.Compressed, + forestType: ForestTypeOptimized, + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = forestSummarizer.summarize({ stringify: JSON.stringify }); + + // Verify metadata exists and has version = 1 + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[forestSummaryMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as ForestSummaryMetadata; + assert.equal( + metadataContent.version, + ForestSummaryVersion.v1, + "Metadata version should be 1", + ); + + // Create a new ForestSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const { forestSummarizer: forestSummarizer2 } = createForestSummarizer({ + encodeType: TreeCompressionStrategy.Compressed, + forestType: ForestTypeOptimized, + }); + + // Should load successfully with version >= 1 + await assert.doesNotReject( + async () => forestSummarizer2.load(mockStorage, JSON.parse), + "Should load successfully with metadata version >= 1", + ); + }); + }); }); diff --git a/packages/dds/tree/src/test/feature-libraries/forest-summary/incrementalSummaryBuilder.spec.ts b/packages/dds/tree/src/test/feature-libraries/forest-summary/incrementalSummaryBuilder.spec.ts index 911e363ef5a5..93bd42f02a65 100644 --- a/packages/dds/tree/src/test/feature-libraries/forest-summary/incrementalSummaryBuilder.spec.ts +++ b/packages/dds/tree/src/test/feature-libraries/forest-summary/incrementalSummaryBuilder.spec.ts @@ -17,6 +17,8 @@ import { ForestSummaryTrackingState, // eslint-disable-next-line import-x/no-internal-modules } from "../../../feature-libraries/forest-summary/incrementalSummaryBuilder.js"; +// eslint-disable-next-line import-x/no-internal-modules +import { ForestSummaryVersion } from "../../../feature-libraries/forest-summary/summaryTypes.js"; import { type EncodedFieldBatch, type ChunkReferenceId, @@ -102,6 +104,7 @@ describe("ForestIncrementalSummaryBuilder", () => { }, defaultIncrementalEncodingPolicy, initialSequenceNumber, + brand(ForestSummaryVersion.v1), ); } diff --git a/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts b/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts index d8df96fa37b4..8733e797813f 100644 --- a/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts +++ b/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts @@ -3,15 +3,26 @@ * Licensed under the MIT License. */ -import { storedEmptyFieldSchema } from "../../../core/index.js"; +import { strict as assert } from "node:assert"; +import { SummaryType, type SummaryObject } from "@fluidframework/driver-definitions/internal"; +import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; +import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; + +import { storedEmptyFieldSchema, TreeStoredSchemaRepository } from "../../../core/index.js"; import { encodeTreeSchema, + SchemaSummarizer, + SchemaSummaryVersion, + schemaMetadataKey, // eslint-disable-next-line import-x/no-internal-modules } from "../../../feature-libraries/schema-index/schemaSummarizer.js"; import { toInitialSchema } from "../../../simple-tree/index.js"; import { takeJsonSnapshot, useSnapshotDirectory } from "../../snapshots/index.js"; import { JsonAsTree } from "../../../jsonDomainSchema.js"; import { supportedSchemaFormats } from "./codecUtil.js"; +import { FluidClientVersion } from "../../../codec/index.js"; +// eslint-disable-next-line import-x/no-internal-modules +import type { CollabWindow } from "../../../feature-libraries/incrementalSummarizationUtils.js"; describe("schemaSummarizer", () => { describe("encodeTreeSchema", () => { @@ -34,4 +45,95 @@ describe("schemaSummarizer", () => { }); } }); + + describe("Metadata blob validation", () => { + function createSchemaSummarizer(options?: { + minVersionForCollab?: MinimumVersionForCollab; + }): SchemaSummarizer { + const schema = new TreeStoredSchemaRepository(); + const collabWindow: CollabWindow = { + getCurrentSeq: () => 0, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const codec: any = { + encode: (data: unknown) => data, + decode: (data: unknown) => data, + }; + return new SchemaSummarizer( + schema, + collabWindow, + codec, + options?.minVersionForCollab ?? FluidClientVersion.v2_73, + ); + } + + it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { + const summarizer = createSchemaSummarizer({ + minVersionForCollab: FluidClientVersion.v2_52, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = summary.summary.tree[schemaMetadataKey]; + assert(metadataBlob === undefined, "Metadata blob should not exist"); + }); + + it("writes metadata blob with version 1 for minVersionForCollab 2.73.0", () => { + const summarizer = createSchemaSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = summary.summary.tree[schemaMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse(metadataBlob.content as string) as { + version: number; + }; + assert.equal( + metadataContent.version, + SchemaSummaryVersion.v1, + "Metadata version should be 1", + ); + }); + + it("loads with metadata blob with version >= 1", async () => { + const summarizer = createSchemaSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Verify metadata exists and has version = 1 + const metadataBlob: SummaryObject | undefined = summary.summary.tree[schemaMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse(metadataBlob.content as string) as { + version: number; + }; + assert.equal(metadataContent.version, 1, "Metadata version should be 1"); + + // Create a new SchemaSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const summarizer2 = createSchemaSummarizer(); + + // Should load successfully with version >= 1 + await assert.doesNotReject( + async () => summarizer2.load(mockStorage, JSON.parse), + "Should load successfully with metadata version >= 1", + ); + }); + }); }); diff --git a/packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts b/packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts new file mode 100644 index 000000000000..e6059d9b19e2 --- /dev/null +++ b/packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts @@ -0,0 +1,132 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; +import { SummaryType, type SummaryObject } from "@fluidframework/driver-definitions/internal"; +import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; +import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; + +import { + EditManagerSummarizer, + makeEditManagerCodec, + editManagerFormatVersions, +} from "../../shared-tree-core/index.js"; +import { + editManagerMetadataKey, + EditManagerSummaryVersion, + // eslint-disable-next-line import-x/no-internal-modules +} from "../../shared-tree-core/editManagerSummarizer.js"; +import { DependentFormatVersion, FluidClientVersion } from "../../codec/index.js"; +import { testIdCompressor } from "../utils.js"; +import { RevisionTagCodec } from "../../core/index.js"; +import { FormatValidatorBasic } from "../../external-utilities/index.js"; +// eslint-disable-next-line import-x/no-internal-modules +import { editManagerFactory } from "./edit-manager/editManagerTestUtils.js"; +import { testChangeFamilyFactory } from "../testChange.js"; + +function createEditManagerSummarizer(options?: { + minVersionForCollab?: MinimumVersionForCollab; +}) { + const family = testChangeFamilyFactory(); + const editManager = editManagerFactory(family); + + const revisionTagCodec = new RevisionTagCodec(testIdCompressor); + // Use a simple passthrough DependentFormatVersion for testing + const changeFormatVersion = DependentFormatVersion.fromPairs( + Array.from(editManagerFormatVersions, (e) => [e, 1]), + ); + const minVersionForCollab = options?.minVersionForCollab ?? FluidClientVersion.v2_73; + const codec = makeEditManagerCodec(family.codecs, changeFormatVersion, revisionTagCodec, { + jsonValidator: FormatValidatorBasic, + minVersionForCollab, + }); + const summarizer = new EditManagerSummarizer( + editManager, + codec, + testIdCompressor, + minVersionForCollab, + ); + return { summarizer, editManager }; +} + +describe("EditManagerSummarizer", () => { + describe("Metadata blob validation", () => { + it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { + const { summarizer } = createEditManagerSummarizer({ + minVersionForCollab: FluidClientVersion.v2_52, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[editManagerMetadataKey]; + assert(metadataBlob === undefined, "Metadata blob should not exist"); + }); + + it("writes metadata blob with version 1 for minVersionForCollab 2.73.0", () => { + const { summarizer } = createEditManagerSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Check if metadata blob exists + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[editManagerMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse(metadataBlob.content as string) as { + version: number; + }; + assert.equal( + metadataContent.version, + EditManagerSummaryVersion.v1, + "Metadata version should be 1", + ); + }); + + it("loads with metadata blob with version >= 1", async () => { + const { summarizer } = createEditManagerSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + fullTree: false, + }); + + // Verify metadata exists and has version = 1 + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[editManagerMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse(metadataBlob.content as string) as { + version: number; + }; + assert.equal( + metadataContent.version, + EditManagerSummaryVersion.v1, + "Metadata version should be 1", + ); + + // Create a new EditManagerSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const { summarizer: summarizer2 } = createEditManagerSummarizer(); + + // Should load successfully with version >= 1 + await assert.doesNotReject( + async () => summarizer2.load(mockStorage, JSON.parse), + "Should load successfully with metadata version >= 1", + ); + }); + }); +}); diff --git a/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts b/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts index 8af0853f811b..fb12b4e18337 100644 --- a/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts +++ b/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts @@ -41,14 +41,17 @@ import type { ModularChangeset, } from "../../feature-libraries/index.js"; import { Tree } from "../../shared-tree/index.js"; -import type { - ChangeEnricherReadonlyCheckout, - EditManager, - ResubmitMachine, - SharedTreeCore, - Summarizable, - SummaryElementParser, - SummaryElementStringifier, +import { + SharedTreeSummaryVersion, + treeSummaryMetadataKey, + type ChangeEnricherReadonlyCheckout, + type EditManager, + type ResubmitMachine, + type SharedTreeCore, + type SharedTreeSummaryMetadata, + type Summarizable, + type SummaryElementParser, + type SummaryElementStringifier, } from "../../shared-tree-core/index.js"; import { brand, disposeSymbol } from "../../util/index.js"; import { @@ -58,15 +61,21 @@ import { TestTreeProviderLite, } from "../utils.js"; -import { createTree, createTreeSharedObject, TestSharedTreeCore } from "./utils.js"; +import { + createTree, + createTreeSharedObject, + testCodecOptions, + TestSharedTreeCore, +} from "./utils.js"; import { SchemaFactory, TreeViewConfiguration } from "../../simple-tree/index.js"; import { mockSerializer } from "../mockSerializer.js"; +import { FluidClientVersion } from "../../codec/index.js"; const enableSchemaValidation = true; describe("SharedTreeCore", () => { it("summarizes without indexes", async () => { - const tree = createTree([]); + const tree = createTree({ indexes: [] }); const { summary, stats } = tree.summarizeCore(mockSerializer); assert(summary !== undefined); assert(stats !== undefined); @@ -81,8 +90,8 @@ describe("SharedTreeCore", () => { let loaded = false; summarizable.on("loaded", () => (loaded = true)); const summarizables = [summarizable] as const; - const tree = createTree(summarizables); - const defaultSummary = createTree([]).summarizeCore(mockSerializer); + const tree = createTree({ indexes: summarizables }); + const defaultSummary = createTree({ indexes: [] }).summarizeCore(mockSerializer); await tree.loadCore( MockSharedObjectServices.createFromSummary(defaultSummary.summary).objectStorage, ); @@ -98,7 +107,7 @@ describe("SharedTreeCore", () => { } }); const summarizables = [summarizable] as const; - const tree = createTree(summarizables); + const tree = createTree({ indexes: summarizables }); const { summary } = tree.summarizeCore(mockSerializer); await tree.loadCore(MockSharedObjectServices.createFromSummary(summary).objectStorage); assert.equal(loadedBlob, true); @@ -112,7 +121,7 @@ describe("SharedTreeCore", () => { let summarizedB = false; summarizableB.on("summarizeAttached", () => (summarizedB = true)); const summarizables = [summarizableA, summarizableB] as const; - const tree = createTree(summarizables); + const tree = createTree({ indexes: summarizables }); const { summary, stats } = tree.summarizeCore(mockSerializer); assert(summarizedA, "Expected summarizable A to summarize"); assert(summarizedB, "Expected summarizable B to summarize"); @@ -135,6 +144,62 @@ describe("SharedTreeCore", () => { }); }); + describe.only("Summary metadata validation", () => { + it("does not write metadata blob for minVersionForCollab < 2.73.0", async () => { + const tree = createTree({ + indexes: [], + codecOptions: { ...testCodecOptions, minVersionForCollab: FluidClientVersion.v2_52 }, + }); + const { summary } = tree.summarizeCore(mockSerializer); + const metadataBlob: SummaryObject | undefined = summary.tree[treeSummaryMetadataKey]; + assert(metadataBlob === undefined, "Metadata blob should not exist"); + }); + + it("writes metadata blob with version 1 for minVersionForCollab 2.73.0", async () => { + const tree = createTree({ + indexes: [], + codecOptions: { ...testCodecOptions, minVersionForCollab: FluidClientVersion.v2_73 }, + }); + const { summary } = tree.summarizeCore(mockSerializer); + const metadataBlob: SummaryObject | undefined = summary.tree[treeSummaryMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as SharedTreeSummaryMetadata; + assert.equal( + metadataContent.version, + SharedTreeSummaryVersion.v1, + "Metadata version should be 1", + ); + }); + + it("loads with metadata blob with version >= 1", async () => { + const tree = createTree({ + indexes: [], + codecOptions: { ...testCodecOptions, minVersionForCollab: FluidClientVersion.v2_73 }, + }); + const { summary } = tree.summarizeCore(mockSerializer); + const metadataBlob: SummaryObject | undefined = summary.tree[treeSummaryMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as SharedTreeSummaryMetadata; + assert.equal( + metadataContent.version, + SharedTreeSummaryVersion.v1, + "Metadata version should be 1", + ); + + await assert.doesNotReject( + async () => + tree.loadCore(MockSharedObjectServices.createFromSummary(summary).objectStorage), + "Should load successfully with metadata version >= 1", + ); + }); + }); + it("evicts trunk commits behind the minimum sequence number", () => { const runtime = new MockFluidDataStoreRuntime({ idCompressor: createIdCompressor() }); const sharedObject = new TestSharedTreeCore(runtime); diff --git a/packages/dds/tree/src/test/shared-tree-core/utils.ts b/packages/dds/tree/src/test/shared-tree-core/utils.ts index 0242e968910b..926d3f41081d 100644 --- a/packages/dds/tree/src/test/shared-tree-core/utils.ts +++ b/packages/dds/tree/src/test/shared-tree-core/utils.ts @@ -86,7 +86,7 @@ import { // eslint-disable-next-line import-x/no-internal-modules } from "../../shared-tree/sharedTree.js"; -const codecOptions: CodecWriteOptions = { +export const testCodecOptions: CodecWriteOptions = { jsonValidator: FormatValidatorBasic, minVersionForCollab: currentVersion, }; @@ -97,11 +97,13 @@ class MockSharedObjectHandle extends MockHandle implements IShare } } -export function createTree( - indexes: TIndexes, - resubmitMachine?: ResubmitMachine, - enricher?: ChangeEnricherReadonlyCheckout, -): SharedTreeCore { +export function createTree(options: { + indexes: TIndexes; + resubmitMachine?: ResubmitMachine; + enricher?: ChangeEnricherReadonlyCheckout; + codecOptions?: CodecWriteOptions; +}): SharedTreeCore { + const { indexes, resubmitMachine, enricher, codecOptions } = options; // This could use TestSharedTreeCore then return its kernel instead of using these mocks, but that would depend on far more code than needed (including other mocks). // Summarizer requires ISharedObjectHandle. Specifically it looks for `bind` method. @@ -128,6 +130,7 @@ export function createTree( TreeCompressionStrategy.Uncompressed, createIdCompressor(), new TreeStoredSchemaRepository(), + codecOptions ?? testCodecOptions, resubmitMachine, enricher, )[0]; @@ -157,7 +160,9 @@ export function createTreeSharedObject export function makeTestDefaultChangeFamily(options?: { idCompressor?: IIdCompressor; chunkCompressionStrategy?: TreeCompressionStrategy; + codecOptions?: CodecWriteOptions; }) { + const codecOptions = options?.codecOptions ?? testCodecOptions; return new DefaultChangeFamily( makeModularChangeCodecFamily( fieldKindConfigurations, @@ -208,6 +213,7 @@ function createTreeInner( chunkCompressionStrategy: TreeCompressionStrategy, idCompressor: IIdCompressor, schema: TreeStoredSchemaRepository, + codecOptions: CodecWriteOptions = testCodecOptions, resubmitMachine?: ResubmitMachine, enricher?: ChangeEnricherReadonlyCheckout, editor?: () => DefaultEditBuilder, @@ -287,6 +293,7 @@ export class TestSharedTreeCore extends SharedObject { chunkCompressionStrategy, runtime.idCompressor, schema, + testCodecOptions, resubmitMachine, enricher, () => this.transaction.activeBranchEditor, diff --git a/packages/dds/tree/src/util/index.ts b/packages/dds/tree/src/util/index.ts index 2bafe3127fe2..995961379e7d 100644 --- a/packages/dds/tree/src/util/index.ts +++ b/packages/dds/tree/src/util/index.ts @@ -147,3 +147,5 @@ export { export { type TupleBTree, newTupleBTree, mergeTupleBTrees } from "./bTreeUtils.js"; export { cloneWithReplacements } from "./cloneWithReplacements.js"; + +export { readAndParseSnapshotBlob } from "./readSnapshotBlob.js"; diff --git a/packages/dds/tree/src/util/readSnapshotBlob.ts b/packages/dds/tree/src/util/readSnapshotBlob.ts new file mode 100644 index 000000000000..45a5ac39746d --- /dev/null +++ b/packages/dds/tree/src/util/readSnapshotBlob.ts @@ -0,0 +1,23 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import type { IChannelStorageService } from "@fluidframework/datastore-definitions/internal"; +import type { JsonCompatible } from "./utils.js"; +import { bufferToString } from "@fluid-internal/client-utils"; +import type { SummaryElementParser } from "../shared-tree-core/index.js"; + +/** + * Reads and parses a snapshot blob from storage service. + */ +export const readAndParseSnapshotBlob = async >( + id: string, + service: IChannelStorageService, + parse: SummaryElementParser, +): Promise => { + const treeBuffer = await service.readBlob(id); + const treeBufferString = bufferToString(treeBuffer, "utf8"); + return parse(treeBufferString) as T; +}; From f93be5d42ad69ffa598dc84595e5db8a70837d29 Mon Sep 17 00:00:00 2001 From: Navin Agarwal Date: Fri, 14 Nov 2025 11:10:17 -0800 Subject: [PATCH 2/3] Update load validation --- .../tree/src/feature-libraries/detachedFieldIndexSummarizer.ts | 2 +- .../src/feature-libraries/forest-summary/forestSummarizer.ts | 2 +- .../tree/src/feature-libraries/schema-index/schemaSummarizer.ts | 2 +- packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts | 2 +- packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts b/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts index 9e06175febde..14e2ce15973e 100644 --- a/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts @@ -127,7 +127,7 @@ export class DetachedFieldIndexSummarizer implements Summarizable { parse, ); assert( - metadata.version >= DetachedFieldIndexSummaryVersion.v1, + metadata.version === DetachedFieldIndexSummaryVersion.v1, "Unsupported detached field index summary", ); } diff --git a/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts b/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts index 406a2bc2e1a4..b17793bd7fa6 100644 --- a/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/forest-summary/forestSummarizer.ts @@ -169,7 +169,7 @@ export class ForestSummarizer implements Summarizable { services, parse, ); - assert(metadata.version >= ForestSummaryVersion.v1, "Unsupported forest summary"); + assert(metadata.version === ForestSummaryVersion.v1, "Unsupported forest summary"); } // Load the incremental summary builder so that it can download any incremental chunks in the diff --git a/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts b/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts index 701a37c25759..ddc9c8f35005 100644 --- a/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts @@ -156,7 +156,7 @@ export class SchemaSummarizer implements Summarizable { services, parse, ); - assert(metadata.version >= SchemaSummaryVersion.v1, "Unsupported schema summary"); + assert(metadata.version === SchemaSummaryVersion.v1, "Unsupported schema summary"); } const schemaBuffer: ArrayBufferLike = await services.readBlob(schemaStringKey); diff --git a/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts b/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts index 99001f7271df..b3456305c8e8 100644 --- a/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts +++ b/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts @@ -146,7 +146,7 @@ export class EditManagerSummarizer implements Summarizable { parse, ); assert( - metadata.version >= EditManagerSummaryVersion.v1, + metadata.version === EditManagerSummaryVersion.v1, "Unsupported edit manager summary", ); } diff --git a/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts b/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts index e8c4454914b6..cac34fa974d8 100644 --- a/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts +++ b/packages/dds/tree/src/shared-tree-core/sharedTreeCore.ts @@ -267,7 +267,7 @@ export class SharedTreeCore services, (contents) => this.serializer.parse(contents), ); - assert(metadata.version >= SharedTreeSummaryVersion.v1, "Unsupported tree summary"); + assert(metadata.version === SharedTreeSummaryVersion.v1, "Unsupported tree summary"); } const [editManagerSummarizer, ...summarizables] = this.summarizables; const loadEditManager = this.loadSummarizable(editManagerSummarizer, services); From 9af8d3f0bf31537d90281cf6b02dffed387c27e3 Mon Sep 17 00:00:00 2001 From: Navin Agarwal Date: Fri, 14 Nov 2025 11:42:18 -0800 Subject: [PATCH 3/3] Test updates --- .../detachedFieldIndexSummarizer.ts | 4 + .../forest-summary/summaryTypes.ts | 4 + .../schema-index/schemaSummarizer.ts | 6 +- .../shared-tree-core/editManagerSummarizer.ts | 11 +++ .../tree/src/shared-tree-core/summaryTypes.ts | 4 + .../detachedFieldIndexSummarizer.spec.ts | 50 +++++++++--- .../forest-summary/forestSummarizer.spec.ts | 46 +++++++++-- .../schema-index/schemaSummarizer.spec.ts | 78 +++++++++++++------ .../editManagerSummarizer.spec.ts | 46 +++++++++-- .../shared-tree-core/sharedTreeCore.spec.ts | 31 +++++++- 10 files changed, 229 insertions(+), 51 deletions(-) diff --git a/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts b/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts index 14e2ce15973e..7ed666b6bf25 100644 --- a/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/detachedFieldIndexSummarizer.ts @@ -51,6 +51,10 @@ export const DetachedFieldIndexSummaryVersion = { * Version 1 adds metadata to the detached field index summary. */ v1: 1, + /** + * The latest version of the detached field index summary. Must be updated when a new version is added. + */ + vLatest: 1, } as const; export type DetachedFieldIndexSummaryVersion = Brand< (typeof DetachedFieldIndexSummaryVersion)[keyof typeof DetachedFieldIndexSummaryVersion], diff --git a/packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts b/packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts index ff261732aed0..5f857121efb3 100644 --- a/packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts +++ b/packages/dds/tree/src/feature-libraries/forest-summary/summaryTypes.ts @@ -47,6 +47,10 @@ export const ForestSummaryVersion = { * Version 1 adds metadata to the forest summary. */ v1: 1, + /** + * The latest version of the forest summary. Must be updated when a new version is added. + */ + vLatest: 1, } as const; export type ForestSummaryVersion = Brand< (typeof ForestSummaryVersion)[keyof typeof ForestSummaryVersion], diff --git a/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts b/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts index ddc9c8f35005..d0e7dcbda77a 100644 --- a/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts +++ b/packages/dds/tree/src/feature-libraries/schema-index/schemaSummarizer.ts @@ -57,6 +57,10 @@ export const SchemaSummaryVersion = { * Version 1 adds metadata to the schema summary. */ v1: 1, + /** + * The latest version of the schema summary. Must be updated when a new version is added. + */ + vLatest: 1, } as const; export type SchemaSummaryVersion = Brand< (typeof SchemaSummaryVersion)[keyof typeof SchemaSummaryVersion], @@ -68,7 +72,7 @@ export type SchemaSummaryVersion = Brand< * Using type definition instead of interface to make this compatible with JsonCompatible. */ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type SchemaSummaryMetadata = { +export type SchemaSummaryMetadata = { /** The version of the schema summary. */ readonly version: SchemaSummaryVersion; }; diff --git a/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts b/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts index b3456305c8e8..062c999bbd00 100644 --- a/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts +++ b/packages/dds/tree/src/shared-tree-core/editManagerSummarizer.ts @@ -47,8 +47,19 @@ export const editManagerMetadataKey = ".metadata"; * v1: Adds a metadata blob to the summary, containing the version of the summary. */ export const EditManagerSummaryVersion = { + /** + * Version 0 represents summaries before versioning was added. This version is not written. + * It is only used to avoid undefined checks. + */ v0: 0, + /** + * Version 1 adds metadata to the schema summary. + */ v1: 1, + /** + * The latest version of the edit manager summary. Must be updated when a new version is added. + */ + vLatest: 1, } as const; export type EditManagerSummaryVersion = Brand< (typeof EditManagerSummaryVersion)[keyof typeof EditManagerSummaryVersion], diff --git a/packages/dds/tree/src/shared-tree-core/summaryTypes.ts b/packages/dds/tree/src/shared-tree-core/summaryTypes.ts index 5c3af9a21006..86f56b51e8bc 100644 --- a/packages/dds/tree/src/shared-tree-core/summaryTypes.ts +++ b/packages/dds/tree/src/shared-tree-core/summaryTypes.ts @@ -31,6 +31,10 @@ export const SharedTreeSummaryVersion = { * Version 1 adds metadata to the SharedTree summary. */ v1: 1, + /** + * The latest version of the SharedTree summary. Must be updated when a new version is added. + */ + vLatest: 1, } as const; export type SharedTreeSummaryVersion = Brand< (typeof SharedTreeSummaryVersion)[keyof typeof SharedTreeSummaryVersion], diff --git a/packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts b/packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts index 0f479b275f5a..7fbd39fb2e89 100644 --- a/packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts +++ b/packages/dds/tree/src/test/feature-libraries/detachedFieldIndexSummarizer.spec.ts @@ -6,7 +6,10 @@ import { strict as assert } from "node:assert"; import { SummaryType, type SummaryObject } from "@fluidframework/driver-definitions/internal"; import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; -import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; +import { + MockStorage, + validateAssertionError, +} from "@fluidframework/test-runtime-utils/internal"; import { DetachedFieldIndex, type ForestRootId } from "../../core/index.js"; import { @@ -18,7 +21,7 @@ import { } from "../../feature-libraries/detachedFieldIndexSummarizer.js"; import { FluidClientVersion } from "../../codec/index.js"; import { testIdCompressor, testRevisionTagCodec } from "../utils.js"; -import { type IdAllocator, idAllocatorFromMaxId } from "../../util/index.js"; +import { brand, type IdAllocator, idAllocatorFromMaxId } from "../../util/index.js"; function createDetachedFieldIndexSummarizer(options?: { minVersionForCollab?: MinimumVersionForCollab; @@ -40,7 +43,7 @@ function createDetachedFieldIndexSummarizer(options?: { } describe("DetachedFieldIndexSummarizer", () => { - describe("Metadata blob validation", () => { + describe("Summary metadata validation", () => { it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { const { summarizer } = createDetachedFieldIndexSummarizer({ minVersionForCollab: FluidClientVersion.v2_52, @@ -48,7 +51,6 @@ describe("DetachedFieldIndexSummarizer", () => { const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Check if metadata blob exists @@ -64,7 +66,6 @@ describe("DetachedFieldIndexSummarizer", () => { const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Check if metadata blob exists @@ -82,14 +83,13 @@ describe("DetachedFieldIndexSummarizer", () => { ); }); - it("loads with metadata blob with version >= 1", async () => { + it("loads with metadata blob with version 1", async () => { const { summarizer } = createDetachedFieldIndexSummarizer({ minVersionForCollab: FluidClientVersion.v2_73, }); const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Verify metadata exists and has version = 1 @@ -110,10 +110,42 @@ describe("DetachedFieldIndexSummarizer", () => { const mockStorage = MockStorage.createFromSummary(summary.summary); const { summarizer: summarizer2 } = createDetachedFieldIndexSummarizer(); - // Should load successfully with version >= 1 + // Should load successfully with version 1 await assert.doesNotReject( async () => summarizer2.load(mockStorage, JSON.parse), - "Should load successfully with metadata version >= 1", + "Should load successfully with metadata version 1", + ); + }); + + it("fail to load with metadata blob with version > latest", async () => { + const { summarizer } = createDetachedFieldIndexSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + }); + + // Modify metadata to have version > latest + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[detachedFieldIndexMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const modifiedMetadata: DetachedFieldIndexSummaryMetadata = { + version: brand( + (DetachedFieldIndexSummaryVersion.vLatest + 1) as DetachedFieldIndexSummaryVersion, + ), + }; + metadataBlob.content = JSON.stringify(modifiedMetadata); + + // Create a new DetachedFieldIndexSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const { summarizer: summarizer2 } = createDetachedFieldIndexSummarizer(); + + // Should fail to load with version > latest + await assert.rejects( + async () => summarizer2.load(mockStorage, JSON.parse), + (e: Error) => validateAssertionError(e, /Unsupported detached field index summary/), ); }); }); diff --git a/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts b/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts index 9a5e2139d4b1..b21bf4636b1e 100644 --- a/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts +++ b/packages/dds/tree/src/test/feature-libraries/forest-summary/forestSummarizer.spec.ts @@ -13,7 +13,10 @@ import type { IExperimentalIncrementalSummaryContext, MinimumVersionForCollab, } from "@fluidframework/runtime-definitions/internal"; -import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; +import { + MockStorage, + validateAssertionError, +} from "@fluidframework/test-runtime-utils/internal"; import { FormatValidatorBasic } from "../../../external-utilities/index.js"; import { FluidClientVersion, type CodecWriteOptions } from "../../../codec/index.js"; @@ -681,7 +684,7 @@ describe("ForestSummarizer", () => { }); }); - describe("Metadata blob validation", () => { + describe("Summary metadata validation", () => { it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { const { forestSummarizer } = createForestSummarizer({ encodeType: TreeCompressionStrategy.Compressed, @@ -721,7 +724,7 @@ describe("ForestSummarizer", () => { ); }); - it("loads with metadata blob with version >= 1", async () => { + it("loads with metadata blob with version 1", async () => { const { forestSummarizer } = createForestSummarizer({ encodeType: TreeCompressionStrategy.Compressed, forestType: ForestTypeOptimized, @@ -751,10 +754,43 @@ describe("ForestSummarizer", () => { forestType: ForestTypeOptimized, }); - // Should load successfully with version >= 1 + // Should load successfully with version 1 await assert.doesNotReject( async () => forestSummarizer2.load(mockStorage, JSON.parse), - "Should load successfully with metadata version >= 1", + "Should load successfully with metadata version 1", + ); + }); + + it("fail to load with metadata blob with version > latest", async () => { + const { forestSummarizer } = createForestSummarizer({ + encodeType: TreeCompressionStrategy.Compressed, + forestType: ForestTypeOptimized, + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = forestSummarizer.summarize({ stringify: JSON.stringify }); + + // Modify metadata to have version > latest + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[forestSummaryMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const modifiedMetadata: ForestSummaryMetadata = { + version: (ForestSummaryVersion.vLatest + 1) as ForestSummaryVersion, + }; + metadataBlob.content = JSON.stringify(modifiedMetadata); + + // Create a new ForestSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const { forestSummarizer: forestSummarizer2 } = createForestSummarizer({ + encodeType: TreeCompressionStrategy.Compressed, + forestType: ForestTypeOptimized, + }); + + // Should fail to load with version > latest + await assert.rejects( + async () => forestSummarizer2.load(mockStorage, JSON.parse), + (e: Error) => validateAssertionError(e, /Unsupported forest summary/), ); }); }); diff --git a/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts b/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts index 8733e797813f..ccb6f8e08e2d 100644 --- a/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts +++ b/packages/dds/tree/src/test/feature-libraries/schema-index/schemaSummarizer.spec.ts @@ -6,11 +6,15 @@ import { strict as assert } from "node:assert"; import { SummaryType, type SummaryObject } from "@fluidframework/driver-definitions/internal"; import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; -import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; +import { + MockStorage, + validateAssertionError, +} from "@fluidframework/test-runtime-utils/internal"; import { storedEmptyFieldSchema, TreeStoredSchemaRepository } from "../../../core/index.js"; import { encodeTreeSchema, + type SchemaSummaryMetadata, SchemaSummarizer, SchemaSummaryVersion, schemaMetadataKey, @@ -20,9 +24,11 @@ import { toInitialSchema } from "../../../simple-tree/index.js"; import { takeJsonSnapshot, useSnapshotDirectory } from "../../snapshots/index.js"; import { JsonAsTree } from "../../../jsonDomainSchema.js"; import { supportedSchemaFormats } from "./codecUtil.js"; -import { FluidClientVersion } from "../../../codec/index.js"; +import { FluidClientVersion, type CodecWriteOptions } from "../../../codec/index.js"; // eslint-disable-next-line import-x/no-internal-modules import type { CollabWindow } from "../../../feature-libraries/incrementalSummarizationUtils.js"; +import { makeSchemaCodec } from "../../../feature-libraries/index.js"; +import { FormatValidatorBasic } from "../../../external-utilities/index.js"; describe("schemaSummarizer", () => { describe("encodeTreeSchema", () => { @@ -46,7 +52,7 @@ describe("schemaSummarizer", () => { } }); - describe("Metadata blob validation", () => { + describe("Summary metadata validation", () => { function createSchemaSummarizer(options?: { minVersionForCollab?: MinimumVersionForCollab; }): SchemaSummarizer { @@ -54,17 +60,13 @@ describe("schemaSummarizer", () => { const collabWindow: CollabWindow = { getCurrentSeq: () => 0, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const codec: any = { - encode: (data: unknown) => data, - decode: (data: unknown) => data, + const minVersionForCollab = options?.minVersionForCollab ?? FluidClientVersion.v2_73; + const codecOptions: CodecWriteOptions = { + jsonValidator: FormatValidatorBasic, + minVersionForCollab, }; - return new SchemaSummarizer( - schema, - collabWindow, - codec, - options?.minVersionForCollab ?? FluidClientVersion.v2_73, - ); + const codec = makeSchemaCodec(codecOptions); + return new SchemaSummarizer(schema, collabWindow, codec, minVersionForCollab); } it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { @@ -74,7 +76,6 @@ describe("schemaSummarizer", () => { const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Check if metadata blob exists @@ -89,16 +90,15 @@ describe("schemaSummarizer", () => { const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Check if metadata blob exists const metadataBlob: SummaryObject | undefined = summary.summary.tree[schemaMetadataKey]; assert(metadataBlob !== undefined, "Metadata blob should exist"); assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); - const metadataContent = JSON.parse(metadataBlob.content as string) as { - version: number; - }; + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as SchemaSummaryMetadata; assert.equal( metadataContent.version, SchemaSummaryVersion.v1, @@ -106,33 +106,61 @@ describe("schemaSummarizer", () => { ); }); - it("loads with metadata blob with version >= 1", async () => { + it("loads with metadata blob with version 1", async () => { const summarizer = createSchemaSummarizer({ minVersionForCollab: FluidClientVersion.v2_73, }); const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Verify metadata exists and has version = 1 const metadataBlob: SummaryObject | undefined = summary.summary.tree[schemaMetadataKey]; assert(metadataBlob !== undefined, "Metadata blob should exist"); assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); - const metadataContent = JSON.parse(metadataBlob.content as string) as { - version: number; - }; + const metadataContent = JSON.parse( + metadataBlob.content as string, + ) as SchemaSummaryMetadata; assert.equal(metadataContent.version, 1, "Metadata version should be 1"); // Create a new SchemaSummarizer and load with the above summary const mockStorage = MockStorage.createFromSummary(summary.summary); const summarizer2 = createSchemaSummarizer(); - // Should load successfully with version >= 1 + // Should load successfully with version 1 await assert.doesNotReject( async () => summarizer2.load(mockStorage, JSON.parse), - "Should load successfully with metadata version >= 1", + "Should load successfully with metadata version 1", + ); + }); + + it("fail to load with metadata blob with version > latest", async () => { + const summarizer = createSchemaSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + }); + + // Modify metadata to have version > latest + const metadataBlob: SummaryObject | undefined = summary.summary.tree[schemaMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const modifiedMetadata = { + version: SchemaSummaryVersion.vLatest + 1, + }; + metadataBlob.content = JSON.stringify(modifiedMetadata); + + // Create a new SchemaSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const summarizer2 = createSchemaSummarizer(); + + // Should fail to load with version > latest + await assert.rejects( + async () => summarizer2.load(mockStorage, JSON.parse), + (e: Error) => validateAssertionError(e, /Unsupported schema summary/), ); }); }); diff --git a/packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts b/packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts index e6059d9b19e2..1bbe2d936422 100644 --- a/packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts +++ b/packages/dds/tree/src/test/shared-tree-core/editManagerSummarizer.spec.ts @@ -6,7 +6,10 @@ import { strict as assert } from "node:assert"; import { SummaryType, type SummaryObject } from "@fluidframework/driver-definitions/internal"; import type { MinimumVersionForCollab } from "@fluidframework/runtime-definitions/internal"; -import { MockStorage } from "@fluidframework/test-runtime-utils/internal"; +import { + MockStorage, + validateAssertionError, +} from "@fluidframework/test-runtime-utils/internal"; import { EditManagerSummarizer, @@ -52,7 +55,7 @@ function createEditManagerSummarizer(options?: { } describe("EditManagerSummarizer", () => { - describe("Metadata blob validation", () => { + describe("Summary metadata validation", () => { it("does not write metadata blob for minVersionForCollab < 2.73.0", () => { const { summarizer } = createEditManagerSummarizer({ minVersionForCollab: FluidClientVersion.v2_52, @@ -60,7 +63,6 @@ describe("EditManagerSummarizer", () => { const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Check if metadata blob exists @@ -76,7 +78,6 @@ describe("EditManagerSummarizer", () => { const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Check if metadata blob exists @@ -94,14 +95,13 @@ describe("EditManagerSummarizer", () => { ); }); - it("loads with metadata blob with version >= 1", async () => { + it("loads with metadata blob with version 1", async () => { const { summarizer } = createEditManagerSummarizer({ minVersionForCollab: FluidClientVersion.v2_73, }); const summary = summarizer.summarize({ stringify: JSON.stringify, - fullTree: false, }); // Verify metadata exists and has version = 1 @@ -122,10 +122,40 @@ describe("EditManagerSummarizer", () => { const mockStorage = MockStorage.createFromSummary(summary.summary); const { summarizer: summarizer2 } = createEditManagerSummarizer(); - // Should load successfully with version >= 1 + // Should load successfully with version 1 await assert.doesNotReject( async () => summarizer2.load(mockStorage, JSON.parse), - "Should load successfully with metadata version >= 1", + "Should load successfully with metadata version 1", + ); + }); + + it("fail to load with metadata blob with version > latest", async () => { + const { summarizer } = createEditManagerSummarizer({ + minVersionForCollab: FluidClientVersion.v2_73, + }); + + const summary = summarizer.summarize({ + stringify: JSON.stringify, + }); + + // Modify metadata to have version > latest + const metadataBlob: SummaryObject | undefined = + summary.summary.tree[editManagerMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const modifiedMetadata = { + version: EditManagerSummaryVersion.vLatest + 1, + }; + metadataBlob.content = JSON.stringify(modifiedMetadata); + + // Create a new EditManagerSummarizer and load with the above summary + const mockStorage = MockStorage.createFromSummary(summary.summary); + const { summarizer: summarizer2 } = createEditManagerSummarizer(); + + // Should fail to load with version > latest + await assert.rejects( + async () => summarizer2.load(mockStorage, JSON.parse), + (e: Error) => validateAssertionError(e, /Unsupported edit manager summary/), ); }); }); diff --git a/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts b/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts index fb12b4e18337..874f1675255b 100644 --- a/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts +++ b/packages/dds/tree/src/test/shared-tree-core/sharedTreeCore.spec.ts @@ -26,6 +26,7 @@ import { MockFluidDataStoreRuntime, MockSharedObjectServices, MockStorage, + validateAssertionError, } from "@fluidframework/test-runtime-utils/internal"; import { @@ -144,7 +145,7 @@ describe("SharedTreeCore", () => { }); }); - describe.only("Summary metadata validation", () => { + describe("Summary metadata validation", () => { it("does not write metadata blob for minVersionForCollab < 2.73.0", async () => { const tree = createTree({ indexes: [], @@ -174,7 +175,7 @@ describe("SharedTreeCore", () => { ); }); - it("loads with metadata blob with version >= 1", async () => { + it("loads with metadata blob with version 1", async () => { const tree = createTree({ indexes: [], codecOptions: { ...testCodecOptions, minVersionForCollab: FluidClientVersion.v2_73 }, @@ -195,7 +196,31 @@ describe("SharedTreeCore", () => { await assert.doesNotReject( async () => tree.loadCore(MockSharedObjectServices.createFromSummary(summary).objectStorage), - "Should load successfully with metadata version >= 1", + "Should load successfully with metadata version 1", + ); + }); + + it("fail to load with metadata blob with version > latest", async () => { + const tree = createTree({ + indexes: [], + codecOptions: { ...testCodecOptions, minVersionForCollab: FluidClientVersion.v2_73 }, + }); + const { summary } = tree.summarizeCore(mockSerializer); + + // Modify metadata to have version > latest + const metadataBlob: SummaryObject | undefined = summary.tree[treeSummaryMetadataKey]; + assert(metadataBlob !== undefined, "Metadata blob should exist"); + assert.equal(metadataBlob.type, SummaryType.Blob, "Metadata should be a blob"); + const modifiedMetadata: SharedTreeSummaryMetadata = { + version: (SharedTreeSummaryVersion.vLatest + 1) as SharedTreeSummaryVersion, + }; + metadataBlob.content = JSON.stringify(modifiedMetadata); + + // Should fail to load with version > latest + await assert.rejects( + async () => + tree.loadCore(MockSharedObjectServices.createFromSummary(summary).objectStorage), + (e: Error) => validateAssertionError(e, /Unsupported tree summary/), ); }); });