From b3fae8aecec2d5ebf49320e0047d0cd5e4e49161 Mon Sep 17 00:00:00 2001 From: Noah Encke Date: Thu, 23 Oct 2025 09:35:09 -0700 Subject: [PATCH 1/4] add ensureSchema --- .changeset/tricky-shirts-sleep.md | 26 +++ .../dds/tree/api-report/tree.alpha.api.md | 6 +- packages/dds/tree/src/index.ts | 1 + .../dds/tree/src/shared-tree/treeAlpha.ts | 42 ++++ .../dds/tree/src/simple-tree/core/index.ts | 7 +- .../dds/tree/src/simple-tree/core/withType.ts | 24 +++ packages/dds/tree/src/simple-tree/index.ts | 1 + .../unhydratedFlexTreeFromInsertable.ts | 9 + .../test/simple-tree/api/treeNodeApi.spec.ts | 132 ++++++++++++ .../unhydratedFlexTreeFromInsertable.spec.ts | 25 ++- .../api-report/fluid-framework.alpha.api.md | 6 +- pnpm-lock.yaml | 191 ++++++++---------- 12 files changed, 359 insertions(+), 111 deletions(-) create mode 100644 .changeset/tricky-shirts-sleep.md diff --git a/.changeset/tricky-shirts-sleep.md b/.changeset/tricky-shirts-sleep.md new file mode 100644 index 000000000000..d7559984665b --- /dev/null +++ b/.changeset/tricky-shirts-sleep.md @@ -0,0 +1,26 @@ +--- +"@fluidframework/tree": minor +"__section": feature +--- +Added `Tree.ensureSchema` + +This helper function allows content to be tagged with a schema type before being inserted into the tree. +This allows content that would otherwise be ambiguous to be well-defined, without having to wrap it in a node constructor. + +Example: + +```typescript +const sf = new SchemaFactory("example"); +class Dog extends sf.object("Dog", { name: sf.string() }) {} +class Cat extends sf.object("Cat", { name: sf.string() }) {} +class Root extends sf.object("Root", { pet: [Dog, Cat] }) {} +// ... +const pet = { name: "Max" }; +view.root.pet = pet; // Error: `pet` is ambiguous - is it a Dog or a Cat? +view.root.pet = new Dog(pet); // This works, but has the overhead of creating a Dog node before the insertion actually happens. +TreeAlpha.ensureSchema(Dog, pet); // Instead, this tags the `pet` object as a Dog... +view.root.pet = pet; // So now there is no error for a normal insertion - it's a Dog. +``` + +This function works by leveraging the new `schemaSymbol`, which is also available for use. +See its documentation for more information. diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index 7296cc5842d3..cac6d72e2217 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -965,7 +965,10 @@ export interface SchemaStaticsAlpha { readonly typesRecursive: >[]>(t: T, metadata?: AllowedTypesMetadata) => AllowedTypesFullFromMixedUnsafe; } -// @alpha @sealed +// @alpha +export const schemaSymbol: unique symbol; + +// @beta @sealed export class SchemaUpgrade { // (undocumented) protected _typeCheck: MakeNominal; @@ -1348,6 +1351,7 @@ export interface TreeAlpha { child(node: TreeNode, key: string | number): TreeNode | TreeLeafValue | undefined; children(node: TreeNode): Iterable<[propertyKey: string | number, child: TreeNode | TreeLeafValue]>; create(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: InsertableField): Unhydrated : TreeNode | TreeLeafValue | undefined>; + ensureSchema>(schema: TSchema, content: TContent): TContent; exportCompressed(tree: TreeNode | TreeLeafValue, options: { idCompressor?: IIdCompressor; } & Pick): JsonCompatible; diff --git a/packages/dds/tree/src/index.ts b/packages/dds/tree/src/index.ts index 8b79e34740c6..39605deb29d5 100644 --- a/packages/dds/tree/src/index.ts +++ b/packages/dds/tree/src/index.ts @@ -123,6 +123,7 @@ export { type WithType, type NodeChangedData, type SchemaUpgrade, + schemaSymbol, // Types not really intended for public use, but used in links. // Can not be moved to internalTypes since doing so causes app code to throw errors like: // Error: src/simple-tree/objectNode.ts:72:1 - (ae-unresolved-link) The @link reference could not be resolved: The package "@fluidframework/tree" does not have an export "TreeNodeApi" diff --git a/packages/dds/tree/src/shared-tree/treeAlpha.ts b/packages/dds/tree/src/shared-tree/treeAlpha.ts index d01265b0a89a..2c641f628ace 100644 --- a/packages/dds/tree/src/shared-tree/treeAlpha.ts +++ b/packages/dds/tree/src/shared-tree/treeAlpha.ts @@ -58,6 +58,8 @@ import { importConcise, exportConcise, borrowCursorFromTreeNodeOrValue, + schemaSymbol, + type TreeNodeSchema, } from "../simple-tree/index.js"; import { brand, extractFromOpaque, type JsonCompatible } from "../util/index.js"; import { @@ -519,6 +521,31 @@ export interface TreeAlpha { onInvalidation: () => void, trackDuring: () => TResult, ): ObservationResults; + + /** + * Ensures that the provided content will be interpreted as the given schema when inserting into the tree. + * @returns `content`, for convenience. + * @remarks + * If applicable, this will tag the given content with a {@link schemaSymbol | special property} that indicates its intended schema. + * The `content` will be interpreted as the given `schema` when later inserted into the tree. + * This is particularly useful when the content's schema cannot be inferred from its structure alone because it is compatible with multiple schemas. + * @example + * ```typescript + * const sf = new SchemaFactory("example"); + * class Dog extends sf.object("Dog", { name: sf.string() }) {} + * class Cat extends sf.object("Cat", { name: sf.string() }) {} + * class Root extends sf.object("Root", { pet: [Dog, Cat] }) {} + * // ... + * const pet = { name: "Max" }; + * view.root.pet = pet; // Error: ambiguous schema - is it a Dog or a Cat? + * TreeAlpha.ensureSchema(Dog, pet); // Tags `pet` as a Dog. + * view.root.pet = pet; // No error - it's a Dog. + * ``` + */ + ensureSchema>( + schema: TSchema, + content: TContent, + ): TContent; } /** @@ -1003,6 +1030,21 @@ export const TreeAlpha: TreeAlpha = { } return result; }, + + ensureSchema>( + schema: TSchema, + node: TNode, + ): TNode { + if (typeof node === "object" && node !== null) { + Reflect.defineProperty(node, schemaSymbol, { + configurable: false, + enumerable: false, + writable: true, + value: schema.identifier, + }); + } + return node; + }, }; /** diff --git a/packages/dds/tree/src/simple-tree/core/index.ts b/packages/dds/tree/src/simple-tree/core/index.ts index 7c4a91bc981f..17437839ce83 100644 --- a/packages/dds/tree/src/simple-tree/core/index.ts +++ b/packages/dds/tree/src/simple-tree/core/index.ts @@ -16,7 +16,12 @@ export { SimpleContextSlot, withBufferedTreeEvents, } from "./treeNodeKernel.js"; -export { type WithType, typeNameSymbol, typeSchemaSymbol } from "./withType.js"; +export { + type WithType, + typeNameSymbol, + typeSchemaSymbol, + schemaSymbol, +} from "./withType.js"; export { type Unhydrated, type InternalTreeNode, diff --git a/packages/dds/tree/src/simple-tree/core/withType.ts b/packages/dds/tree/src/simple-tree/core/withType.ts index 8d5288778ec8..952a74b66071 100644 --- a/packages/dds/tree/src/simple-tree/core/withType.ts +++ b/packages/dds/tree/src/simple-tree/core/withType.ts @@ -5,6 +5,9 @@ import type { TreeNode } from "./treeNode.js"; import type { NodeKind, TreeNodeSchemaClass } from "./treeNodeSchema.js"; +// Used by doc links: +// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports +import type { TreeAlpha } from "../../shared-tree/index.js"; /** * The type of a {@link TreeNode}. @@ -39,6 +42,27 @@ export const typeNameSymbol: unique symbol = Symbol("TreeNode Type"); */ export const typeSchemaSymbol: unique symbol = Symbol("TreeNode Schema"); +/** + * The intended type of insertable content that is to become a {@link TreeNode}. + * @remarks **Note:** Whenever possible, use the type-safe {@link (TreeAlpha:interface).ensureSchema} function rather than manually employing this symbol. + * + * If a property with this symbol key is present on an object that is inserted into the tree, + * the tree will use the schema identifier specified by the value of this property when creating the node. + * This is particularly useful for specifying the intended schema of untyped content when it would otherwise be ambiguous. + * @example + * ```typescript + * const sf = new SchemaFactory("example"); + * class Dog extends sf.object("Dog", { name: sf.string() }) {} + * class Cat extends sf.object("Cat", { name: sf.string() }) {} + * class Root extends sf.object("Root", { pet: [Dog, Cat] }) {} + * // ... + * view.root.pet = { name: "Max" }; // Error: ambiguous schema - is it a Dog or a Cat? + * view.root.pet = { name: "Max", [schemaSymbol]: "example.Dog" }; // No error - it's a Dog. + * ``` + * @alpha + */ +export const schemaSymbol: unique symbol = Symbol("SharedTree Schema"); + /** * Adds a type symbol to a type for stronger typing. * diff --git a/packages/dds/tree/src/simple-tree/index.ts b/packages/dds/tree/src/simple-tree/index.ts index 7e4a69fd7954..088394c7eb62 100644 --- a/packages/dds/tree/src/simple-tree/index.ts +++ b/packages/dds/tree/src/simple-tree/index.ts @@ -6,6 +6,7 @@ export { typeNameSymbol, typeSchemaSymbol, + schemaSymbol, type WithType, type TreeNodeSchema, type AnnotatedAllowedType, diff --git a/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts b/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts index 9476074e7471..c080b10656c4 100644 --- a/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts +++ b/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts @@ -17,6 +17,7 @@ import { isTreeNode, type TreeNode, type TreeNodeSchema, + schemaSymbol, type Unhydrated, UnhydratedFlexTreeNode, } from "./core/index.js"; @@ -125,16 +126,24 @@ For class-based schema, this can be done by replacing an expression like "{foo: /** * Returns all types for which the data is schema-compatible. + * @remarks This will respect the {@link schemaSymbol} property on data to disambiguate types - if present, only that type will be returned. */ export function getPossibleTypes( allowedTypes: ReadonlySet, data: FactoryContent, ): TreeNodeSchema[] { assert(data !== undefined, 0x889 /* undefined cannot be used as FactoryContent. */); + const type = + typeof data === "object" && data !== null + ? (data as Partial<{ [schemaSymbol]: string }>)[schemaSymbol] + : undefined; let best = CompatibilityLevel.None; const possibleTypes: TreeNodeSchema[] = []; for (const schema of allowedTypes) { + if (type !== undefined && schema.identifier === type) { + return [schema]; + } const handler = getTreeNodeSchemaPrivateData(schema).idempotentInitialize(); const level = handler.shallowCompatibilityTest(data); if (level > best) { diff --git a/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts b/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts index b79a81b652bb..29ba00cba051 100644 --- a/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts @@ -3544,6 +3544,138 @@ describe("treeNodeApi", () => { } }); }); + + describe("ensureType", () => { + const sf = new SchemaFactory("test"); + class Son extends sf.object("Son", { value: sf.number }) {} + class Daughter extends sf.object("Daughter", { value: sf.number }) {} + class Parent extends sf.object("Parent", { + child: sf.optional([Son, Daughter]), + }) {} + it("returns the same value that was passed in", () => { + const child = { value: 3 }; + const son = TreeAlpha.ensureSchema(Son, child); + assert.equal(son, child); + }); + + it("allows leaf types", () => { + const nullValue = null; + assert.equal(TreeAlpha.ensureSchema(schema.null, nullValue), nullValue); + const booleanValue = true; + assert.equal(TreeAlpha.ensureSchema(schema.boolean, booleanValue), booleanValue); + const numberValue = 3; + assert.equal(TreeAlpha.ensureSchema(schema.number, numberValue), numberValue); + const stringValue = "hello"; + assert.equal(TreeAlpha.ensureSchema(schema.string, stringValue), stringValue); + }); + + it("tags an object that is otherwise ambiguous", () => { + const child = { value: 3 }; + // `child` could be either a Son or a Daughter, so we can't disambiguate. + assert.throws( + () => { + hydrate(Parent, { child }); + }, + validateUsageError(/compatible with more than one type/), + ); + // If we explicitly tag it as a Daughter, it is thereafter interpreted as such. + const daughter = TreeAlpha.ensureSchema(Daughter, child); + const parent = hydrate(Parent, { child: daughter }); + assert(Tree.is(parent.child, Daughter)); + }); + + it("tags an array that is otherwise ambiguous", () => { + class Sons extends sf.array("Sons", Son) {} + class Daughters extends sf.array("Daughters", Daughter) {} + class ArrayParent extends sf.object("Parent", { + children: sf.optional([Sons, Daughters]), + }) {} + const children = [{ value: 3 }, { value: 4 }]; + assert.throws( + () => { + hydrate(ArrayParent, { children }); + }, + validateUsageError(/compatible with more than one type/), + ); + const daughters = TreeAlpha.ensureSchema(Daughters, children); + const parent = hydrate(ArrayParent, { children: daughters }); + assert(Tree.is(parent.children, Daughters)); + }); + + it("tags a map that is otherwise ambiguous", () => { + class SonMap extends sf.map("SonMap", Son) {} + class DaughterMap extends sf.map("DaughterMap", Daughter) {} + class MapParent extends sf.object("Parent", { + children: sf.optional([SonMap, DaughterMap]), + }) {} + + const children = { + a: { value: 3 }, + b: { value: 4 }, + }; + assert.throws( + () => { + hydrate(MapParent, { children }); + }, + validateUsageError(/compatible with more than one type/), + ); + const daughterMap = TreeAlpha.ensureSchema(DaughterMap, children); + const parent = hydrate(MapParent, { children: daughterMap }); + assert(Tree.is(parent.children, DaughterMap)); + }); + + it("can re-tag an object that has already been tagged", () => { + const child = { value: 3 }; + const daughter = TreeAlpha.ensureSchema(Daughter, child); + const son = TreeAlpha.ensureSchema(Son, daughter); + const parent = hydrate(Parent, { child: son }); + assert(Tree.is(parent.child, Son)); + }); + + it("can be used to disambiguate deep trees", () => { + class Father extends sf.object("Father", { + child: sf.optional([Son, Daughter]), + }) {} + class Mother extends sf.object("Mother", { + child: sf.optional([Son, Daughter]), + }) {} + class GrandParent extends sf.object("GrandParent", { + parent: sf.optional([Father, Mother]), + }) {} + // Ambiguous parent and child + assert.throws(() => { + hydrate(GrandParent, { + parent: { + child: { value: 3 }, + }, + }); + }); + // Tagged parent, but ambiguous child + assert.throws(() => { + hydrate(GrandParent, { + parent: { + child: TreeAlpha.ensureSchema(Son, { value: 3 }), + }, + }); + }); + // Ambiguous parent, but tagged child + assert.throws(() => { + hydrate(GrandParent, { + parent: TreeAlpha.ensureSchema(Father, { + child: { value: 3 }, + }), + }); + }); + // Both parent and child tagged + const grandParent = hydrate(GrandParent, { + parent: TreeAlpha.ensureSchema(Father, { + child: TreeAlpha.ensureSchema(Son, { value: 3 }), + }), + }); + assert.ok(Tree.is(grandParent.parent, Father)); + assert.ok(Tree.is(grandParent.parent?.child, Son)); + }); + }); }); function checkoutWithInitialTree( diff --git a/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts b/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts index 2dd834d1f66b..1057b0c09729 100644 --- a/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts @@ -43,7 +43,7 @@ import { } from "../../feature-libraries/index.js"; import { validateUsageError } from "../utils.js"; // eslint-disable-next-line import/no-internal-modules -import { UnhydratedFlexTreeNode } from "../../simple-tree/core/index.js"; +import { schemaSymbol, UnhydratedFlexTreeNode } from "../../simple-tree/core/index.js"; // eslint-disable-next-line import/no-internal-modules import { getUnhydratedContext } from "../../simple-tree/createContext.js"; // eslint-disable-next-line import/no-internal-modules @@ -1459,6 +1459,29 @@ describe("unhydratedFlexTreeFromInsertable", () => { [Optional], ); }); + + it("with type symbol", () => { + const f = new SchemaFactory("test"); + class A extends f.object("A", { + value: f.string, + }) {} + class B extends f.object("B", { + value: f.string, + }) {} + // Type symbol specified when there is only one valid option + const content = { [schemaSymbol]: "test.A", value: "hello" }; + assert.deepEqual(getPossibleTypes(new Set([A]), content), [A]); + + // Type symbol specified when there are multiple valid options + const ambiguousContent = { [schemaSymbol]: "test.B", value: "hello" }; + // Only B, even though A is also valid based on properties + assert.deepEqual(getPossibleTypes(new Set([A, B]), ambiguousContent), [B]); + + // Type symbol specified that does not match any options + const invalidContent = { [schemaSymbol]: "test.C", value: "hello" }; + // Should fall back to A, as if no type symbol was provided + assert.deepEqual(getPossibleTypes(new Set([A]), invalidContent), [A]); + }); }); describe("defaults", () => { diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md index 7eac5a555392..bb7dc41707ed 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md @@ -1335,7 +1335,10 @@ export interface SchemaStaticsAlpha { readonly typesRecursive: >[]>(t: T, metadata?: AllowedTypesMetadata) => AllowedTypesFullFromMixedUnsafe; } -// @alpha @sealed +// @alpha +export const schemaSymbol: unique symbol; + +// @beta @sealed export class SchemaUpgrade { // (undocumented) protected _typeCheck: MakeNominal; @@ -1740,6 +1743,7 @@ export interface TreeAlpha { child(node: TreeNode, key: string | number): TreeNode | TreeLeafValue | undefined; children(node: TreeNode): Iterable<[propertyKey: string | number, child: TreeNode | TreeLeafValue]>; create(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: InsertableField): Unhydrated : TreeNode | TreeLeafValue | undefined>; + ensureSchema>(schema: TSchema, content: TContent): TContent; exportCompressed(tree: TreeNode | TreeLeafValue, options: { idCompressor?: IIdCompressor; } & Pick): JsonCompatible; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80993c9c65bd..523b10941ad8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9440,8 +9440,8 @@ importers: specifier: ^7.0.3 version: 7.0.3 dependency-cruiser: - specifier: ^14.1.0 - version: 14.1.2 + specifier: ^17.1.0 + version: 17.1.0 diff: specifier: ^3.5.0 version: 3.5.0 @@ -20143,18 +20143,14 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-loose@8.3.0: - resolution: {integrity: sha512-75lAs9H19ldmW+fAbyqHdjgdCrz0pWGXKmnqFoh8PyVd1L2RIb4RzYrSjmopeqv3E1G3/Pimu6GgLlrGbrkF7w==} + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} engines: {node: '>=0.4.0'} acorn-walk@7.2.0: resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} engines: {node: '>=0.4.0'} - acorn-walk@8.2.0: - resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} - engines: {node: '>=0.4.0'} - acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -20164,11 +20160,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.10.0: - resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -20812,10 +20803,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.3.0: - resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chalk@5.4.1: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -21081,14 +21068,14 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.1: + resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + engines: {node: '>=20'} + commander@2.1.0: resolution: {integrity: sha512-J2wnb6TKniXNOtoHS8TSrG9IOQluPrsmyAJ8oCUJOBmv+uLBCyPYAZkD2jFvw2DCzIXNnISIM01NIvr35TkBMQ==} engines: {node: '>= 0.6.x'} @@ -21562,9 +21549,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dependency-cruiser@14.1.2: - resolution: {integrity: sha512-DlHeyF7LxK+pQdEBS+5AykHhUNOyzhek7QCElcmkWfj/dGxuXudT4SrIn6jVtzOnIhU9GAJ8whqm1MHdLDDCHg==} - engines: {node: ^18.17||>=20} + dependency-cruiser@17.1.0: + resolution: {integrity: sha512-8ZtJmSEqG5xAFAMYBclJbvp3R8j4wBw2QTzT0ZhC2cou6c/3u0G6j7coNc/fz0qyo0haQ5ihLt7u0iEnRMui/A==} + engines: {node: ^20.12||^22||>=24} hasBin: true deprecation@2.3.1: @@ -21841,12 +21828,8 @@ packages: resolution: {integrity: sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.15.0: - resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} - engines: {node: '>=10.13.0'} - - enhanced-resolve@5.17.1: - resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} enquirer@2.3.6: @@ -22396,10 +22379,6 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} - figures@5.0.0: - resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==} - engines: {node: '>=14'} - file-entry-cache@5.0.1: resolution: {integrity: sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==} engines: {node: '>=4'} @@ -22731,6 +22710,10 @@ packages: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} @@ -23085,14 +23068,14 @@ packages: resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} engines: {node: '>= 4'} - ignore@5.2.4: - resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} - engines: {node: '>= 4'} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} @@ -23157,6 +23140,10 @@ packages: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ini@4.1.3: resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -23347,6 +23334,10 @@ packages: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} + is-installed-globally@1.0.0: + resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} + engines: {node: '>=18'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -23388,6 +23379,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@2.1.0: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} @@ -24364,6 +24359,10 @@ packages: resolution: {integrity: sha512-q9MmZXd2rRWHS6GU3WEm3HyiXZyyoA1DqdOhEq0lxPBmKb5S7IAOwX0RgUCwJfqjelDCySa5h8ujOy24LqsWcw==} engines: {node: '>= 4.0.0'} + memoize@10.1.0: + resolution: {integrity: sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==} + engines: {node: '>=18'} + memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -26294,11 +26293,6 @@ packages: resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} engines: {node: '>=12'} - semver-try-require@6.2.3: - resolution: {integrity: sha512-6q1N/Vr/4/G0EcQ1k4svN5kwfh3MJs4Gfl+zBAVcKn+AeIjKLwTXQ143Y6YHu6xEeN5gSCbCD1/5+NwCipLY5A==} - engines: {node: ^14||^16||>=18} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - semver-ts@1.0.3: resolution: {integrity: sha512-RMf2+Nbd0Hiq5u8LADzqnSRb09Vu1UKOLFw9P9nm8XY3JPeFjv3DLVYBZjPWFHUxBVz7ktIVGR3xFjYilHlXng==} engines: {node: '>=0.10.0'} @@ -26951,9 +26945,6 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - teamcity-service-messages@0.1.14: - resolution: {integrity: sha512-29aQwaHqm8RMX74u2o/h1KbMLP89FjNiMxD9wbF2BbWOnbM+q+d1sCEC+MqCc4QW3NJykn77OMpTFw/xTHIc0w==} - temp@0.9.4: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} @@ -27203,8 +27194,8 @@ packages: engines: {node: '>= 10.9'} hasBin: true - tsconfig-paths-webpack-plugin@4.1.0: - resolution: {integrity: sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==} + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} tsconfig-paths@3.15.0: @@ -27708,8 +27699,8 @@ packages: resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} engines: {node: '>=10.13.0'} - watskeburt@2.0.0: - resolution: {integrity: sha512-RJ961Bcw9sfHr1NqZwvcFBYWo6bN9xE1CeBy6LigLqpzzrdnvsMT5HFg2JhOe4ioDOrCndjNa3tsErIVZtCc3g==} + watskeburt@4.2.3: + resolution: {integrity: sha512-uG9qtQYoHqAsnT711nG5iZc/8M5inSmkGCOp7pFaytKG2aTfIca7p//CjiVzAE4P7hzaYuCozMjNNaLgmhbK5g==} engines: {node: ^18||>=20} hasBin: true @@ -34229,30 +34220,22 @@ snapshots: dependencies: acorn: 7.4.1 - acorn-jsx@5.3.2(acorn@8.10.0): - dependencies: - acorn: 8.10.0 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 - acorn-loose@8.3.0: + acorn-loose@8.5.2: dependencies: acorn: 8.15.0 acorn-walk@7.2.0: {} - acorn-walk@8.2.0: {} - acorn-walk@8.3.4: dependencies: acorn: 8.15.0 acorn@7.4.1: {} - acorn@8.10.0: {} - acorn@8.15.0: {} address@1.2.2: {} @@ -34991,8 +34974,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.3.0: {} - chalk@5.4.1: {} change-case@3.1.0: @@ -35264,10 +35245,10 @@ snapshots: commander@10.0.1: {} - commander@11.1.0: {} - commander@13.1.0: {} + commander@14.0.1: {} + commander@2.1.0: {} commander@2.15.1: {} @@ -35768,34 +35749,28 @@ snapshots: depd@2.0.0: {} - dependency-cruiser@14.1.2: + dependency-cruiser@17.1.0: dependencies: - acorn: 8.10.0 - acorn-jsx: 5.3.2(acorn@8.10.0) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) acorn-jsx-walk: 2.0.0 - acorn-loose: 8.3.0 - acorn-walk: 8.2.0 - ajv: 8.12.0 - chalk: 5.3.0 - commander: 11.1.0 - enhanced-resolve: 5.15.0 - figures: 5.0.0 - ignore: 5.2.4 - indent-string: 5.0.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.4 + ajv: 8.17.1 + commander: 14.0.1 + enhanced-resolve: 5.18.3 + ignore: 7.0.5 interpret: 3.1.1 - is-installed-globally: 0.4.0 + is-installed-globally: 1.0.0 json5: 2.2.3 - lodash: 4.17.21 - picomatch: 2.3.1 + memoize: 10.1.0 + picomatch: 4.0.3 prompts: 2.4.2 rechoir: 0.8.0 safe-regex: 2.1.1 semver: 7.7.3 - semver-try-require: 6.2.3 - teamcity-service-messages: 0.1.14 - tsconfig-paths-webpack-plugin: 4.1.0 - watskeburt: 2.0.0 - wrap-ansi: 8.1.0 + tsconfig-paths-webpack-plugin: 4.2.0 + watskeburt: 4.2.3 deprecation@2.3.1: {} @@ -36058,12 +36033,7 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.15.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.1 - - enhanced-resolve@5.17.1: + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.2.1 @@ -36796,11 +36766,6 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - figures@5.0.0: - dependencies: - escape-string-regexp: 5.0.0 - is-unicode-supported: 1.3.0 - file-entry-cache@5.0.1: dependencies: flat-cache: 2.0.1 @@ -37166,6 +37131,10 @@ snapshots: minipass: 4.2.8 path-scurry: 1.11.1 + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + global-dirs@3.0.1: dependencies: ini: 2.0.0 @@ -37610,10 +37579,10 @@ snapshots: ignore@4.0.6: {} - ignore@5.2.4: {} - ignore@5.3.2: {} + ignore@7.0.5: {} + image-size@0.5.5: optional: true @@ -37662,6 +37631,8 @@ snapshots: ini@2.0.0: {} + ini@4.1.1: {} + ini@4.1.3: {} ini@5.0.0: {} @@ -37878,6 +37849,11 @@ snapshots: global-dirs: 3.0.1 is-path-inside: 3.0.3 + is-installed-globally@1.0.0: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + is-interactive@2.0.0: {} is-lambda@1.0.1: {} @@ -37908,6 +37884,8 @@ snapshots: is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} + is-plain-obj@2.1.0: {} is-plain-obj@3.0.0: {} @@ -39323,6 +39301,10 @@ snapshots: tree-dump: 1.0.2(tslib@2.8.1) tslib: 2.8.1 + memoize@10.1.0: + dependencies: + mimic-function: 5.0.1 + memorystream@0.3.1: {} merge-descriptors@1.0.3: {} @@ -41708,10 +41690,6 @@ snapshots: dependencies: semver: 7.7.3 - semver-try-require@6.2.3: - dependencies: - semver: 7.7.3 - semver-ts@1.0.3: {} semver-utils@1.1.4: {} @@ -42605,8 +42583,6 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - teamcity-service-messages@0.1.14: {} - temp@0.9.4: dependencies: mkdirp: 0.5.6 @@ -42843,7 +42819,7 @@ snapshots: ts-loader@9.5.1(typescript@5.4.5)(webpack@5.97.1): dependencies: chalk: 4.1.2 - enhanced-resolve: 5.17.1 + enhanced-resolve: 5.18.3 micromatch: 4.0.8 semver: 7.7.3 source-map: 0.7.4 @@ -42886,10 +42862,11 @@ snapshots: ts-morph: 20.0.0 typescript: 5.4.5 - tsconfig-paths-webpack-plugin@4.1.0: + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.17.1 + enhanced-resolve: 5.18.3 + tapable: 2.2.1 tsconfig-paths: 4.2.0 tsconfig-paths@3.15.0: @@ -43448,7 +43425,7 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 - watskeburt@2.0.0: {} + watskeburt@4.2.3: {} wbuf@1.7.3: dependencies: @@ -43679,7 +43656,7 @@ snapshots: acorn: 8.15.0 browserslist: 4.24.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 + enhanced-resolve: 5.18.3 es-module-lexer: 1.5.4 eslint-scope: 5.1.1 events: 3.3.0 From 382c76d90f5a561c6038c0dcda9b2293fd65228f Mon Sep 17 00:00:00 2001 From: Noah Encke Date: Fri, 24 Oct 2025 10:44:53 -0700 Subject: [PATCH 2/4] PR feedback --- .../dds/tree/api-report/tree.alpha.api.md | 8 ++-- packages/dds/tree/src/index.ts | 2 +- .../dds/tree/src/shared-tree/treeAlpha.ts | 15 ++++--- .../dds/tree/src/simple-tree/core/index.ts | 2 +- .../dds/tree/src/simple-tree/core/withType.ts | 6 +-- packages/dds/tree/src/simple-tree/index.ts | 2 +- .../unhydratedFlexTreeFromInsertable.ts | 14 ++++--- .../test/simple-tree/api/treeNodeApi.spec.ts | 42 ++++++++++++------- .../unhydratedFlexTreeFromInsertable.spec.ts | 8 ++-- .../api-report/fluid-framework.alpha.api.md | 8 ++-- 10 files changed, 63 insertions(+), 44 deletions(-) diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index 8ba2382c48d8..781b7479a32f 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -163,6 +163,9 @@ export type ConciseTree = Exclude; +// @alpha +export const contentSchemaSymbol: unique symbol; + // @alpha export function createIdentifierIndex(view: TreeView): IdentifierIndex; @@ -965,9 +968,6 @@ export interface SchemaStaticsAlpha { readonly typesRecursive: >[]>(t: T, metadata?: AllowedTypesMetadata) => AllowedTypesFullFromMixedUnsafe; } -// @alpha -export const schemaSymbol: unique symbol; - // @beta @sealed export class SchemaUpgrade { // (undocumented) @@ -1351,7 +1351,6 @@ export interface TreeAlpha { child(node: TreeNode, key: string | number): TreeNode | TreeLeafValue | undefined; children(node: TreeNode): Iterable<[propertyKey: string | number, child: TreeNode | TreeLeafValue]>; create(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: InsertableField): Unhydrated : TreeNode | TreeLeafValue | undefined>; - ensureSchema>(schema: TSchema, content: TContent): TContent; exportCompressed(tree: TreeNode | TreeLeafValue, options: { idCompressor?: IIdCompressor; } & Pick): JsonCompatible; @@ -1365,6 +1364,7 @@ export interface TreeAlpha { importConcise(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: ConciseTree | undefined): Unhydrated : TreeNode | TreeLeafValue | undefined>; importVerbose(schema: TSchema, data: VerboseTree | undefined, options?: TreeParsingOptions): Unhydrated>; key2(node: TreeNode): string | number | undefined; + tagContentSchema>(schema: TSchema, content: TContent): TContent; trackObservations(onInvalidation: () => void, trackDuring: () => TResult): ObservationResults; trackObservationsOnce(onInvalidation: () => void, trackDuring: () => TResult): ObservationResults; } diff --git a/packages/dds/tree/src/index.ts b/packages/dds/tree/src/index.ts index 39605deb29d5..0fc7c45de1f9 100644 --- a/packages/dds/tree/src/index.ts +++ b/packages/dds/tree/src/index.ts @@ -123,7 +123,7 @@ export { type WithType, type NodeChangedData, type SchemaUpgrade, - schemaSymbol, + contentSchemaSymbol, // Types not really intended for public use, but used in links. // Can not be moved to internalTypes since doing so causes app code to throw errors like: // Error: src/simple-tree/objectNode.ts:72:1 - (ae-unresolved-link) The @link reference could not be resolved: The package "@fluidframework/tree" does not have an export "TreeNodeApi" diff --git a/packages/dds/tree/src/shared-tree/treeAlpha.ts b/packages/dds/tree/src/shared-tree/treeAlpha.ts index 2c641f628ace..afa983f723cb 100644 --- a/packages/dds/tree/src/shared-tree/treeAlpha.ts +++ b/packages/dds/tree/src/shared-tree/treeAlpha.ts @@ -58,7 +58,7 @@ import { importConcise, exportConcise, borrowCursorFromTreeNodeOrValue, - schemaSymbol, + contentSchemaSymbol, type TreeNodeSchema, } from "../simple-tree/index.js"; import { brand, extractFromOpaque, type JsonCompatible } from "../util/index.js"; @@ -87,6 +87,7 @@ import { } from "../feature-libraries/index.js"; import { independentInitializedView, type ViewContent } from "./independentView.js"; import { SchematizingSimpleTreeView, ViewSlot } from "./schematizingTreeView.js"; +import { isFluidHandle } from "@fluidframework/runtime-utils"; const identifier: TreeIdentifierUtils = (node: TreeNode): string | undefined => { const nodeIdentifier = getIdentifierFromNode(node, "uncompressed"); @@ -526,8 +527,10 @@ export interface TreeAlpha { * Ensures that the provided content will be interpreted as the given schema when inserting into the tree. * @returns `content`, for convenience. * @remarks - * If applicable, this will tag the given content with a {@link schemaSymbol | special property} that indicates its intended schema. + * If applicable, this will tag the given content with a {@link contentSchemaSymbol | special property} that indicates its intended schema. * The `content` will be interpreted as the given `schema` when later inserted into the tree. + * This does not validate that the content actually conforms to the given schema (such validation will be done at insert time). + * * This is particularly useful when the content's schema cannot be inferred from its structure alone because it is compatible with multiple schemas. * @example * ```typescript @@ -542,7 +545,7 @@ export interface TreeAlpha { * view.root.pet = pet; // No error - it's a Dog. * ``` */ - ensureSchema>( + tagContentSchema>( schema: TSchema, content: TContent, ): TContent; @@ -1031,12 +1034,12 @@ export const TreeAlpha: TreeAlpha = { return result; }, - ensureSchema>( + tagContentSchema>( schema: TSchema, node: TNode, ): TNode { - if (typeof node === "object" && node !== null) { - Reflect.defineProperty(node, schemaSymbol, { + if (typeof node === "object" && node !== null && !isFluidHandle(node)) { + Reflect.defineProperty(node, contentSchemaSymbol, { configurable: false, enumerable: false, writable: true, diff --git a/packages/dds/tree/src/simple-tree/core/index.ts b/packages/dds/tree/src/simple-tree/core/index.ts index 17437839ce83..f6aedb00fc1b 100644 --- a/packages/dds/tree/src/simple-tree/core/index.ts +++ b/packages/dds/tree/src/simple-tree/core/index.ts @@ -20,7 +20,7 @@ export { type WithType, typeNameSymbol, typeSchemaSymbol, - schemaSymbol, + contentSchemaSymbol, } from "./withType.js"; export { type Unhydrated, diff --git a/packages/dds/tree/src/simple-tree/core/withType.ts b/packages/dds/tree/src/simple-tree/core/withType.ts index 952a74b66071..1944771727d1 100644 --- a/packages/dds/tree/src/simple-tree/core/withType.ts +++ b/packages/dds/tree/src/simple-tree/core/withType.ts @@ -44,7 +44,7 @@ export const typeSchemaSymbol: unique symbol = Symbol("TreeNode Schema"); /** * The intended type of insertable content that is to become a {@link TreeNode}. - * @remarks **Note:** Whenever possible, use the type-safe {@link (TreeAlpha:interface).ensureSchema} function rather than manually employing this symbol. + * @remarks Use the type-safe {@link (TreeAlpha:interface).tagContentSchema} function to tag insertable content with this symbol. * * If a property with this symbol key is present on an object that is inserted into the tree, * the tree will use the schema identifier specified by the value of this property when creating the node. @@ -57,11 +57,11 @@ export const typeSchemaSymbol: unique symbol = Symbol("TreeNode Schema"); * class Root extends sf.object("Root", { pet: [Dog, Cat] }) {} * // ... * view.root.pet = { name: "Max" }; // Error: ambiguous schema - is it a Dog or a Cat? - * view.root.pet = { name: "Max", [schemaSymbol]: "example.Dog" }; // No error - it's a Dog. + * view.root.pet = { name: "Max", [contentSchemaSymbol]: "example.Dog" }; // No error - it's a Dog. * ``` * @alpha */ -export const schemaSymbol: unique symbol = Symbol("SharedTree Schema"); +export const contentSchemaSymbol: unique symbol = Symbol("SharedTree Schema"); /** * Adds a type symbol to a type for stronger typing. diff --git a/packages/dds/tree/src/simple-tree/index.ts b/packages/dds/tree/src/simple-tree/index.ts index 088394c7eb62..9b7997b59082 100644 --- a/packages/dds/tree/src/simple-tree/index.ts +++ b/packages/dds/tree/src/simple-tree/index.ts @@ -6,7 +6,7 @@ export { typeNameSymbol, typeSchemaSymbol, - schemaSymbol, + contentSchemaSymbol, type WithType, type TreeNodeSchema, type AnnotatedAllowedType, diff --git a/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts b/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts index c080b10656c4..34f57e657272 100644 --- a/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts +++ b/packages/dds/tree/src/simple-tree/unhydratedFlexTreeFromInsertable.ts @@ -17,7 +17,7 @@ import { isTreeNode, type TreeNode, type TreeNodeSchema, - schemaSymbol, + contentSchemaSymbol, type Unhydrated, UnhydratedFlexTreeNode, } from "./core/index.js"; @@ -126,7 +126,7 @@ For class-based schema, this can be done by replacing an expression like "{foo: /** * Returns all types for which the data is schema-compatible. - * @remarks This will respect the {@link schemaSymbol} property on data to disambiguate types - if present, only that type will be returned. + * @remarks This will respect the {@link contentSchemaSymbol} property on data to disambiguate types - if present, only that type will be returned. */ export function getPossibleTypes( allowedTypes: ReadonlySet, @@ -135,14 +135,18 @@ export function getPossibleTypes( assert(data !== undefined, 0x889 /* undefined cannot be used as FactoryContent. */); const type = typeof data === "object" && data !== null - ? (data as Partial<{ [schemaSymbol]: string }>)[schemaSymbol] + ? (data as Partial<{ [contentSchemaSymbol]: string }>)[contentSchemaSymbol] : undefined; let best = CompatibilityLevel.None; const possibleTypes: TreeNodeSchema[] = []; for (const schema of allowedTypes) { - if (type !== undefined && schema.identifier === type) { - return [schema]; + if (type !== undefined) { + if (schema.identifier === type) { + return [schema]; + } else { + continue; + } } const handler = getTreeNodeSchemaPrivateData(schema).idempotentInitialize(); const level = handler.shallowCompatibilityTest(data); diff --git a/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts b/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts index 29ba00cba051..de7bf7af5008 100644 --- a/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/treeNodeApi.spec.ts @@ -3545,7 +3545,7 @@ describe("treeNodeApi", () => { }); }); - describe("ensureType", () => { + describe("tagContentSchema", () => { const sf = new SchemaFactory("test"); class Son extends sf.object("Son", { value: sf.number }) {} class Daughter extends sf.object("Daughter", { value: sf.number }) {} @@ -3554,19 +3554,21 @@ describe("treeNodeApi", () => { }) {} it("returns the same value that was passed in", () => { const child = { value: 3 }; - const son = TreeAlpha.ensureSchema(Son, child); + const son = TreeAlpha.tagContentSchema(Son, child); assert.equal(son, child); }); it("allows leaf types", () => { const nullValue = null; - assert.equal(TreeAlpha.ensureSchema(schema.null, nullValue), nullValue); + assert.equal(TreeAlpha.tagContentSchema(schema.null, nullValue), nullValue); const booleanValue = true; - assert.equal(TreeAlpha.ensureSchema(schema.boolean, booleanValue), booleanValue); + assert.equal(TreeAlpha.tagContentSchema(schema.boolean, booleanValue), booleanValue); const numberValue = 3; - assert.equal(TreeAlpha.ensureSchema(schema.number, numberValue), numberValue); + assert.equal(TreeAlpha.tagContentSchema(schema.number, numberValue), numberValue); const stringValue = "hello"; - assert.equal(TreeAlpha.ensureSchema(schema.string, stringValue), stringValue); + assert.equal(TreeAlpha.tagContentSchema(schema.string, stringValue), stringValue); + const handleValue = new MockHandle("test"); + assert.equal(TreeAlpha.tagContentSchema(schema.handle, handleValue), handleValue); }); it("tags an object that is otherwise ambiguous", () => { @@ -3579,7 +3581,7 @@ describe("treeNodeApi", () => { validateUsageError(/compatible with more than one type/), ); // If we explicitly tag it as a Daughter, it is thereafter interpreted as such. - const daughter = TreeAlpha.ensureSchema(Daughter, child); + const daughter = TreeAlpha.tagContentSchema(Daughter, child); const parent = hydrate(Parent, { child: daughter }); assert(Tree.is(parent.child, Daughter)); }); @@ -3597,7 +3599,7 @@ describe("treeNodeApi", () => { }, validateUsageError(/compatible with more than one type/), ); - const daughters = TreeAlpha.ensureSchema(Daughters, children); + const daughters = TreeAlpha.tagContentSchema(Daughters, children); const parent = hydrate(ArrayParent, { children: daughters }); assert(Tree.is(parent.children, Daughters)); }); @@ -3619,19 +3621,29 @@ describe("treeNodeApi", () => { }, validateUsageError(/compatible with more than one type/), ); - const daughterMap = TreeAlpha.ensureSchema(DaughterMap, children); + const daughterMap = TreeAlpha.tagContentSchema(DaughterMap, children); const parent = hydrate(MapParent, { children: daughterMap }); assert(Tree.is(parent.children, DaughterMap)); }); it("can re-tag an object that has already been tagged", () => { const child = { value: 3 }; - const daughter = TreeAlpha.ensureSchema(Daughter, child); - const son = TreeAlpha.ensureSchema(Son, daughter); + const daughter = TreeAlpha.tagContentSchema(Daughter, child); + const son = TreeAlpha.tagContentSchema(Son, daughter); const parent = hydrate(Parent, { child: son }); assert(Tree.is(parent.child, Son)); }); + it("does not allow content to be interpreted as other types", () => { + const child = { value: 3 }; + hydrate(Son, child); + const daughter = TreeAlpha.tagContentSchema(Daughter, child); + assert.throws( + () => hydrate(Son, daughter), + validateUsageError(/incompatible with all of the types/), + ); + }); + it("can be used to disambiguate deep trees", () => { class Father extends sf.object("Father", { child: sf.optional([Son, Daughter]), @@ -3654,22 +3666,22 @@ describe("treeNodeApi", () => { assert.throws(() => { hydrate(GrandParent, { parent: { - child: TreeAlpha.ensureSchema(Son, { value: 3 }), + child: TreeAlpha.tagContentSchema(Son, { value: 3 }), }, }); }); // Ambiguous parent, but tagged child assert.throws(() => { hydrate(GrandParent, { - parent: TreeAlpha.ensureSchema(Father, { + parent: TreeAlpha.tagContentSchema(Father, { child: { value: 3 }, }), }); }); // Both parent and child tagged const grandParent = hydrate(GrandParent, { - parent: TreeAlpha.ensureSchema(Father, { - child: TreeAlpha.ensureSchema(Son, { value: 3 }), + parent: TreeAlpha.tagContentSchema(Father, { + child: TreeAlpha.tagContentSchema(Son, { value: 3 }), }), }); assert.ok(Tree.is(grandParent.parent, Father)); diff --git a/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts b/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts index 1057b0c09729..d857204efb1f 100644 --- a/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts @@ -43,7 +43,7 @@ import { } from "../../feature-libraries/index.js"; import { validateUsageError } from "../utils.js"; // eslint-disable-next-line import/no-internal-modules -import { schemaSymbol, UnhydratedFlexTreeNode } from "../../simple-tree/core/index.js"; +import { contentSchemaSymbol, UnhydratedFlexTreeNode } from "../../simple-tree/core/index.js"; // eslint-disable-next-line import/no-internal-modules import { getUnhydratedContext } from "../../simple-tree/createContext.js"; // eslint-disable-next-line import/no-internal-modules @@ -1469,16 +1469,16 @@ describe("unhydratedFlexTreeFromInsertable", () => { value: f.string, }) {} // Type symbol specified when there is only one valid option - const content = { [schemaSymbol]: "test.A", value: "hello" }; + const content = { [contentSchemaSymbol]: "test.A", value: "hello" }; assert.deepEqual(getPossibleTypes(new Set([A]), content), [A]); // Type symbol specified when there are multiple valid options - const ambiguousContent = { [schemaSymbol]: "test.B", value: "hello" }; + const ambiguousContent = { [contentSchemaSymbol]: "test.B", value: "hello" }; // Only B, even though A is also valid based on properties assert.deepEqual(getPossibleTypes(new Set([A, B]), ambiguousContent), [B]); // Type symbol specified that does not match any options - const invalidContent = { [schemaSymbol]: "test.C", value: "hello" }; + const invalidContent = { [contentSchemaSymbol]: "test.C", value: "hello" }; // Should fall back to A, as if no type symbol was provided assert.deepEqual(getPossibleTypes(new Set([A]), invalidContent), [A]); }); diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md index b4bb8dd3f984..6aae605cca5a 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md @@ -205,6 +205,9 @@ export interface ContainerSchema { readonly initialObjects: Record; } +// @alpha +export const contentSchemaSymbol: unique symbol; + // @alpha export function createIdentifierIndex(view: TreeView): IdentifierIndex; @@ -1335,9 +1338,6 @@ export interface SchemaStaticsAlpha { readonly typesRecursive: >[]>(t: T, metadata?: AllowedTypesMetadata) => AllowedTypesFullFromMixedUnsafe; } -// @alpha -export const schemaSymbol: unique symbol; - // @beta @sealed export class SchemaUpgrade { // (undocumented) @@ -1743,7 +1743,6 @@ export interface TreeAlpha { child(node: TreeNode, key: string | number): TreeNode | TreeLeafValue | undefined; children(node: TreeNode): Iterable<[propertyKey: string | number, child: TreeNode | TreeLeafValue]>; create(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: InsertableField): Unhydrated : TreeNode | TreeLeafValue | undefined>; - ensureSchema>(schema: TSchema, content: TContent): TContent; exportCompressed(tree: TreeNode | TreeLeafValue, options: { idCompressor?: IIdCompressor; } & Pick): JsonCompatible; @@ -1757,6 +1756,7 @@ export interface TreeAlpha { importConcise(schema: UnsafeUnknownSchema extends TSchema ? ImplicitFieldSchema : TSchema & ImplicitFieldSchema, data: ConciseTree | undefined): Unhydrated : TreeNode | TreeLeafValue | undefined>; importVerbose(schema: TSchema, data: VerboseTree | undefined, options?: TreeParsingOptions): Unhydrated>; key2(node: TreeNode): string | number | undefined; + tagContentSchema>(schema: TSchema, content: TContent): TContent; trackObservations(onInvalidation: () => void, trackDuring: () => TResult): ObservationResults; trackObservationsOnce(onInvalidation: () => void, trackDuring: () => TResult): ObservationResults; } From ba833621492920fa7966d520f25b8f2f3d666304 Mon Sep 17 00:00:00 2001 From: Noah Encke Date: Fri, 24 Oct 2025 11:15:38 -0700 Subject: [PATCH 3/4] Add clarifying error comment --- packages/dds/tree/src/shared-tree/treeAlpha.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/dds/tree/src/shared-tree/treeAlpha.ts b/packages/dds/tree/src/shared-tree/treeAlpha.ts index afa983f723cb..f05925291382 100644 --- a/packages/dds/tree/src/shared-tree/treeAlpha.ts +++ b/packages/dds/tree/src/shared-tree/treeAlpha.ts @@ -529,7 +529,9 @@ export interface TreeAlpha { * @remarks * If applicable, this will tag the given content with a {@link contentSchemaSymbol | special property} that indicates its intended schema. * The `content` will be interpreted as the given `schema` when later inserted into the tree. + * * This does not validate that the content actually conforms to the given schema (such validation will be done at insert time). + * If the content is not compatible with the tagged schema, an error will be thrown when the content is inserted. * * This is particularly useful when the content's schema cannot be inferred from its structure alone because it is compatible with multiple schemas. * @example From f13ead888db19c5cc0511e76ef8545ea9922010b Mon Sep 17 00:00:00 2001 From: Noah Encke Date: Fri, 24 Oct 2025 12:26:26 -0700 Subject: [PATCH 4/4] Fix stale test --- .../simple-tree/unhydratedFlexTreeFromInsertable.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts b/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts index d857204efb1f..c1156c502d62 100644 --- a/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/unhydratedFlexTreeFromInsertable.spec.ts @@ -1460,7 +1460,7 @@ describe("unhydratedFlexTreeFromInsertable", () => { ); }); - it("with type symbol", () => { + it("with contentSchemaSymbol", () => { const f = new SchemaFactory("test"); class A extends f.object("A", { value: f.string, @@ -1479,8 +1479,8 @@ describe("unhydratedFlexTreeFromInsertable", () => { // Type symbol specified that does not match any options const invalidContent = { [contentSchemaSymbol]: "test.C", value: "hello" }; - // Should fall back to A, as if no type symbol was provided - assert.deepEqual(getPossibleTypes(new Set([A]), invalidContent), [A]); + // Should report no valid types + assert.deepEqual(getPossibleTypes(new Set([]), invalidContent), []); }); });