Skip to content

Shared Tree: Persisted Schema Format v2 with persisted metadata support #24590

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 40 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
bd8406a
Initial change.
TommyBrosman May 12, 2025
3165fe3
persistedMetadata is now backed by an object instead of a string.
TommyBrosman May 13, 2025
9709ede
Updated codec.spec.ts to cover FormatV2.
TommyBrosman May 13, 2025
ba058e1
Removed extraneous whitespace change.
TommyBrosman May 13, 2025
b819742
- Changed TreeNodeStoredSchema implementations to take the write vers…
TommyBrosman May 13, 2025
0b5c842
- Changed the stored schema implementation to handle multiple schema …
TommyBrosman May 13, 2025
ecdca7a
Merge branch 'main' into metadata-schema-3
TommyBrosman May 13, 2025
1117c2c
Apply suggestions from code review
TommyBrosman May 14, 2025
94a5c39
- Switched to using the SchemaCodecVersion in public APIs. Eventually…
TommyBrosman May 14, 2025
302cb21
Merge branch 'metadata-schema-3' of https://github.com/TommyBrosman/F…
TommyBrosman May 14, 2025
0ba34d1
- Removed the schema version constant and replaced it with SchemaCode…
TommyBrosman May 14, 2025
3275ee0
Minor: reverted changes to FluidClientVersion utils.
TommyBrosman May 15, 2025
7c48b9d
Switched back to using min client version for importCompressed.
TommyBrosman May 15, 2025
a2f4195
Minor: reverted accidental change.
TommyBrosman May 15, 2025
4786dcc
Apply suggestions from code review
TommyBrosman May 15, 2025
6fa8836
Made persisted metadata schema field naming consistent.
TommyBrosman May 15, 2025
e4064c7
- Created a separate storedSchemaDecodeDispatcher for v2 schemas.
TommyBrosman May 16, 2025
ab1b882
- Refactored the node kind dispatch.
TommyBrosman May 19, 2025
1027daa
Updated snapshots.
TommyBrosman May 19, 2025
e527eba
- Removed minimum client version changes. Equivalent changes will be …
TommyBrosman May 19, 2025
3eae1f6
Removed more minimumClientVersion glue.
TommyBrosman May 19, 2025
98e1b76
Updated API files.
TommyBrosman May 19, 2025
ecc77c9
Rename: SchemaCodecVersion -> SchemaVersion.
TommyBrosman May 20, 2025
37e900f
Merge branch 'main' into metadata-schema-3
TommyBrosman May 20, 2025
737633b
Removed top-level persisted metadata.
TommyBrosman May 21, 2025
4bb136c
Fixed comments.
TommyBrosman May 22, 2025
0bcb90f
Exposed persistedMetadata as a JsonCompatibleReadOnlyObject.
TommyBrosman May 22, 2025
a675338
Changeset.
TommyBrosman May 22, 2025
1d1ef5f
Wired up toStoredSchema. Still needs tests.
TommyBrosman May 22, 2025
b9898e8
- Updated an old snapshot that didn't include the metadata field.
TommyBrosman May 22, 2025
6ca189a
Refactor: persistedMetadata -> metadata on persisted types.
TommyBrosman May 27, 2025
85240dc
Apply suggestions from code review
TommyBrosman May 27, 2025
8883d45
- Updated the changeset description
TommyBrosman May 28, 2025
9c2c13f
Reverted unnecessary change.
TommyBrosman May 28, 2025
c62f7ec
Wired up node schema metadata persistence. Currently errors on tests …
TommyBrosman May 29, 2025
2f6b519
Reverted unneeded change.
TommyBrosman May 29, 2025
ae1779b
Removed tests for the alpha API I removed in a previous revision.
TommyBrosman May 29, 2025
7eff8ab
This revision adds simple-tree persistence for node and field schema …
TommyBrosman May 30, 2025
f3f572c
- Fixed missing persistedMetadata on fields. toStoredSchema.ts and sh…
TommyBrosman Jun 2, 2025
faaf2f7
Merge branch 'main' into metadata-schema-3
TommyBrosman Jun 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/brown-dingos-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@fluidframework/tree": minor
"fluid-framework": minor
"__section": tree
---
Add APIs for declaring "persisted" schema metadata

Add alpha APIs for declaring node and field schema metadata which future versions of the Fluid Framework will provide a way to opt into persisting in the document.
85 changes: 57 additions & 28 deletions packages/dds/tree/api-report/tree.alpha.api.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/dds/tree/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,13 @@ export {
storedEmptyFieldSchema,
type StoredSchemaCollection,
schemaFormatV1,
schemaFormatV2,
LeafNodeStoredSchema,
ObjectNodeStoredSchema,
MapNodeStoredSchema,
decodeFieldSchema,
encodeFieldSchema,
encodeFieldSchemaV1,
encodeFieldSchemaV2,
storedSchemaDecodeDispatcher,
type SchemaAndPolicy,
Multiplicity,
Expand Down
78 changes: 78 additions & 0 deletions packages/dds/tree/src/core/schema-stored/formatV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import { type ObjectOptions, type Static, Type } from "@sinclair/typebox";

import { JsonCompatibleReadOnlySchema } from "../../util/index.js";
import {
FieldKindIdentifierSchema,
PersistedValueSchema,
TreeNodeSchemaIdentifierSchema,
} from "./formatV1.js";
import { unionOptions } from "../../codec/index.js";

export const PersistedMetadataFormat = Type.Optional(JsonCompatibleReadOnlySchema);

const FieldSchemaFormatBase = Type.Object({
kind: FieldKindIdentifierSchema,
types: Type.Array(TreeNodeSchemaIdentifierSchema),
metadata: PersistedMetadataFormat,
});

const noAdditionalProps: ObjectOptions = { additionalProperties: false };

export const FieldSchemaFormat = Type.Composite([FieldSchemaFormatBase], noAdditionalProps);

/**
* Format for the content of a {@link TreeNodeStoredSchema}.
*
* See {@link DiscriminatedUnionDispatcher} for more information on this pattern.
*/
export const TreeNodeSchemaUnionFormat = Type.Object(
{
/**
* Object node union member.
*/
object: Type.Optional(Type.Record(Type.String(), FieldSchemaFormat)),
/**
* Map node union member.
*/
map: Type.Optional(FieldSchemaFormat),
/**
* Leaf node union member.
*/
leaf: Type.Optional(Type.Enum(PersistedValueSchema)),
},
unionOptions,
);

export type TreeNodeSchemaUnionFormat = Static<typeof TreeNodeSchemaUnionFormat>;

/**
* Format for {@link TreeNodeStoredSchema}.
*
* See {@link DiscriminatedUnionDispatcher} for more information on this pattern.
*/
export const TreeNodeSchemaDataFormat = Type.Object(
{
/**
* Node kind specific data.
*/
kind: TreeNodeSchemaUnionFormat,

// Data in common for all TreeNode schemas:
/**
* Leaf node union member.
*/
metadata: PersistedMetadataFormat,
},
noAdditionalProps,
);

export type TreeNodeSchemaDataFormat = Static<typeof TreeNodeSchemaDataFormat>;

export type FieldSchemaFormat = Static<typeof FieldSchemaFormat>;

export type PersistedMetadataFormat = Static<typeof PersistedMetadataFormat>;
5 changes: 4 additions & 1 deletion packages/dds/tree/src/core/schema-stored/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export {
ObjectNodeStoredSchema,
MapNodeStoredSchema,
decodeFieldSchema,
encodeFieldSchema,
encodeFieldSchemaV1,
encodeFieldSchemaV2,
storedSchemaDecodeDispatcher,
type SchemaAndPolicy,
type SchemaPolicy,
Expand All @@ -37,3 +38,5 @@ export type { TreeNodeSchemaIdentifier, FieldKey, FieldKindIdentifier } from "./

import * as schemaFormatV1 from "./formatV1.js";
export { schemaFormatV1 };
import * as schemaFormatV2 from "./formatV2.js";
export { schemaFormatV2 };
149 changes: 119 additions & 30 deletions packages/dds/tree/src/core/schema-stored/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,32 @@ import { type MakeNominal, brand, invertMap } from "../../util/index.js";
import {
type FieldKey,
type FieldKindIdentifier,
type FieldSchemaFormat,
type FieldSchemaFormat as FieldSchemaFormatV1,
PersistedValueSchema,
type TreeNodeSchemaDataFormat,
type TreeNodeSchemaDataFormat as TreeNodeSchemaDataFormatV1,
type TreeNodeSchemaIdentifier,
} from "./formatV1.js";
import type {
FieldSchemaFormat as FieldSchemaFormatV2,
PersistedMetadataFormat,
TreeNodeSchemaUnionFormat,
TreeNodeSchemaDataFormat as TreeNodeSchemaDataFormatV2,
} from "./formatV2.js";
import type { Multiplicity } from "./multiplicity.js";

/**
* The format version for the schema.
*/
export enum SchemaVersion {
v1 = 1,
/**
* Adds persisted metadata to the node schema and field schema.
*/
v2 = 2,
}

type FieldSchemaFormat = FieldSchemaFormatV1 | FieldSchemaFormatV2;

/**
* Schema for what {@link TreeLeafValue} is allowed on a Leaf node.
* @privateRemarks
Expand Down Expand Up @@ -128,6 +140,13 @@ export interface TreeFieldStoredSchema {
* If not specified, types are unconstrained.
*/
readonly types: TreeTypeSet;

/**
* Portion of the metadata which can be persisted.
* @remarks
* Discarded when encoding to {@link SchemaFormatVersion.V1}.
*/
readonly metadata: PersistedMetadataFormat | undefined;
}

/**
Expand All @@ -151,6 +170,7 @@ export const storedEmptyFieldSchema: TreeFieldStoredSchema = {
kind: brand(forbiddenFieldKindIdentifier),
// This type set also forces the field to be empty not not allowing any types as all.
types: new Set(),
metadata: undefined,
};

/**
Expand All @@ -164,12 +184,21 @@ export abstract class TreeNodeStoredSchema {
protected _typeCheck!: MakeNominal;

/**
* @privateRemarks
* Returns TreeNodeSchemaDataFormat.
* This is uses an opaque type to avoid leaking these types out of the package,
* and is runtime validated by the codec.
* Constructor for a TreeNodeStoredSchema.
* @param metadata - Persisted metadata for this node schema.
*/
public constructor(public readonly metadata: PersistedMetadataFormat | undefined) {}

/**
* Encode in the v1 schema format.
*/
public abstract encode(): TreeNodeSchemaDataFormat;
public abstract encodeV1(): TreeNodeSchemaDataFormatV1;

/**
* Encode in the v2 schema format.
* @remarks Post-condition: if metadata was specified on the input schema, it will be present in the output.
*/
public abstract encodeV2(): TreeNodeSchemaDataFormatV2;

/**
* Returns the schema for the provided field.
Expand All @@ -190,29 +219,55 @@ export class ObjectNodeStoredSchema extends TreeNodeStoredSchema {
*/
public constructor(
public readonly objectNodeFields: ReadonlyMap<FieldKey, TreeFieldStoredSchema>,
metadata?: PersistedMetadataFormat | undefined,
) {
super();
super(metadata);
}

public override encode(): TreeNodeSchemaDataFormat {
public override encodeV1(): TreeNodeSchemaDataFormatV1 {
const fieldsObject: Record<string, FieldSchemaFormat> = Object.create(null);
// Sort fields to ensure output is identical for for equivalent schema (since field order is not considered significant).
// This makes comparing schema easier, and ensures chunk reuse for schema summaries isn't needlessly broken.
for (const key of [...this.objectNodeFields.keys()].sort()) {
const value = encodeFieldSchemaV1(
this.objectNodeFields.get(key) ?? fail(0xae7 /* missing field */),
);

Object.defineProperty(fieldsObject, key, {
enumerable: true,
configurable: true,
writable: true,
value: encodeFieldSchema(
this.objectNodeFields.get(key) ?? fail(0xae7 /* missing field */),
),
value,
});
}
return {
object: fieldsObject,
};
}

public override encodeV2(): TreeNodeSchemaDataFormatV2 {
const fieldsObject: Record<string, FieldSchemaFormat> = Object.create(null);
// Sort fields to ensure output is identical for for equivalent schema (since field order is not considered significant).
// This makes comparing schema easier, and ensures chunk reuse for schema summaries isn't needlessly broken.
for (const key of [...this.objectNodeFields.keys()].sort()) {
const value = encodeFieldSchemaV2(
this.objectNodeFields.get(key) ?? fail(0xae7 /* missing field */),
);

Object.defineProperty(fieldsObject, key, {
enumerable: true,
configurable: true,
writable: true,
value,
});
}

const kind = { object: fieldsObject };

// Omit metadata from the output if it is undefined
return this.metadata !== undefined ? { kind, metadata: this.metadata } : { kind };
}

public override getFieldSchema(field: FieldKey): TreeFieldStoredSchema {
return this.objectNodeFields.get(field) ?? storedEmptyFieldSchema;
}
Expand All @@ -229,14 +284,20 @@ export class MapNodeStoredSchema extends TreeNodeStoredSchema {
* since no nodes can ever be in schema if you use `FieldKind.Value` here
* (that would require infinite children).
*/
public constructor(public readonly mapFields: TreeFieldStoredSchema) {
super();
public constructor(
public readonly mapFields: TreeFieldStoredSchema,
metadata?: PersistedMetadataFormat | undefined,
) {
super(metadata);
}

public override encode(): TreeNodeSchemaDataFormat {
return {
map: encodeFieldSchema(this.mapFields),
};
public override encodeV1(): TreeNodeSchemaDataFormatV1 {
return { map: encodeFieldSchemaV1(this.mapFields) };
}

public override encodeV2(): TreeNodeSchemaDataFormatV2 {
const kind = { map: encodeFieldSchemaV2(this.mapFields) };
return this.metadata === undefined ? { kind, metadata: this.metadata } : { kind };
}

public override getFieldSchema(field: FieldKey): TreeFieldStoredSchema {
Expand All @@ -260,36 +321,54 @@ export class LeafNodeStoredSchema extends TreeNodeStoredSchema {
* This is simply one approach that can work for modeling them in the internal schema representation.
*/
public constructor(public readonly leafValue: ValueSchema) {
super();
// No metadata for leaf nodes.
super(undefined);
}

public override encode(): TreeNodeSchemaDataFormat {
public override encodeV1(): TreeNodeSchemaDataFormatV1 {
return {
leaf: encodeValueSchema(this.leafValue),
};
}

public override encodeV2(): TreeNodeSchemaDataFormatV2 {
return {
// No metadata for leaf nodes, so don't emit a metadata field.
kind: {
leaf: encodeValueSchema(this.leafValue),
},
};
}

public override getFieldSchema(field: FieldKey): TreeFieldStoredSchema {
return storedEmptyFieldSchema;
}
}

/**
* Decoder wrapper function for {@link TreeNodeStoredSchema} implementations.
* Curries the constructor so that the caller can inject metadata.
*/
type StoredSchemaDecoder = (
metadata: PersistedMetadataFormat | undefined,
) => TreeNodeStoredSchema;

export const storedSchemaDecodeDispatcher: DiscriminatedUnionDispatcher<
TreeNodeSchemaDataFormat,
TreeNodeSchemaUnionFormat,
[],
TreeNodeStoredSchema
StoredSchemaDecoder
> = new DiscriminatedUnionDispatcher({
leaf: (data: PersistedValueSchema) => new LeafNodeStoredSchema(decodeValueSchema(data)),
object: (
data: Record<TreeNodeSchemaIdentifier, FieldSchemaFormat>,
): TreeNodeStoredSchema => {
leaf: (data: PersistedValueSchema) => (metadata) =>
new LeafNodeStoredSchema(decodeValueSchema(data)),
object: (data: Record<TreeNodeSchemaIdentifier, FieldSchemaFormat>) => (metadata) => {
const map = new Map();
for (const [key, value] of Object.entries(data)) {
map.set(key, decodeFieldSchema(value));
}
return new ObjectNodeStoredSchema(map);
return new ObjectNodeStoredSchema(map, metadata);
},
map: (data: FieldSchemaFormat) => new MapNodeStoredSchema(decodeFieldSchema(data)),
map: (data: FieldSchemaFormat) => (metadata) =>
new MapNodeStoredSchema(decodeFieldSchema(data), metadata),
});

const valueSchemaEncode = new Map([
Expand All @@ -310,19 +389,29 @@ function decodeValueSchema(inMemory: PersistedValueSchema): ValueSchema {
return valueSchemaDecode.get(inMemory) ?? fail(0xae9 /* missing ValueSchema */);
}

export function encodeFieldSchema(schema: TreeFieldStoredSchema): FieldSchemaFormat {
export function encodeFieldSchemaV1(schema: TreeFieldStoredSchema): FieldSchemaFormatV1 {
return {
kind: schema.kind,
// Types are sorted by identifier to improve stability of persisted data to increase chance of schema blob reuse.
types: [...schema.types].sort(),
};
}

export function decodeFieldSchema(schema: FieldSchemaFormat): TreeFieldStoredSchema {
export function encodeFieldSchemaV2(schema: TreeFieldStoredSchema): FieldSchemaFormatV2 {
const fieldSchema: FieldSchemaFormatV1 = encodeFieldSchemaV1(schema);

// Omit metadata from the output if it is undefined
return schema.metadata !== undefined
? { ...fieldSchema, metadata: schema.metadata }
: { ...fieldSchema };
}

export function decodeFieldSchema(schema: FieldSchemaFormatV2): TreeFieldStoredSchema {
const out: TreeFieldStoredSchema = {
// TODO: maybe provide actual FieldKind objects here, error on unrecognized kinds.
kind: schema.kind,
types: new Set(schema.types),
metadata: schema.metadata,
};
return out;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export class FieldKindWithEditor<
isNeverField(policy, originalData, {
kind: this.identifier,
types: originalTypes,
// Metadata is not used for this check.
metadata: undefined,
})
) {
return true;
Expand Down
Loading
Loading