diff --git a/.changeset/fruity-laws-rule.md b/.changeset/fruity-laws-rule.md new file mode 100644 index 000000000000..a66c551d722d --- /dev/null +++ b/.changeset/fruity-laws-rule.md @@ -0,0 +1,9 @@ +--- +"@fluidframework/tree": minor +"fluid-framework": minor +"__section": tree +--- +Defaulted identifier fields on unhydrated nodes are now enumerable + +Previously, there was a special case for defaulted [identifier](https://fluidframework.com/docs/api/fluid-framework/schemafactory-class#identifier-property) fields on unhydrated nodes where they were not enumerable. +This special case has been removed: they are now enumerable independent of hydration status and defaulting. diff --git a/packages/dds/tree/src/feature-libraries/default-schema/schemaChecker.ts b/packages/dds/tree/src/feature-libraries/default-schema/schemaChecker.ts index a5f99923d953..d7b826afacf0 100644 --- a/packages/dds/tree/src/feature-libraries/default-schema/schemaChecker.ts +++ b/packages/dds/tree/src/feature-libraries/default-schema/schemaChecker.ts @@ -16,6 +16,7 @@ import { } from "../../core/index.js"; import { allowsValue } from "../valueUtilities.js"; import type { MapTreeFieldViewGeneric, MinimalMapTreeNodeView } from "../mapTreeCursor.js"; +import { iterableHasSome, mapIterable } from "../../util/index.js"; export enum SchemaValidationError { Field_KindNotInSchemaPolicy, @@ -57,7 +58,7 @@ export function isNodeInSchema( // Validate the node is well formed according to its schema if (schema instanceof LeafNodeStoredSchema) { - if (node.fields.size !== 0) { + if (iterableHasSome(node.fields)) { return SchemaValidationError.LeafNode_FieldsNotAllowed; } if (!allowsValue(schema.leafValue, node.value)) { @@ -69,7 +70,7 @@ export function isNodeInSchema( } if (schema instanceof ObjectNodeStoredSchema) { - const uncheckedFieldsFromNode = new Set(node.fields.keys()); + const uncheckedFieldsFromNode = new Set(mapIterable(node.fields, ([key, field]) => key)); for (const [fieldKey, fieldSchema] of schema.objectNodeFields) { const nodeField = node.fields.get(fieldKey) ?? []; const fieldInSchemaResult = isFieldInSchema(nodeField, fieldSchema, schemaAndPolicy); diff --git a/packages/dds/tree/src/feature-libraries/flex-tree/flexTreeTypes.ts b/packages/dds/tree/src/feature-libraries/flex-tree/flexTreeTypes.ts index fedaa8f6880c..1e40c7ee0e48 100644 --- a/packages/dds/tree/src/feature-libraries/flex-tree/flexTreeTypes.ts +++ b/packages/dds/tree/src/feature-libraries/flex-tree/flexTreeTypes.ts @@ -5,7 +5,6 @@ import { type AnchorNode, - type ExclusiveMapTree, type FieldKey, type FieldKindIdentifier, type ITreeCursorSynchronous, @@ -20,6 +19,7 @@ import type { ValueFieldEditBuilder, OptionalFieldEditBuilder, } from "../default-schema/index.js"; +import type { MinimalMapTreeNodeView } from "../mapTreeCursor.js"; import type { FlexFieldKind } from "../modular-schema/index.js"; import type { FlexTreeContext } from "./context.js"; @@ -287,7 +287,7 @@ export type FlexibleFieldContent = readonly FlexibleNodeContent[]; /** * Tree for inserting as a node. */ -export type FlexibleNodeContent = ExclusiveMapTree; +export type FlexibleNodeContent = MinimalMapTreeNodeView; /** * {@link FlexTreeField} that stores a sequence of children. diff --git a/packages/dds/tree/src/feature-libraries/index.ts b/packages/dds/tree/src/feature-libraries/index.ts index 93807d9c19f8..966d7c6db4b4 100644 --- a/packages/dds/tree/src/feature-libraries/index.ts +++ b/packages/dds/tree/src/feature-libraries/index.ts @@ -18,6 +18,8 @@ export { type MinimalMapTreeNodeView, mapTreeFieldsWithField, mapTreeWithField, + type MapTreeFieldViewGeneric, + type MapTreeNodeViewGeneric, } from "./mapTreeCursor.js"; export { buildForest } from "./object-forest/index.js"; export { diff --git a/packages/dds/tree/src/feature-libraries/mapTreeCursor.ts b/packages/dds/tree/src/feature-libraries/mapTreeCursor.ts index 2ed30eb94219..d1bcb5210ce8 100644 --- a/packages/dds/tree/src/feature-libraries/mapTreeCursor.ts +++ b/packages/dds/tree/src/feature-libraries/mapTreeCursor.ts @@ -40,11 +40,11 @@ export interface MapTreeNodeViewGeneric extends NodeData { * The non-empty fields on this node. * @remarks * This is the subset of map needed to view the tree. - * Theoretically "size" could be removed by measuring the length of keys, but it is included for convenience. + * Theoretically `Symbol.iterator` and "keys" are redundant. */ readonly fields: Pick< - ReadonlyMap>, - typeof Symbol.iterator | "get" | "size" | "keys" + Map>, + typeof Symbol.iterator | "get" >; } @@ -127,7 +127,7 @@ export function cursorForMapTreeField>( const adapter: CursorAdapter = { value: (node) => node.value, type: (node) => node.type, - keysFromNode: (node) => [...node.fields.keys()], // TODO: don't convert this to array here. + keysFromNode: (node) => Array.from(node.fields, ([key, field]) => key), getFieldFromNode: (node, key): Field => { const field = node.fields.get(key) as | MinimalMapTreeNodeView[] diff --git a/packages/dds/tree/src/shared-tree/treeAlpha.ts b/packages/dds/tree/src/shared-tree/treeAlpha.ts index f46b0d4d91af..29bf5ba4b460 100644 --- a/packages/dds/tree/src/shared-tree/treeAlpha.ts +++ b/packages/dds/tree/src/shared-tree/treeAlpha.ts @@ -39,6 +39,7 @@ import { treeNodeApi, getIdentifierFromNode, mapTreeFromNodeData, + getOrCreateNodeFromInnerNode, } from "../simple-tree/index.js"; import { extractFromOpaque, type JsonCompatible } from "../util/index.js"; import type { CodecWriteOptions, ICodecOptions } from "../codec/index.js"; @@ -58,7 +59,6 @@ import { import { independentInitializedView, type ViewContent } from "./independentView.js"; import { SchematizingSimpleTreeView, ViewSlot } from "./schematizingTreeView.js"; import { currentVersion, noopValidator } from "../codec/index.js"; -import { createFromMapTree } from "../simple-tree/index.js"; const identifier: TreeIdentifierUtils = (node: TreeNode): string | undefined => { const nodeIdentifier = getIdentifierFromNode(node, "uncompressed"); @@ -371,7 +371,7 @@ export const TreeAlpha: TreeAlpha = { : TreeNode | TreeLeafValue | undefined > { const mapTree = mapTreeFromNodeData(data as InsertableField, schema); - const result = mapTree === undefined ? undefined : createFromMapTree(schema, mapTree); + const result = mapTree === undefined ? undefined : getOrCreateNodeFromInnerNode(mapTree); return result as Unhydrated< TSchema extends ImplicitFieldSchema ? TreeFieldFromImplicitField diff --git a/packages/dds/tree/src/simple-tree/api/create.ts b/packages/dds/tree/src/simple-tree/api/create.ts index 25ffa10f033b..be6f34d153e1 100644 --- a/packages/dds/tree/src/simple-tree/api/create.ts +++ b/packages/dds/tree/src/simple-tree/api/create.ts @@ -5,35 +5,46 @@ import { assert } from "@fluidframework/core-utils/internal"; -import type { - ExclusiveMapTree, - ITreeCursorSynchronous, - SchemaAndPolicy, +import { + CursorLocationType, + mapCursorField, + mapCursorFields, + type ITreeCursorSynchronous, + type SchemaAndPolicy, } from "../../core/index.js"; import type { ImplicitFieldSchema, TreeFieldFromImplicitField } from "../schemaTypes.js"; import { + type Context, getOrCreateNodeFromInnerNode, - UnhydratedFlexTreeNode, + type NodeKind, type Unhydrated, + UnhydratedFlexTreeNode, + createField, } from "../core/index.js"; import { defaultSchemaPolicy, inSchemaOrThrow, - mapTreeFromCursor, isFieldInSchema, } from "../../feature-libraries/index.js"; import { getUnhydratedContext } from "../createContext.js"; import { createUnknownOptionalFieldPolicy } from "../node-kinds/index.js"; +import type { SimpleNodeSchema, SimpleNodeSchemaBase } from "../simpleSchema.js"; +import { getStoredSchema } from "../toStoredSchema.js"; +import { unknownTypeError } from "./customTree.js"; /** * Creates an unhydrated simple-tree field from a cursor in nodes mode. + * @remarks + * Does not support defaults. + * Validates the field is in schema. */ export function createFromCursor( schema: TSchema, cursor: ITreeCursorSynchronous | undefined, ): Unhydrated> { - const mapTrees = cursor === undefined ? [] : [mapTreeFromCursor(cursor)]; const context = getUnhydratedContext(schema); + const mapTrees = cursor === undefined ? [] : [unhydratedFlexTreeFromCursor(context, cursor)]; + const flexSchema = context.flexContext.schema; const schemaValidationPolicy: SchemaAndPolicy = { @@ -58,21 +69,40 @@ export function createFromCursor( // Length asserted above, so this is safe. This assert is done instead of checking for undefined after indexing to ensure a length greater than 1 also errors. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const mapTree = mapTrees[0]!; - return createFromMapTree(schema, mapTree); + + return getOrCreateNodeFromInnerNode(mapTree) as Unhydrated< + TreeFieldFromImplicitField + >; } /** - * Creates an unhydrated simple-tree field from an ExclusiveMapTree. + * Construct an {@link UnhydratedFlexTreeNode} from a cursor in Nodes mode. + * @remarks + * This does not validate the node is in schema. */ -export function createFromMapTree( - schema: TSchema, - mapTree: ExclusiveMapTree, -): Unhydrated> { - const mapTreeNode = UnhydratedFlexTreeNode.getOrCreate( - getUnhydratedContext(schema), - mapTree, +export function unhydratedFlexTreeFromCursor( + context: Context, + cursor: ITreeCursorSynchronous, +): UnhydratedFlexTreeNode { + assert(cursor.mode === CursorLocationType.Nodes, "Expected nodes cursor"); + const schema = context.schema.get(cursor.type) ?? unknownTypeError(cursor.type); + const storedSchema = getStoredSchema( + schema as SimpleNodeSchemaBase as SimpleNodeSchema, + ); + const fields = new Map( + mapCursorFields(cursor, () => [ + cursor.getFieldKey(), + createField( + context.flexContext, + storedSchema.getFieldSchema(cursor.getFieldKey()).kind, + cursor.getFieldKey(), + mapCursorField(cursor, () => unhydratedFlexTreeFromCursor(context, cursor)), + ), + ]), + ); + return new UnhydratedFlexTreeNode( + { type: cursor.type, value: cursor.value }, + fields, + context, ); - - const result = getOrCreateNodeFromInnerNode(mapTreeNode); - return result as Unhydrated>; } diff --git a/packages/dds/tree/src/simple-tree/api/index.ts b/packages/dds/tree/src/simple-tree/api/index.ts index 68153f70adc2..71cb5e7dcc69 100644 --- a/packages/dds/tree/src/simple-tree/api/index.ts +++ b/packages/dds/tree/src/simple-tree/api/index.ts @@ -52,10 +52,7 @@ export { getPropertyKeyFromStoredKey, getIdentifierFromNode, } from "./treeNodeApi.js"; -export { - createFromCursor, - createFromMapTree, -} from "./create.js"; +export { createFromCursor } from "./create.js"; export { type JsonSchemaId, type JsonSchemaType, diff --git a/packages/dds/tree/src/simple-tree/api/schemaFactory.ts b/packages/dds/tree/src/simple-tree/api/schemaFactory.ts index 674fbfcc1f6f..14fbd1d67e19 100644 --- a/packages/dds/tree/src/simple-tree/api/schemaFactory.ts +++ b/packages/dds/tree/src/simple-tree/api/schemaFactory.ts @@ -9,7 +9,6 @@ import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; import type { TreeValue } from "../../core/index.js"; -import type { NodeIdentifierManager } from "../../feature-libraries/index.js"; // This import is required for intellisense in @link doc comments on mouseover in VSCode. // eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars import type { TreeAlpha } from "../../shared-tree/index.js"; @@ -26,6 +25,7 @@ import type { TreeNodeSchemaClass, TreeNodeSchemaNonClass, TreeNodeSchemaBoth, + UnhydratedFlexTreeNode, } from "../core/index.js"; import { isLazy } from "../flexList.js"; import { @@ -67,6 +67,10 @@ import { import { createFieldSchemaUnsafe } from "./schemaFactoryRecursive.js"; import type { System_Unsafe, FieldSchemaAlphaUnsafe } from "./typesUnsafe.js"; +import type { IIdCompressor } from "@fluidframework/id-compressor"; +import { createIdCompressor } from "@fluidframework/id-compressor/internal"; +import type { FlexTreeHydratedContextMinimal } from "../../feature-libraries/index.js"; +import { mapTreeFromNodeData } from "../toMapTree.js"; /** * Gets the leaf domain schema compatible with a given {@link TreeValue}. @@ -296,9 +300,7 @@ export interface SchemaStatics { ) => System_Unsafe.FieldSchemaUnsafe; } -const defaultOptionalProvider: DefaultProvider = getDefaultProvider(() => { - return undefined; -}); +const defaultOptionalProvider: DefaultProvider = getDefaultProvider(() => []); // The following overloads for optional and required are used to get around the fact that // the compiler can't infer that UnannotateImplicitAllowedTypes is equal to T when T is known to extend ImplicitAllowedTypes @@ -1112,8 +1114,6 @@ export class SchemaFactory< * * - A compressed form of the identifier can be accessed at runtime via the {@link TreeNodeApi.shortId|Tree.shortId()} API. * - * - It will not be present in the object's iterable properties until explicitly read or until having been inserted into a tree. - * * However, a user may alternatively supply their own string as the identifier if desired (for example, if importing identifiers from another system). * In that case, if the user requires it to be unique, it is up to them to ensure uniqueness. * User-supplied identifiers may be read immediately, even before insertion into the tree. @@ -1122,10 +1122,19 @@ export class SchemaFactory< */ public get identifier(): FieldSchema { const defaultIdentifierProvider: DefaultProvider = getDefaultProvider( - (nodeKeyManager: NodeIdentifierManager) => { - return nodeKeyManager.stabilizeNodeIdentifier( - nodeKeyManager.generateLocalNodeIdentifier(), - ); + ( + context: FlexTreeHydratedContextMinimal | "UseGlobalContext", + ): UnhydratedFlexTreeNode[] => { + const id = + context === "UseGlobalContext" + ? globalIdentifierAllocator.decompress( + globalIdentifierAllocator.generateCompressedId(), + ) + : context.nodeKeyManager.stabilizeNodeIdentifier( + context.nodeKeyManager.generateLocalNodeIdentifier(), + ); + + return [mapTreeFromNodeData(id, this.string)]; }, ); return createFieldSchema(FieldKind.Identifier, this.string, { @@ -1298,3 +1307,11 @@ export function structuralName( } return `${collectionName}<${inner}>`; } + +/** + * Used to allocate default identifiers for unhydrated nodes when no context is available. + * @remarks + * The identifiers allocated by this will never be compressed to Short Ids. + * Using this is only better than creating fully random V4 UUIDs because it reduces the entropy making it possible for things like text compression to work slightly better. + */ +const globalIdentifierAllocator: IIdCompressor = createIdCompressor(); diff --git a/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts b/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts index 4d4172cd2caf..30f3a118d507 100644 --- a/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts +++ b/packages/dds/tree/src/simple-tree/api/treeNodeApi.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assert, oob, fail } from "@fluidframework/core-utils/internal"; +import { assert, oob, fail, unreachableCase } from "@fluidframework/core-utils/internal"; import { EmptyKey, rootFieldKey } from "../../core/index.js"; import { type TreeStatus, isTreeValue, FieldKinds } from "../../feature-libraries/index.js"; @@ -34,12 +34,12 @@ import { type TreeNode, tryGetTreeNodeSchema, getOrCreateNodeFromInnerNode, - UnhydratedFlexTreeNode, typeSchemaSymbol, getOrCreateInnerNode, } from "../core/index.js"; import type { TreeChangeEvents } from "./treeChangeEvents.js"; -import { lazilyAllocateIdentifier, isObjectNodeSchema } from "../node-kinds/index.js"; +import { isObjectNodeSchema } from "../node-kinds/index.js"; +import { getTreeNodeForField } from "../getTreeNodeForField.js"; /** * Provides various functions for analyzing {@link TreeNode}s. @@ -314,29 +314,39 @@ export function getIdentifierFromNode( return undefined; case 1: { const key = identifierFieldKeys[0] ?? oob(); - const identifier = flexNode.tryGetField(key)?.boxedAt(0); - if (flexNode instanceof UnhydratedFlexTreeNode) { - if (identifier === undefined) { - return lazilyAllocateIdentifier(flexNode, key); - } - return identifier.value as string; - } - assert( - identifier?.context.isHydrated() === true, - 0xa27 /* Expected hydrated identifier */, - ); - const identifierValue = identifier.value as string; + const identifierField = flexNode.tryGetField(key); + assert(identifierField !== undefined, "missing identifier field"); + const identifierValue = getTreeNodeForField(identifierField); + assert(typeof identifierValue === "string", "identifier not a string"); - if (compression === "preferCompressed") { - const localNodeKey = - identifier.context.nodeKeyManager.tryLocalizeNodeIdentifier(identifierValue); - return localNodeKey !== undefined ? extractFromOpaque(localNodeKey) : identifierValue; - } else if (compression === "compressed") { - const localNodeKey = - identifier.context.nodeKeyManager.tryLocalizeNodeIdentifier(identifierValue); - return localNodeKey !== undefined ? extractFromOpaque(localNodeKey) : undefined; + const context = flexNode.context; + switch (compression) { + case "preferCompressed": { + if (context.isHydrated()) { + const localNodeKey = + context.nodeKeyManager.tryLocalizeNodeIdentifier(identifierValue); + return localNodeKey !== undefined + ? extractFromOpaque(localNodeKey) + : identifierValue; + } else { + return identifierValue; + } + } + case "compressed": { + if (context.isHydrated()) { + const localNodeKey = + context.nodeKeyManager.tryLocalizeNodeIdentifier(identifierValue); + return localNodeKey !== undefined ? extractFromOpaque(localNodeKey) : undefined; + } else { + return undefined; + } + } + case "uncompressed": { + return identifierValue; + } + default: + unreachableCase(compression); } - return identifierValue; } default: throw new UsageError( diff --git a/packages/dds/tree/src/simple-tree/core/getOrCreateNode.ts b/packages/dds/tree/src/simple-tree/core/getOrCreateNode.ts index ef752fe51e98..7aaf0d8fd615 100644 --- a/packages/dds/tree/src/simple-tree/core/getOrCreateNode.ts +++ b/packages/dds/tree/src/simple-tree/core/getOrCreateNode.ts @@ -8,7 +8,6 @@ import type { TreeValue } from "../../core/index.js"; import type { TreeNode } from "./treeNode.js"; import { type InnerNode, - unhydratedFlexTreeNodeToTreeNode, simpleTreeNodeSlot, createTreeNodeFromInner, } from "./treeNodeKernel.js"; @@ -23,7 +22,7 @@ import { UnhydratedFlexTreeNode } from "./unhydratedFlexTree.js"; export function getOrCreateNodeFromInnerNode(flexNode: InnerNode): TreeNode | TreeValue { const cached = flexNode instanceof UnhydratedFlexTreeNode - ? unhydratedFlexTreeNodeToTreeNode.get(flexNode) + ? flexNode.treeNode : flexNode.anchorNode.slots.get(simpleTreeNodeSlot); if (cached !== undefined) { diff --git a/packages/dds/tree/src/simple-tree/core/index.ts b/packages/dds/tree/src/simple-tree/core/index.ts index e542ee8a7130..312e9ab882eb 100644 --- a/packages/dds/tree/src/simple-tree/core/index.ts +++ b/packages/dds/tree/src/simple-tree/core/index.ts @@ -10,7 +10,6 @@ export { tryGetTreeNodeSchema, type InnerNode, tryDisposeTreeNode, - unhydratedFlexTreeNodeToTreeNode, getOrCreateInnerNode, treeNodeFromAnchor, getSimpleNodeSchemaFromInnerNode, @@ -38,7 +37,7 @@ export { Context, HydratedContext, SimpleContextSlot } from "./context.js"; export { getOrCreateNodeFromInnerNode } from "./getOrCreateNode.js"; export { UnhydratedFlexTreeNode, - UnhydratedTreeSequenceField, - tryUnhydratedFlexTreeNode, + UnhydratedSequenceField, UnhydratedContext, + createField, } from "./unhydratedFlexTree.js"; diff --git a/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts b/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts index b9ae2fbe23df..b7616ad933a6 100644 --- a/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts +++ b/packages/dds/tree/src/simple-tree/core/treeNodeKernel.ts @@ -148,7 +148,8 @@ export class TreeNodeKernel { if (innerNode instanceof UnhydratedFlexTreeNode) { // Unhydrated case - unhydratedFlexTreeNodeToTreeNodeInternal.set(innerNode, node); + debugAssert(() => innerNode.treeNode === undefined); + innerNode.treeNode = node; // Register for change events from the unhydrated flex node. // These will be fired if the unhydrated node is edited, and will also be forwarded later to the hydrated node. this.#hydrationState = { @@ -160,7 +161,7 @@ export class TreeNodeKernel { let unhydratedNode: UnhydratedFlexTreeNode | undefined = innerNode; while (unhydratedNode !== undefined) { - const treeNode = unhydratedFlexTreeNodeToTreeNodeInternal.get(unhydratedNode); + const treeNode = unhydratedNode.treeNode; if (treeNode !== undefined) { const kernel = getKernel(treeNode); kernel.#unhydratedEvents.value.emit("subtreeChangedAfterBatch"); @@ -203,7 +204,6 @@ export class TreeNodeKernel { public hydrate(anchors: AnchorSet, path: UpPath): void { assert(!this.disposed, 0xa2a /* cannot hydrate a disposed node */); assert(!isHydrated(this.#hydrationState), 0xa2b /* hydration should only happen once */); - unhydratedFlexTreeNodeToTreeNodeInternal.delete(this.#hydrationState.innerNode); const anchor = anchors.track(path); const anchorNode = @@ -292,7 +292,7 @@ export class TreeNodeKernel { } /** - * Retrieves the flex node associated with the given target via {@link setInnerNode}. + * Retrieves the flex node associated with the given target. * @remarks * For {@link Unhydrated} nodes, this returns the MapTreeNode. * @@ -339,20 +339,12 @@ export class TreeNodeKernel { } /** - * Retrieves the InnerNode associated with the given target via {@link setInnerNode}, if any. - * @remarks - * If `target` is an unhydrated node, returns its UnhydratedFlexTreeNode. - * If `target` is a cooked node (or marinated but a FlexTreeNode exists) returns the FlexTreeNode. - * If the target is a marinated node with no FlexTreeNode for its anchor, returns undefined. + * Retrieves the {@link UnhydratedFlexTreeNode} if unhydrated. otherwise undefined. */ - public tryGetInnerNode(): InnerNode | undefined { + public getInnerNodeIfUnhydrated(): UnhydratedFlexTreeNode | undefined { if (isHydrated(this.#hydrationState)) { - return ( - this.#hydrationState.innerNode ?? - this.#hydrationState.anchorNode.slots.get(flexTreeSlot) - ); + return undefined; } - return this.#hydrationState.innerNode; } } @@ -375,22 +367,6 @@ type KernelEvents = Pick; */ export type InnerNode = FlexTreeNode | UnhydratedFlexTreeNode; -/** - * Associates a given {@link UnhydratedFlexTreeNode} with a {@link TreeNode}. - */ -const unhydratedFlexTreeNodeToTreeNodeInternal = new WeakMap< - UnhydratedFlexTreeNode, - TreeNode ->(); -/** - * Retrieves the {@link TreeNode} associated with the given {@link UnhydratedFlexTreeNode} if any. - */ -export const unhydratedFlexTreeNodeToTreeNode = - unhydratedFlexTreeNodeToTreeNodeInternal as Pick< - WeakMap, - "get" - >; - /** * An anchor slot which associates an anchor with its corresponding {@link TreeNode}, if there is one. * @remarks @@ -434,7 +410,7 @@ export function getSimpleContextFromInnerNode(innerNode: InnerNode): Context { } /** - * Retrieves the flex node associated with the given target via {@link setInnerNode}. + * Retrieves the flex node associated with the given target. * @remarks * For {@link Unhydrated} nodes, this returns the MapTreeNode. * diff --git a/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts b/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts index dfe12c1c2905..23e431401142 100644 --- a/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts +++ b/packages/dds/tree/src/simple-tree/core/unhydratedFlexTree.ts @@ -12,17 +12,17 @@ import { type AnchorEvents, type AnchorNode, EmptyKey, - type ExclusiveMapTree, type FieldKey, type FieldKindIdentifier, forbiddenFieldKindIdentifier, type ITreeCursorSynchronous, - type MapTree, + type NodeData, type NormalizedFieldUpPath, type SchemaPolicy, type TreeNodeSchemaIdentifier, type TreeNodeStoredSchema, type TreeStoredSchema, + type TreeValue, type Value, } from "../../core/index.js"; import { @@ -45,21 +45,19 @@ import { type OptionalFieldEditBuilder, type ValueFieldEditBuilder, type FlexibleNodeContent, + type FlexTreeHydratedContextMinimal, + type FlexibleFieldContent, + type MapTreeFieldViewGeneric, + type MapTreeNodeViewGeneric, } from "../../feature-libraries/index.js"; -import { brand, getOrCreate, mapIterable } from "../../util/index.js"; +import { brand, filterIterable, getOrCreate } from "../../util/index.js"; import type { Context } from "./context.js"; +import type { ContextualFieldProvider } from "../schemaTypes.js"; +import type { TreeNode } from "./treeNode.js"; interface UnhydratedTreeSequenceFieldEditBuilder - extends SequenceFieldEditBuilder { - /** - * Issues a change which removes `count` elements starting at the given `index`. - * @param index - The index of the first removed element. - * @param count - The number of elements to remove. - * @returns the MapTrees that were removed - */ - remove(index: number, count: number): ExclusiveMapTree[]; -} + extends SequenceFieldEditBuilder {} type UnhydratedFlexTreeNodeEvents = Pick; @@ -70,24 +68,29 @@ interface LocationInField { } /** - * An unhydrated implementation of {@link FlexTreeNode} which wraps a {@link MapTree}. - * @remarks - * MapTreeNodes are unconditionally cached - - * when retrieved via {@link getOrCreateNodeFromInnerNode}, the same {@link MapTree} object will always produce the same `UnhydratedFlexTreeNode` object. - * - * Create a `UnhydratedFlexTreeNode` by calling {@link getOrCreate}. + * The {@link Unhydrated} implementation of {@link FlexTreeNode}. */ -export class UnhydratedFlexTreeNode implements FlexTreeNode { - public get schema(): TreeNodeSchemaIdentifier { - return this.mapTree.type; - } +export class UnhydratedFlexTreeNode + implements FlexTreeNode, MapTreeNodeViewGeneric +{ + private location = unparentedLocation; public get storedSchema(): TreeNodeStoredSchema { return ( - this.context.schema.nodeSchema.get(this.mapTree.type) ?? fail(0xb46 /* missing schema */) + this.context.schema.nodeSchema.get(this.data.type) ?? fail(0xb46 /* missing schema */) ); } + /** + * Cache storing the {@link TreeNode} for this inner node. + * @remarks + * When creating a `TreeNode` for this `UnhydratedFlexTreeNode`, cache the `TreeNode` in this property. + * Currently this is done by {@link TreeNodeKernel}. + * + * See {@link getOrCreateNodeFromInnerNode} how to get the `TreeNode`, even if not already created, regardless of hydration status. + */ + public treeNode: TreeNode | undefined; + public readonly [flexTreeMarker] = FlexTreeEntityKind.Node as const; private readonly _events = createEmitter(); @@ -95,52 +98,77 @@ export class UnhydratedFlexTreeNode implements FlexTreeNode { return this._events; } - /** - * Create a {@link UnhydratedFlexTreeNode} that wraps the given {@link MapTree}, or get the node that already exists for that {@link MapTree} if there is one. - * @param nodeSchema - the {@link FlexTreeNodeSchema | schema} that the node conforms to - * @param mapTree - the {@link MapTree} containing the data for this node. - * @remarks It must conform to the `nodeSchema`. - */ - public static getOrCreate( - context: Context, - mapTree: ExclusiveMapTree, - ): UnhydratedFlexTreeNode { - return nodeCache.get(mapTree) ?? new UnhydratedFlexTreeNode(context, mapTree, undefined); - } - public get context(): FlexTreeContext { return this.simpleContext.flexContext; } /** * Create a new UnhydratedFlexTreeNode. - * @param location - the parentage of this node, if it is being created underneath an existing node and field, or undefined if not - * @remarks This class (and its subclasses) should not be directly constructed outside of this module. - * Instead, use {@link getOrCreateNodeFromInnerNode} to create a UnhydratedFlexTreeNode from a {@link MapTree}. - * A `UnhydratedFlexTreeNode` may never be constructed more than once for the same {@link MapTree} object. - * Instead, it should always be acquired via {@link getOrCreateNodeFromInnerNode}. */ public constructor( + /** + * The {@link NodeData} for this node. + */ + public readonly data: NodeData, + /** + * All {@link UnhydratedFlexTreeField} for this node that have been created so far. + * @remarks + * This includes all non-empty fields, but also any empty fields which have been previously requested. + */ + private readonly fieldsAll: Map, + /** + * The {@link Context} for this node. + * @remarks + * Provides access to all schema reachable from this node. + * See {@link getUnhydratedContext}. + */ public readonly simpleContext: Context, - /** The underlying {@link MapTree} that this `UnhydratedFlexTreeNode` reads its data from */ - public readonly mapTree: ExclusiveMapTree, - private location = unparentedLocation, ) { - assert(!nodeCache.has(mapTree), 0x98b /* A node already exists for the given MapTree */); - nodeCache.set(mapTree, this); + for (const [_key, field] of this.fieldsAll) { + field.parent = this; + } + } + + /** + * The non-empty fields on this node. + * @remarks + * This is needed to implement {@link MapTreeNodeViewGeneric.fields}, which must omit empty fields. + * Due to having to detect if a field is empty, this forces the evaluation of any pending defaults in the fields. + * Use {@link allFieldsLazy} to avoid evaluating pending defaults. + */ + public readonly fields: Pick< + Map, + typeof Symbol.iterator | "get" + > = { + get: (key: FieldKey): UnhydratedFlexTreeField | undefined => this.tryGetField(key), + [Symbol.iterator]: (): IterableIterator<[FieldKey, UnhydratedFlexTreeField]> => + filterIterable(this.fieldsAll, ([, field]) => field.length > 0), + }; - // Fully demand the tree to ensure that parent pointers are present and accurate on all nodes. - // When a UnhydratedFlexTreeNode is constructed, its MapTree may contain nodes (anywhere below) that map (via the `nodeCache`) to pre-existing UnhydratedFlexTreeNodes. - // Put another way, for a given MapTree, some ancestor UnhydratedFlexTreeNode can be created after any number of its descendant UnhydratedFlexTreeNodes already exist. - // In such a case, the spine of nodes between the descendant and ancestor need to exist in order for the ancestor to be able to walk upwards via the `parentField` property. - // This needs to happen for all UnhydratedFlexTreeNodes that are descendants of the ancestor UnhydratedFlexTreeNode. - // Demanding the entire tree is overkill to solve this problem since not all descendant MapTree nodes will have corresponding UnhydratedFlexTreeNodes. - // However, demanding the full tree also lets us eagerly validate that there are no duplicate MapTrees (i.e. same MapTree object) anywhere in the tree. - this.walkTree(); + /** + * Gets all fields, without filtering out empty ones. + * @remarks + * This avoids forcing the evaluating of pending defaults in the fields, and also saves a copy on access. + */ + public get allFieldsLazy(): ReadonlyMap { + return this.fieldsAll; } public get type(): TreeNodeSchemaIdentifier { - return this.mapTree.type; + return this.data.type; + } + + public get schema(): TreeNodeSchemaIdentifier { + return this.data.type; + } + + private getOrCreateField(key: FieldKey): UnhydratedFlexTreeField { + return getOrCreate(this.fieldsAll, key, () => { + const stored = this.storedSchema.getFieldSchema(key).kind; + const field = createField(this.context, stored, key, []); + field.parent = this; + return field; + }); } /** @@ -156,7 +184,7 @@ export class UnhydratedFlexTreeNode implements FlexTreeNode { if (parent !== undefined) { assert(index !== undefined, 0xa08 /* Expected index */); if (this.location !== unparentedLocation) { - throw new UsageError("A node may not be inserted if it's already in a tree"); + throw new UsageError("A node may not be in more than one place in the tree"); } let unhydratedNode: UnhydratedFlexTreeNode | undefined = parent.parent; while (unhydratedNode !== undefined) { @@ -191,44 +219,32 @@ export class UnhydratedFlexTreeNode implements FlexTreeNode { } public borrowCursor(): ITreeCursorSynchronous { - return cursorForMapTreeNode(this.mapTree); + return cursorForMapTreeNode>(this); } public tryGetField(key: FieldKey): UnhydratedFlexTreeField | undefined { - const field = this.mapTree.fields.get(key); + const field = this.fieldsAll.get(key); // Only return the field if it is not empty, in order to fulfill the contract of `tryGetField`. if (field !== undefined && field.length > 0) { - return getOrCreateField(this, key, this.storedSchema.getFieldSchema(key).kind, () => - this.emitChangedEvent(key), - ); + return field; } } - public getBoxed(key: string): FlexTreeField { + public getBoxed(key: string): UnhydratedFlexTreeField { const fieldKey: FieldKey = brand(key); - return getOrCreateField( - this, - fieldKey, - this.storedSchema.getFieldSchema(fieldKey).kind, - () => this.emitChangedEvent(fieldKey), - ); + return this.getOrCreateField(fieldKey); } public boxedIterator(): IterableIterator { - return mapIterable(this.mapTree.fields.entries(), ([key]) => - getOrCreateField(this, key, this.storedSchema.getFieldSchema(key).kind, () => - this.emitChangedEvent(key), - ), - ); + return Array.from(this.fields, ([key, field]) => field)[Symbol.iterator](); } public keys(): IterableIterator { - // TODO: how this should handle missing defaults (and empty keys if they end up being allowed) needs to be determined. - return this.mapTree.fields.keys(); + return Array.from(this.fields, ([key]) => key)[Symbol.iterator](); } public get value(): Value { - return this.mapTree.value; + return this.data.value; } public get anchorNode(): AnchorNode { @@ -237,32 +253,7 @@ export class UnhydratedFlexTreeNode implements FlexTreeNode { return fail(0xb47 /* UnhydratedFlexTreeNode does not implement anchorNode */); } - private walkTree(): void { - for (const [key, mapTrees] of this.mapTree.fields) { - const field = getOrCreateField( - this, - key, - this.storedSchema.getFieldSchema(key).kind, - () => this.emitChangedEvent(key), - ); - for (let index = 0; index < field.length; index++) { - const child = getOrCreateChild(this.simpleContext, mapTrees[index] ?? oob(), { - parent: field, - index, - }); - // These next asserts detect the case where `getOrCreateChild` gets a cache hit of a different node than the one we're trying to create - assert(child.location !== undefined, 0x98d /* Expected node to have parent */); - assert( - child.location.parent.parent === this, - 0x98e /* Node may not be multi-parented */, - ); - assert(child.location.index === index, 0x98f /* Node may not be multi-parented */); - child.walkTree(); - } - } - } - - private emitChangedEvent(key: FieldKey): void { + public emitChangedEvent(key: FieldKey): void { this._events.emit("childrenChangedAfterBatch", { changedFields: new Set([key]) }); } } @@ -326,59 +317,82 @@ const unparentedLocation: LocationInField = { index: -1, }; -class UnhydratedFlexTreeField implements FlexTreeField { +/** + * The {@link Unhydrated} implementation of {@link FlexTreeField}. + */ +export class UnhydratedFlexTreeField + implements FlexTreeField, MapTreeFieldViewGeneric +{ public [flexTreeMarker] = FlexTreeEntityKind.Field as const; - public get context(): FlexTreeContext { - return this.simpleContext.flexContext; - } + public parent: UnhydratedFlexTreeNode | undefined = undefined; public constructor( - public readonly simpleContext: Context, + public readonly context: FlexTreeContext, public readonly schema: FieldKindIdentifier, public readonly key: FieldKey, - public readonly parent: UnhydratedFlexTreeNode, - public readonly onEdit?: () => void, + /** + * The children of this field. + * @remarks + * This is either an array of {@link UnhydratedFlexTreeNode}s or a {@link ContextualFieldProvider} that will be used to populate the children lazily (after which it will become an array). + * See {@link fillPendingDefaults}. + * Note that any fields using a {@link ConstantFieldProvider} should be evaluated before constructing the UnhydratedFlexTreeField. + */ + private lazyChildren: UnhydratedFlexTreeNode[] | ContextualFieldProvider, ) { - const fieldKeyCache = getFieldKeyCache(parent); - assert(!fieldKeyCache.has(key), 0x990 /* A field already exists for the given MapTrees */); - fieldKeyCache.set(key, this); - // When this field is created (which only happens one time, because it is cached), all the children become parented for the first time. // "Adopt" each child by updating its parent information to point to this field. - for (const [i, mapTree] of this.mapTrees.entries()) { - const mapTreeNodeChild = nodeCache.get(mapTree); - if (mapTreeNodeChild !== undefined) { - if (mapTreeNodeChild.parentField !== unparentedLocation) { - throw new UsageError("A node may not be in more than one place in the tree"); - } - mapTreeNodeChild.adoptBy(this, i); + if (Array.isArray(lazyChildren)) { + for (const [i, child] of lazyChildren.entries()) { + child.adoptBy(this, i); } } } - public get mapTrees(): readonly ExclusiveMapTree[] { - return this.parent.mapTree.fields.get(this.key) ?? []; + private getPendingDefault(): ContextualFieldProvider | undefined { + return !Array.isArray(this.lazyChildren) ? this.lazyChildren : undefined; + } + + /** + * Populate pending default (if present) using the provided context. + * @remarks + * This apply to just this field: caller will likely want to recursively walk the tree. + * @see {@link pendingDefault}. + */ + public fillPendingDefaults(context: FlexTreeHydratedContextMinimal): void { + const provider = this.getPendingDefault(); + if (provider) { + const content = provider(context); + this.lazyChildren = content; + } + } + + /** + * Returns true if this field has a pending default due to defined defined using a {@link ContextualFieldProvider}. + */ + public get pendingDefault(): boolean { + return this.getPendingDefault() !== undefined; + } + + public get children(): UnhydratedFlexTreeNode[] { + const provider = this.getPendingDefault(); + if (provider) { + const content = provider("UseGlobalContext"); + this.lazyChildren = content; + } + return this.lazyChildren as UnhydratedFlexTreeNode[]; } public get length(): number { - return this.mapTrees.length; + return this.children.length; } public is(kind: TKind2): this is FlexTreeTypedField { return this.schema === kind.identifier; } - public boxedIterator(): IterableIterator { - return this.mapTrees - .map( - (m, index) => - getOrCreateChild(this.simpleContext, m, { - parent: this, - index, - }) as FlexTreeNode, - ) - .values(); + public boxedIterator(): IterableIterator { + return this.children[Symbol.iterator](); } public boxedAt(index: number): FlexTreeNode | undefined { @@ -386,13 +400,12 @@ class UnhydratedFlexTreeField implements FlexTreeField { if (i === undefined) { return undefined; } - const m = this.mapTrees[i]; - if (m !== undefined) { - return getOrCreateChild(this.simpleContext, m, { - parent: this, - index: i, - }) as FlexTreeNode; - } + const m = this.children[i]; + return m; + } + + public [Symbol.iterator](): IterableIterator { + return this.boxedIterator(); } /** @@ -403,27 +416,22 @@ class UnhydratedFlexTreeField implements FlexTreeField { * @remarks All edits to the field (i.e. mutations of the field's MapTrees) should be directed through this function. * This function ensures that the parent MapTree has no empty fields (which is an invariant of `MapTree`) after the mutation. */ - protected edit(edit: (mapTrees: ExclusiveMapTree[]) => void | ExclusiveMapTree[]): void { - const oldMapTrees = this.parent.mapTree.fields.get(this.key) ?? []; - + protected edit( + edit: (mapTrees: UnhydratedFlexTreeNode[]) => void | UnhydratedFlexTreeNode[], + ): void { // Clear parents for all old map trees. - for (const tree of oldMapTrees) { - tryUnhydratedFlexTreeNode(tree)?.adoptBy(undefined); + for (const tree of this.children) { + tree.adoptBy(undefined); } - const newMapTrees = edit(oldMapTrees) ?? oldMapTrees; - if (newMapTrees.length > 0) { - this.parent.mapTree.fields.set(this.key, newMapTrees); - } else { - this.parent.mapTree.fields.delete(this.key); - } + this.lazyChildren = edit(this.children) ?? this.children; // Set parents for all new map trees. - for (const [index, tree] of newMapTrees.entries()) { - tryUnhydratedFlexTreeNode(tree)?.adoptBy(this, index); + for (const [index, tree] of this.children.entries()) { + tree.adoptBy(this, index); } - this.onEdit?.(); + this.parent?.emitChangedEvent(this.key); } public getFieldPath(): NormalizedFieldUpPath { @@ -431,23 +439,29 @@ class UnhydratedFlexTreeField implements FlexTreeField { } /** Unboxes leaf nodes to their values */ - protected unboxed(index: number): FlexTreeUnknownUnboxed { - const mapTree: ExclusiveMapTree = this.mapTrees[index] ?? oob(); - const value = mapTree.value; + protected unboxed(index: number): TreeValue | UnhydratedFlexTreeNode { + const child = this.children[index] ?? oob(); + const value = child.value; if (value !== undefined) { return value; } - - return getOrCreateChild(this.simpleContext, mapTree, { parent: this, index }); + return child; } } -class EagerMapTreeOptionalField +/** + * The {@link Unhydrated} implementation of {@link FlexTreeOptionalField}. + */ +export class UnhydratedOptionalField extends UnhydratedFlexTreeField implements FlexTreeOptionalField { public readonly editor = { - set: (newContent: ExclusiveMapTree | undefined): void => { + set: (newContent: FlexibleNodeContent | undefined): void => { + if (newContent !== undefined) { + assert(newContent instanceof UnhydratedFlexTreeNode, "Expected unhydrated node"); + } + this.edit((mapTrees) => { if (newContent !== undefined) { mapTrees[0] = newContent; @@ -460,7 +474,7 @@ class EagerMapTreeOptionalField ValueFieldEditBuilder; public get content(): FlexTreeUnknownUnboxed | undefined { - const value = this.mapTrees[0]; + const value = this.children[0]; if (value !== undefined) { return this.unboxed(0); } @@ -469,8 +483,8 @@ class EagerMapTreeOptionalField } } -class EagerMapTreeRequiredField - extends EagerMapTreeOptionalField +class UnhydratedRequiredField + extends UnhydratedOptionalField implements FlexTreeRequiredField { public override get content(): FlexTreeUnknownUnboxed { @@ -483,37 +497,42 @@ class EagerMapTreeRequiredField } } -export class UnhydratedTreeSequenceField +/** + * The {@link Unhydrated} implementation of {@link FlexTreeSequenceField}. + */ +export class UnhydratedSequenceField extends UnhydratedFlexTreeField implements FlexTreeSequenceField { - public readonly editor: UnhydratedTreeSequenceFieldEditBuilder = { + public readonly editor = { insert: (index, newContent): void => { for (const c of newContent) { assert(c !== undefined, 0xa0a /* Unexpected sparse array content */); + assert(c instanceof UnhydratedFlexTreeNode, "Expected unhydrated node"); } + const newContentChecked = newContent as readonly UnhydratedFlexTreeNode[]; this.edit((mapTrees) => { if (newContent.length < 1000) { // For "smallish arrays" (`1000` is not empirically derived), the `splice` function is appropriate... - mapTrees.splice(index, 0, ...newContent); + mapTrees.splice(index, 0, ...newContentChecked); } else { // ...but we avoid using `splice` + spread for very large input arrays since there is a limit on how many elements can be spread (too many will overflow the stack). - return mapTrees.slice(0, index).concat(newContent, mapTrees.slice(index)); + return mapTrees.slice(0, index).concat(newContentChecked, mapTrees.slice(index)); } }); }, - remove: (index, count): ExclusiveMapTree[] => { + remove: (index, count): UnhydratedFlexTreeNode[] => { for (let i = index; i < index + count; i++) { - const c = this.mapTrees[i]; + const c = this.children[i]; assert(c !== undefined, 0xa0b /* Unexpected sparse array */); } - let removed: ExclusiveMapTree[] | undefined; + let removed: UnhydratedFlexTreeNode[] | undefined; this.edit((mapTrees) => { removed = mapTrees.splice(index, count); }); return removed ?? fail(0xb4a /* Expected removed to be set by edit */); }, - }; + } satisfies UnhydratedTreeSequenceFieldEditBuilder; public at(index: number): FlexTreeUnknownUnboxed | undefined { const i = indexForAt(index, this.length); @@ -525,91 +544,30 @@ export class UnhydratedTreeSequenceField public map(callbackfn: (value: FlexTreeUnknownUnboxed, index: number) => U): U[] { return Array.from(this, callbackfn); } - - public *[Symbol.iterator](): IterableIterator { - for (const [i] of this.mapTrees.entries()) { - yield this.unboxed(i); - } - } } // #endregion Fields -// #region Caching and unboxing utilities - -const nodeCache = new WeakMap(); -/** Node Parent -\> Field Key -\> Field */ -const fieldCache = new WeakMap< - UnhydratedFlexTreeNode, - Map ->(); -function getFieldKeyCache( - parent: UnhydratedFlexTreeNode, -): WeakMap { - return getOrCreate(fieldCache, parent, () => new Map()); -} - -/** - * If there exists a {@link UnhydratedFlexTreeNode} for the given {@link MapTree}, returns it, otherwise returns `undefined`. - * @remarks {@link UnhydratedFlexTreeNode | UnhydratedFlexTreeNodes} are created via {@link getOrCreateNodeFromInnerNode}. - */ -export function tryUnhydratedFlexTreeNode( - mapTree: MapTree, -): UnhydratedFlexTreeNode | undefined { - return nodeCache.get(mapTree); -} - -/** Helper for creating a `UnhydratedFlexTreeNode` given the parent field (e.g. when "walking down") */ -function getOrCreateChild( - context: Context, - mapTree: ExclusiveMapTree, - parent: LocationInField | undefined, -): UnhydratedFlexTreeNode { - const cached = nodeCache.get(mapTree); - if (cached !== undefined) { - return cached; - } - - return new UnhydratedFlexTreeNode(context, mapTree, parent); -} - -/** Creates a field with the given attributes, or returns a cached field if there is one */ -function getOrCreateField( - parent: UnhydratedFlexTreeNode, - key: FieldKey, - schema: FieldKindIdentifier, - onEdit?: () => void, +/** Creates a field with the given attributes */ +export function createField( + ...args: ConstructorParameters ): UnhydratedFlexTreeField { - const cached = getFieldKeyCache(parent).get(key); - if (cached !== undefined) { - return cached; - } - - if ( - schema === FieldKinds.required.identifier || - schema === FieldKinds.identifier.identifier - ) { - return new EagerMapTreeRequiredField(parent.simpleContext, schema, key, parent, onEdit); - } - - if (schema === FieldKinds.optional.identifier) { - return new EagerMapTreeOptionalField(parent.simpleContext, schema, key, parent, onEdit); + switch (args[1]) { + case FieldKinds.required.identifier: + case FieldKinds.identifier.identifier: + return new UnhydratedRequiredField(...args); + case FieldKinds.optional.identifier: + return new UnhydratedOptionalField(...args); + case FieldKinds.sequence.identifier: + return new UnhydratedSequenceField(...args); + case FieldKinds.forbidden.identifier: + // TODO: this seems to used by unknown optional fields. They should probably use "optional" not "Forbidden" schema. + return new UnhydratedFlexTreeField(...args); + default: + return fail(0xb9d /* unsupported field kind */); } - - if (schema === FieldKinds.sequence.identifier) { - return new UnhydratedTreeSequenceField(parent.simpleContext, schema, key, parent, onEdit); - } - - // TODO: this seems to used by unknown optional fields. They should probably use "optional" not "Forbidden" schema. - if (schema === FieldKinds.forbidden.identifier) { - return new UnhydratedFlexTreeField(parent.simpleContext, schema, key, parent, onEdit); - } - - return fail(0xb9d /* unsupported field kind */); } -// #endregion Caching and unboxing utilities - export function unsupportedUsageError(message?: string): Error { return new UsageError( `${ diff --git a/packages/dds/tree/src/simple-tree/index.ts b/packages/dds/tree/src/simple-tree/index.ts index 134dd51ba5f8..ed39c723d207 100644 --- a/packages/dds/tree/src/simple-tree/index.ts +++ b/packages/dds/tree/src/simple-tree/index.ts @@ -22,6 +22,7 @@ export { HydratedContext, SimpleContextSlot, getOrCreateInnerNode, + getOrCreateNodeFromInnerNode, getKernel, } from "./core/index.js"; export { @@ -120,7 +121,6 @@ export { type FixRecursiveRecursionLimit, schemaStatics, type TreeChangeEvents, - createFromMapTree, } from "./api/index.js"; export type { SimpleTreeSchema, diff --git a/packages/dds/tree/src/simple-tree/node-kinds/array/arrayNode.ts b/packages/dds/tree/src/simple-tree/node-kinds/array/arrayNode.ts index b9148f419169..b642662dade6 100644 --- a/packages/dds/tree/src/simple-tree/node-kinds/array/arrayNode.ts +++ b/packages/dds/tree/src/simple-tree/node-kinds/array/arrayNode.ts @@ -39,8 +39,8 @@ import { getOrCreateInnerNode, type TreeNodeSchemaClass, getKernel, - UnhydratedFlexTreeNode, - UnhydratedTreeSequenceField, + type UnhydratedFlexTreeNode, + UnhydratedSequenceField, } from "../../core/index.js"; import { type InsertableContent, mapTreeFromNodeData } from "../../toMapTree.js"; import { prepareArrayContentForInsertion } from "../../prepareForInsertion.js"; @@ -1013,7 +1013,7 @@ abstract class CustomArrayNodeBase const movedCount = sourceEnd - sourceStart; if (!destinationField.context.isHydrated()) { - if (!(sourceField instanceof UnhydratedTreeSequenceField)) { + if (!(sourceField instanceof UnhydratedSequenceField)) { throw new UsageError( "Cannot move elements from a hydrated array to an unhydrated array.", ); @@ -1138,10 +1138,7 @@ export function arraySchema< instance: TreeNodeValid, input: T2, ): UnhydratedFlexTreeNode { - return UnhydratedFlexTreeNode.getOrCreate( - unhydratedContext, - mapTreeFromNodeData(input as object, this as unknown as ImplicitAllowedTypes), - ); + return mapTreeFromNodeData(input as object, this as unknown as ImplicitAllowedTypes); } public static get allowedTypesIdentifiers(): ReadonlySet { diff --git a/packages/dds/tree/src/simple-tree/node-kinds/index.ts b/packages/dds/tree/src/simple-tree/node-kinds/index.ts index e3b1a5f39fd4..b3bb722192a6 100644 --- a/packages/dds/tree/src/simple-tree/node-kinds/index.ts +++ b/packages/dds/tree/src/simple-tree/node-kinds/index.ts @@ -31,7 +31,6 @@ export { type InsertableObjectFromAnnotatedSchemaRecord, type InsertableObjectFromSchemaRecord, isObjectNodeSchema, - lazilyAllocateIdentifier, type ObjectFromSchemaRecord, ObjectNodeSchema, objectSchema, diff --git a/packages/dds/tree/src/simple-tree/node-kinds/map/mapNode.ts b/packages/dds/tree/src/simple-tree/node-kinds/map/mapNode.ts index 4198e7a8bd05..22a9e4930fba 100644 --- a/packages/dds/tree/src/simple-tree/node-kinds/map/mapNode.ts +++ b/packages/dds/tree/src/simple-tree/node-kinds/map/mapNode.ts @@ -33,9 +33,9 @@ import { type TreeNode, typeSchemaSymbol, type Context, - UnhydratedFlexTreeNode, getOrCreateInnerNode, type InternalTreeNode, + type UnhydratedFlexTreeNode, } from "../../core/index.js"; import { mapTreeFromNodeData, @@ -271,9 +271,9 @@ export function mapSchema< instance: TreeNodeValid, input: T2, ): UnhydratedFlexTreeNode { - return UnhydratedFlexTreeNode.getOrCreate( - unhydratedContext, - mapTreeFromNodeData(input as FactoryContent, this as unknown as ImplicitAllowedTypes), + return mapTreeFromNodeData( + input as FactoryContent, + this as unknown as ImplicitAllowedTypes, ); } diff --git a/packages/dds/tree/src/simple-tree/node-kinds/object/index.ts b/packages/dds/tree/src/simple-tree/node-kinds/object/index.ts index 3e6590396634..7f65ed9a0112 100644 --- a/packages/dds/tree/src/simple-tree/node-kinds/object/index.ts +++ b/packages/dds/tree/src/simple-tree/node-kinds/object/index.ts @@ -8,7 +8,6 @@ export { type FieldHasDefault, type InsertableObjectFromAnnotatedSchemaRecord, type InsertableObjectFromSchemaRecord, - lazilyAllocateIdentifier, type ObjectFromSchemaRecord, objectSchema, setField, diff --git a/packages/dds/tree/src/simple-tree/node-kinds/object/objectNode.ts b/packages/dds/tree/src/simple-tree/node-kinds/object/objectNode.ts index ba695d1cc17d..014e91dcbe88 100644 --- a/packages/dds/tree/src/simple-tree/node-kinds/object/objectNode.ts +++ b/packages/dds/tree/src/simple-tree/node-kinds/object/objectNode.ts @@ -5,8 +5,6 @@ import { assert, Lazy, fail, debugAssert } from "@fluidframework/core-utils/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; -import type { IIdCompressor } from "@fluidframework/id-compressor"; -import { createIdCompressor } from "@fluidframework/id-compressor/internal"; import type { FieldKey, SchemaPolicy } from "../../../core/index.js"; import { @@ -16,7 +14,7 @@ import { type FlexTreeOptionalField, type FlexTreeRequiredField, } from "../../../feature-libraries/index.js"; -import { type RestrictiveStringRecord, type FlattenKeys, brand } from "../../../util/index.js"; +import type { RestrictiveStringRecord, FlattenKeys } from "../../../util/index.js"; import { type TreeNodeSchema, @@ -28,7 +26,7 @@ import { type InternalTreeNode, type TreeNode, type Context, - UnhydratedFlexTreeNode, + type UnhydratedFlexTreeNode, getOrCreateInnerNode, } from "../../core/index.js"; import { getUnhydratedContext } from "../../createContext.js"; @@ -47,7 +45,6 @@ import { type InsertableTreeFieldFromImplicitField, type FieldSchema, normalizeFieldSchema, - type ImplicitAllowedTypes, FieldKind, type NodeSchemaMetadata, type FieldSchemaAlpha, @@ -59,7 +56,6 @@ import { import type { SimpleObjectFieldSchema } from "../../simpleSchema.js"; import { mapTreeFromNodeData, type InsertableContent } from "../../toMapTree.js"; import { TreeNodeValid, type MostDerivedData } from "../../treeNodeValid.js"; -import { stringSchema } from "../../leafNodeSchema.js"; /** * Generates the properties for an ObjectNode from its field schema object. @@ -201,37 +197,6 @@ function createFlexKeyMapping(fields: Record): Simp return keyMap; } -const globalIdentifierAllocator: IIdCompressor = createIdCompressor(); - -/** - * Modify `flexNode` to add a newly generated identifier under the given `storedKey`. - * @remarks - * This is used after checking if the user is trying to read an identifier field of an unhydrated node, but the identifier is not present. - * This means the identifier is an "auto-generated identifier", because otherwise it would have been supplied by the user at construction time and would have been successfully read just above. - * In this case, it is categorically impossible to provide an identifier (auto-generated identifiers can't be created until hydration/insertion time), so we emit an error. - * @privateRemarks - * TODO: this special case logic should move to the inner node (who's schema claims it has an identifier), rather than here, after we already read undefined out of a required field. - * TODO: unify this with a more general defaults mechanism. - */ -export function lazilyAllocateIdentifier( - flexNode: UnhydratedFlexTreeNode, - storedKey: FieldKey, -): string { - debugAssert(() => !flexNode.mapTree.fields.has(storedKey) || "Identifier field already set"); - const value = globalIdentifierAllocator.decompress( - globalIdentifierAllocator.generateCompressedId(), - ); - flexNode.mapTree.fields.set(storedKey, [ - { - type: brand(stringSchema.identifier), - value, - fields: new Map(), - }, - ]); - - return value; -} - /** * Creates a proxy handler for the given schema. * @@ -265,13 +230,6 @@ function createProxyHandler( return getTreeNodeForField(field); } - if ( - fieldInfo.schema.kind === FieldKind.Identifier && - flexNode instanceof UnhydratedFlexTreeNode - ) { - return lazilyAllocateIdentifier(flexNode, fieldInfo.storedKey); - } - return undefined; } @@ -495,10 +453,7 @@ export function objectSchema< instance: TreeNodeValid, input: T2, ): UnhydratedFlexTreeNode { - return UnhydratedFlexTreeNode.getOrCreate( - unhydratedContext, - mapTreeFromNodeData(input as object, this as unknown as ImplicitAllowedTypes), - ); + return mapTreeFromNodeData(input as object, this as Output); } protected static override constructorCached: MostDerivedData | undefined = undefined; diff --git a/packages/dds/tree/src/simple-tree/prepareForInsertion.ts b/packages/dds/tree/src/simple-tree/prepareForInsertion.ts index 838d230e2998..b583cf4529dd 100644 --- a/packages/dds/tree/src/simple-tree/prepareForInsertion.ts +++ b/packages/dds/tree/src/simple-tree/prepareForInsertion.ts @@ -4,10 +4,8 @@ */ import type { - ExclusiveMapTree, SchemaAndPolicy, IForestSubscription, - MapTree, UpPath, NodeIndex, FieldKey, @@ -19,6 +17,8 @@ import { getSchemaAndPolicy, type FlexTreeHydratedContextMinimal, FieldKinds, + type FlexibleFieldContent, + type FlexibleNodeContent, } from "../feature-libraries/index.js"; import { normalizeFieldSchema, @@ -28,12 +28,7 @@ import { import { type InsertableContent, mapTreeFromNodeData } from "./toMapTree.js"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; import { brand } from "../util/index.js"; -import { - getKernel, - type TreeNode, - tryUnhydratedFlexTreeNode, - unhydratedFlexTreeNodeToTreeNode, -} from "./core/index.js"; +import { getKernel, type TreeNode, type UnhydratedFlexTreeNode } from "./core/index.js"; import { debugAssert, oob } from "@fluidframework/core-utils/internal"; import { inSchemaOrThrow, isFieldInSchema } from "../feature-libraries/index.js"; import { convertField } from "./toStoredSchema.js"; @@ -50,7 +45,7 @@ export function prepareForInsertion( data: TIn, schema: ImplicitFieldSchema, destinationContext: FlexTreeContext, -): TIn extends undefined ? undefined : ExclusiveMapTree { +): TIn extends undefined ? undefined : FlexibleNodeContent { return prepareForInsertionContextless( data, schema, @@ -76,13 +71,9 @@ export function prepareArrayContentForInsertion( data: readonly InsertableContent[], schema: ImplicitAllowedTypes, destinationContext: FlexTreeContext, -): ExclusiveMapTree[] { - const mapTrees: ExclusiveMapTree[] = data.map((item) => - mapTreeFromNodeData( - item, - schema, - destinationContext.isHydrated() ? destinationContext.nodeKeyManager : undefined, - ), +): FlexibleFieldContent { + const mapTrees: UnhydratedFlexTreeNode[] = data.map((item) => + mapTreeFromNodeData(item, schema), ); const fieldSchema = convertField(normalizeFieldSchema(schema)); @@ -111,8 +102,8 @@ export function prepareForInsertionContextless { - batch.paths.push({ path: p, node }); - }); + walkMapTree( + item, + batch.rootPath, + (p, node) => { + batch.paths.push({ path: p, node }); + }, + context, + ); } scheduleHydration(batches, forest); } function walkMapTree( - mapTree: MapTree, + root: UnhydratedFlexTreeNode, path: UpPath, onVisitTreeNode: (path: UpPath, treeNode: TreeNode) => void, + context: FlexTreeHydratedContextMinimal, ): void { - if (tryUnhydratedFlexTreeNode(mapTree)?.parentField.parent.parent !== undefined) { + if (root.parentField.parent.parent !== undefined) { throw new UsageError( "Attempted to insert a node which is already under a parent. If this is desired, remove the node from its parent before inserting it elsewhere.", ); } - type Next = [path: UpPath, tree: MapTree]; + type Next = [path: UpPath, tree: UnhydratedFlexTreeNode]; const nexts: Next[] = []; - for (let next: Next | undefined = [path, mapTree]; next !== undefined; next = nexts.pop()) { - const [p, m] = next; - const mapTreeNode = tryUnhydratedFlexTreeNode(m); - if (mapTreeNode !== undefined) { - const treeNode = unhydratedFlexTreeNodeToTreeNode.get(mapTreeNode); + for (let next: Next | undefined = [path, root]; next !== undefined; next = nexts.pop()) { + const [p, node] = next; + if (node !== undefined) { + const treeNode = node.treeNode; if (treeNode !== undefined) { onVisitTreeNode(p, treeNode); } } - for (const [key, field] of m.fields) { - for (const [i, child] of field.entries()) { + for (const [key, field] of node.allFieldsLazy) { + field.fillPendingDefaults(context); + for (const [i, child] of field.children.entries()) { nexts.push([ { parent: p, diff --git a/packages/dds/tree/src/simple-tree/schemaTypes.ts b/packages/dds/tree/src/simple-tree/schemaTypes.ts index dd6e26102957..16c68960cd66 100644 --- a/packages/dds/tree/src/simple-tree/schemaTypes.ts +++ b/packages/dds/tree/src/simple-tree/schemaTypes.ts @@ -8,7 +8,7 @@ import { Lazy } from "@fluidframework/core-utils/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; import type { FieldKey } from "../core/index.js"; -import type { NodeIdentifierManager } from "../feature-libraries/index.js"; +import type { FlexTreeHydratedContextMinimal } from "../feature-libraries/index.js"; import { type MakeNominal, brand, @@ -30,6 +30,7 @@ import type { TreeNode, TreeNodeSchemaCore, TreeNodeSchemaNonClass, + UnhydratedFlexTreeNode, } from "./core/index.js"; import { inPrototypeChain } from "./core/index.js"; import { isLazy, type FlexListToUnion, type LazyItem } from "./flexList.js"; @@ -296,17 +297,17 @@ export interface FieldProps { } /** - * A {@link FieldProvider} which requires additional context in order to produce its content + * A {@link FieldProvider} which prefers to have additional context in order to produce its content. */ export type ContextualFieldProvider = ( - context: NodeIdentifierManager, -) => InsertableContent | undefined; + context: FlexTreeHydratedContextMinimal | "UseGlobalContext", +) => UnhydratedFlexTreeNode[]; /** - * A {@link FieldProvider} which can produce its content in a vacuum + * A {@link FieldProvider} which can produce its content in a vacuum. */ -export type ConstantFieldProvider = () => InsertableContent | undefined; +export type ConstantFieldProvider = () => UnhydratedFlexTreeNode[]; /** - * A function which produces content for a field every time that it is called + * A function which produces content for a field every time that it is called. */ export type FieldProvider = ContextualFieldProvider | ConstantFieldProvider; /** diff --git a/packages/dds/tree/src/simple-tree/toMapTree.ts b/packages/dds/tree/src/simple-tree/toMapTree.ts index 442b8569b6fb..ba24717abb7a 100644 --- a/packages/dds/tree/src/simple-tree/toMapTree.ts +++ b/packages/dds/tree/src/simple-tree/toMapTree.ts @@ -10,41 +10,34 @@ import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; import { EmptyKey, type FieldKey, - type MapTree, + type NodeData, type TreeValue, type ValueSchema, - type ExclusiveMapTree, } from "../core/index.js"; -import { - isTreeValue, - valueSchemaAllows, - type NodeIdentifierManager, -} from "../feature-libraries/index.js"; -import { brand, isReadonlyArray, find, hasSingle } from "../util/index.js"; +import { FieldKinds, isTreeValue, valueSchemaAllows } from "../feature-libraries/index.js"; +import { brand, isReadonlyArray, hasSingle } from "../util/index.js"; import { nullSchema } from "./leafNodeSchema.js"; import { - type FieldSchema, type ImplicitAllowedTypes, normalizeAllowedTypes, - extractFieldProvider, isConstant, - type FieldProvider, type ImplicitFieldSchema, normalizeFieldSchema, FieldKind, type TreeLeafValue, + extractFieldProvider, + type ContextualFieldProvider, } from "./schemaTypes.js"; import { getKernel, - getSimpleNodeSchemaFromInnerNode, isTreeNode, NodeKind, - type InnerNode, type TreeNode, type TreeNodeSchema, type Unhydrated, UnhydratedFlexTreeNode, + UnhydratedSequenceField, } from "./core/index.js"; // Required to prevent the introduction of new circular dependencies // TODO: Having the schema provide their own policy functions for compatibility which @@ -53,6 +46,10 @@ import { // eslint-disable-next-line import/no-internal-modules import { isObjectNodeSchema } from "./node-kinds/object/objectNodeTypes.js"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; +// eslint-disable-next-line import/no-internal-modules +import { createField, type UnhydratedFlexTreeField } from "./core/unhydratedFlexTree.js"; +import { convertFieldKind } from "./toStoredSchema.js"; +import { getUnhydratedContext } from "./createContext.js"; /** * Module notes: @@ -93,8 +90,7 @@ import type { IFluidHandle } from "@fluidframework/core-interfaces"; export function mapTreeFromNodeData( data: TIn, allowedTypes: ImplicitFieldSchema, - context?: NodeIdentifierManager, -): TIn extends undefined ? undefined : ExclusiveMapTree { +): TIn extends undefined ? undefined : UnhydratedFlexTreeNode { const normalizedFieldSchema = normalizeFieldSchema(allowedTypes); if (data === undefined) { @@ -102,14 +98,15 @@ export function mapTreeFromNodeData( if (normalizedFieldSchema.kind !== FieldKind.Optional) { throw new UsageError("Got undefined for non-optional field."); } - return undefined as TIn extends undefined ? undefined : ExclusiveMapTree; + return undefined as TIn extends undefined ? undefined : UnhydratedFlexTreeNode; } - const mapTree = nodeDataToMapTree(data, normalizedFieldSchema.allowedTypeSet); - // Add what defaults can be provided. If no `context` is providing, some defaults may still be missing. - addDefaultsToMapTree(mapTree, normalizedFieldSchema.allowedTypes, context); + const mapTree: UnhydratedFlexTreeNode = nodeDataToMapTree( + data, + normalizedFieldSchema.allowedTypeSet, + ); - return mapTree as TIn extends undefined ? undefined : ExclusiveMapTree; + return mapTree as TIn extends undefined ? undefined : UnhydratedFlexTreeNode; } /** @@ -122,32 +119,24 @@ export function mapTreeFromNodeData( function nodeDataToMapTree( data: InsertableContent, allowedTypes: ReadonlySet, -): ExclusiveMapTree { - // A special cache path for processing unhydrated nodes. - // They already have the mapTree, so there is no need to recompute it. - const innerNode = tryGetInnerNode(data); - if (innerNode !== undefined) { - if (innerNode instanceof UnhydratedFlexTreeNode) { - if (!allowedTypes.has(getSimpleNodeSchemaFromInnerNode(innerNode))) { - throw new UsageError("Invalid schema for this context."); - } - // TODO: mapTreeFromNodeData modifies the trees it gets to add defaults. - // Using a cached value here can result in this tree having defaults applied to it more than once. - // This is unnecessary and inefficient, but should be a no-op if all calls provide the same context (which they might not). - // A cleaner design (avoiding this cast) might be to apply defaults eagerly if they don't need a context, and lazily (when hydrating) if they do. - // This could avoid having to mutate the map tree to apply defaults, removing the need for this cast. - return innerNode.mapTree; - } else { +): UnhydratedFlexTreeNode { + if (isTreeNode(data)) { + const kernel = getKernel(data); + const inner = kernel.getInnerNodeIfUnhydrated(); + if (inner === undefined) { // The node is already hydrated, meaning that it already got inserted into the tree previously throw new UsageError("A node may not be inserted into the tree more than once"); + } else { + if (!allowedTypes.has(kernel.schema)) { + throw new UsageError("Invalid schema for this context."); + } + return inner; } } - assert(!isTreeNode(data), 0xa23 /* data without an inner node cannot be TreeNode */); - const schema = getType(data, allowedTypes); - let result: ExclusiveMapTree; + let result: FlexContent; switch (schema.kind) { case NodeKind.Leaf: result = leafToMapTree(data, schema, allowedTypes); @@ -165,9 +154,11 @@ function nodeDataToMapTree( unreachableCase(schema.kind); } - return result; + return new UnhydratedFlexTreeNode(...result, getUnhydratedContext(schema)); } +type FlexContent = [NodeData, Map]; + /** * Transforms data under a Leaf schema. * @param data - The tree data to be transformed. Must be a {@link TreeValue}. @@ -179,7 +170,7 @@ function leafToMapTree( data: FactoryContent, schema: TreeNodeSchema, allowedTypes: ReadonlySet, -): ExclusiveMapTree { +): FlexContent { assert(schema.kind === NodeKind.Leaf, 0x921 /* Expected a leaf schema. */); if (!isTreeValue(data)) { // This rule exists to protect against useless `toString` output like `[object Object]`. @@ -196,11 +187,13 @@ function leafToMapTree( 0x84a /* Unsupported schema for provided primitive. */, ); - return { - value: mappedValue, - type: brand(mappedSchema.identifier), - fields: new Map(), - }; + return [ + { + value: mappedValue, + type: brand(mappedSchema.identifier), + }, + new Map(), + ]; } /** @@ -256,7 +249,7 @@ function mapValueWithFallbacks( function arrayChildToMapTree( child: InsertableContent, allowedTypes: ReadonlySet, -): ExclusiveMapTree { +): UnhydratedFlexTreeNode { // We do not support undefined sequence entries. // If we encounter an undefined entry, use null instead if supported by the schema, otherwise throw. let childWithFallback = child; @@ -278,7 +271,7 @@ function arrayChildToMapTree( * validation should happen. If it does, the input tree will be validated against this schema + policy, and an error will * be thrown if the tree does not conform to the schema. If undefined, no validation against the stored schema is done. */ -function arrayToMapTree(data: FactoryContent, schema: TreeNodeSchema): ExclusiveMapTree { +function arrayToMapTree(data: FactoryContent, schema: TreeNodeSchema): FlexContent { assert(schema.kind === NodeKind.Array, 0x922 /* Expected an array schema. */); if (!(typeof data === "object" && data !== null && Symbol.iterator in data)) { throw new UsageError(`Input data is incompatible with Array schema: ${data}`); @@ -290,13 +283,30 @@ function arrayToMapTree(data: FactoryContent, schema: TreeNodeSchema): Exclusive arrayChildToMapTree(child, allowedChildTypes), ); - // Array nodes have a single `EmptyKey` field: - const fieldsEntries = mappedData.length === 0 ? [] : ([[EmptyKey, mappedData]] as const); + const context = getUnhydratedContext(schema).flexContext; - return { - type: brand(schema.identifier), - fields: new Map(fieldsEntries), - }; + // Array nodes have a single `EmptyKey` field: + const fieldsEntries = + mappedData.length === 0 + ? [] + : ([ + [ + EmptyKey, + new UnhydratedSequenceField( + context, + FieldKinds.sequence.identifier, + EmptyKey, + mappedData, + ), + ], + ] as const); + + return [ + { + type: brand(schema.identifier), + }, + new Map(fieldsEntries), + ]; } /** @@ -307,7 +317,7 @@ function arrayToMapTree(data: FactoryContent, schema: TreeNodeSchema): Exclusive * validation should happen. If it does, the input tree will be validated against this schema + policy, and an error will * be thrown if the tree does not conform to the schema. If undefined, no validation against the stored schema is done. */ -function mapToMapTree(data: FactoryContent, schema: TreeNodeSchema): ExclusiveMapTree { +function mapToMapTree(data: FactoryContent, schema: TreeNodeSchema): FlexContent { assert(schema.kind === NodeKind.Map, 0x923 /* Expected a Map schema. */); if (!(typeof data === "object" && data !== null)) { throw new UsageError(`Input data is incompatible with Map schema: ${data}`); @@ -323,7 +333,9 @@ function mapToMapTree(data: FactoryContent, schema: TreeNodeSchema): ExclusiveMa Object.entries(data) ) as Iterable; - const transformedFields = new Map(); + const context = getUnhydratedContext(schema).flexContext; + + const transformedFields = new Map(); for (const item of fieldsIterator) { if (!isReadonlyArray(item) || item.length !== 2 || typeof item[0] !== "string") { throw new UsageError(`Input data is incompatible with map entry: ${item}`); @@ -333,15 +345,18 @@ function mapToMapTree(data: FactoryContent, schema: TreeNodeSchema): ExclusiveMa // Omit undefined values - an entry with an undefined value is equivalent to one that has been removed or omitted if (value !== undefined) { - const mappedField = nodeDataToMapTree(value, allowedChildTypes); - transformedFields.set(brand(key), [mappedField]); + const child = nodeDataToMapTree(value, allowedChildTypes); + const field = createField(context, FieldKinds.optional.identifier, brand(key), [child]); + transformedFields.set(brand(key), field); } } - return { - type: brand(schema.identifier), - fields: transformedFields, - }; + return [ + { + type: brand(schema.identifier), + }, + transformedFields, + ]; } /** @@ -349,7 +364,7 @@ function mapToMapTree(data: FactoryContent, schema: TreeNodeSchema): ExclusiveMa * @param data - The tree data to be transformed. Must be a Record-like object. * @param schema - The schema associated with the value. */ -function objectToMapTree(data: FactoryContent, schema: TreeNodeSchema): ExclusiveMapTree { +function objectToMapTree(data: FactoryContent, schema: TreeNodeSchema): FlexContent { assert(isObjectNodeSchema(schema), 0x924 /* Expected an Object schema. */); if ( typeof data !== "object" || @@ -360,21 +375,31 @@ function objectToMapTree(data: FactoryContent, schema: TreeNodeSchema): Exclusiv throw new UsageError(`Input data is incompatible with Object schema: ${data}`); } - const fields = new Map(); + const fields = new Map(); + const context = getUnhydratedContext(schema).flexContext; - // Loop through field keys without data. - // This does NOT apply defaults. for (const [key, fieldInfo] of schema.flexKeyMap) { const value = getFieldProperty(data, key); - if (value !== undefined) { - setFieldValue(fields, value, fieldInfo.schema, fieldInfo.storedKey); + + let children: UnhydratedFlexTreeNode[] | ContextualFieldProvider; + if (value === undefined) { + const defaultProvider = + fieldInfo.schema.props?.defaultProvider ?? + fail("missing field has no default provider"); + const fieldProvider = extractFieldProvider(defaultProvider); + children = isConstant(fieldProvider) ? fieldProvider() : fieldProvider; + } else { + children = [nodeDataToMapTree(value, fieldInfo.schema.allowedTypeSet)]; } + + const kind = convertFieldKind.get(fieldInfo.schema.kind) ?? fail("Invalid field kind"); + fields.set( + fieldInfo.storedKey, + createField(context, kind.identifier, fieldInfo.storedKey, children), + ); } - return { - type: brand(schema.identifier), - fields, - }; + return [{ type: brand(schema.identifier) }, fields]; } /** @@ -402,20 +427,6 @@ function getFieldProperty( return undefined; } -function setFieldValue( - fields: Map, - fieldValue: InsertableContent | undefined, - fieldSchema: FieldSchema, - flexKey: FieldKey, -): void { - if (fieldValue !== undefined) { - const mappedChildTree = nodeDataToMapTree(fieldValue, fieldSchema.allowedTypeSet); - - assert(!fields.has(flexKey), 0x956 /* Keys must not be duplicated */); - fields.set(flexKey, [mappedChildTree]); - } -} - function getType( data: FactoryContent, allowedTypes: ReadonlySet, @@ -582,102 +593,6 @@ function allowsValue(schema: TreeNodeSchema, value: TreeValue): boolean { return false; } -/** - * Walk the given {@link ExclusiveMapTree} and deeply provide any field defaults for fields that are missing in the tree but present in the schema. - * @param mapTree - The tree to populate with defaults. This is borrowed: no references to it are kept by this function. - * @param allowedTypes - Some {@link TreeNodeSchema}, at least one of which the input tree must conform to - * @param context - An optional context for generating defaults. - * If present, all applicable defaults will be provided. - * If absent, only defaults produced by a {@link ConstantFieldProvider} will be provided, and defaults produced by a {@link ContextualFieldProvider} will be ignored. - * @remarks This function mutates the input tree by deeply adding new fields to the field maps where applicable. - */ -export function addDefaultsToMapTree( - mapTree: ExclusiveMapTree, - allowedTypes: ImplicitAllowedTypes, - context: NodeIdentifierManager | undefined, -): void { - const schema = - find(normalizeAllowedTypes(allowedTypes), (s) => s.identifier === mapTree.type) ?? - fail(0xae1 /* MapTree is incompatible with schema */); - - if (isObjectNodeSchema(schema)) { - for (const [_key, fieldInfo] of schema.flexKeyMap) { - const field = mapTree.fields.get(fieldInfo.storedKey); - if (field !== undefined) { - for (const child of field) { - addDefaultsToMapTree(child, fieldInfo.schema.allowedTypes, context); - } - } else { - const defaultProvider = fieldInfo.schema.props?.defaultProvider; - if (defaultProvider !== undefined) { - const fieldProvider = extractFieldProvider(defaultProvider); - const data = provideDefault(fieldProvider, context); - if (data !== undefined) { - setFieldValue(mapTree.fields, data, fieldInfo.schema, fieldInfo.storedKey); - // call addDefaultsToMapTree on newly inserted default values - for (const child of mapTree.fields.get(fieldInfo.storedKey) ?? - fail(0xae2 /* Expected field to be populated */)) { - addDefaultsToMapTree(child, fieldInfo.schema.allowedTypes, context); - } - } - } - } - } - return; - } - - switch (schema.kind) { - case NodeKind.Array: - case NodeKind.Map: - { - for (const field of mapTree.fields.values()) { - for (const child of field) { - addDefaultsToMapTree(child, schema.info as ImplicitAllowedTypes, context); - } - } - } - break; - default: - assert(schema.kind === NodeKind.Leaf, 0x989 /* Unrecognized schema kind */); - break; - } -} - -/** - * Provides the default value (which can be undefined, for example with optional fields), or undefined if a context is required but not provided. - * @privateRemarks - * It is a bit concerning that there is no way for the caller to know when undefined is returned if that is the default value, or a context was required. - * TODO: maybe better formalize the two stage defaulting (without then with context), or rework this design we only do one stage. - */ -function provideDefault( - fieldProvider: FieldProvider, - context: NodeIdentifierManager | undefined, -): InsertableContent | undefined { - if (context !== undefined) { - return fieldProvider(context); - } else { - if (isConstant(fieldProvider)) { - return fieldProvider(); - } else { - // Leaving field empty despite it needing a default value since a context was required and none was provided. - // Caller better handle this case by providing the default at some other point in time when the context becomes known. - } - } -} - -/** - * Retrieves the InnerNode associated with the given target via {@link setInnerNode}, if any. - * @remarks - * If `target` is a unhydrated node, returns its MapTreeNode. - * If `target` is a cooked node (or marinated but a FlexTreeNode exists) returns the FlexTreeNode. - * If the target is not a node, or a marinated node with no FlexTreeNode for its anchor, returns undefined. - */ -function tryGetInnerNode(target: unknown): InnerNode | undefined { - if (isTreeNode(target)) { - return getKernel(target).tryGetInnerNode(); - } -} - /** * Content which can be used to build a node. * @remarks diff --git a/packages/dds/tree/src/simple-tree/toStoredSchema.ts b/packages/dds/tree/src/simple-tree/toStoredSchema.ts index 75926929ea01..d6819c934739 100644 --- a/packages/dds/tree/src/simple-tree/toStoredSchema.ts +++ b/packages/dds/tree/src/simple-tree/toStoredSchema.ts @@ -93,7 +93,10 @@ export function convertField(schema: SimpleFieldSchema): TreeFieldStoredSchema { return { kind, types }; } -const convertFieldKind: ReadonlyMap = new Map< +/** + * A map that converts {@link FieldKind} to {@link FlexFieldKind}. + */ +export const convertFieldKind: ReadonlyMap = new Map< FieldKind, FlexFieldKind >([ diff --git a/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts b/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts index b08322680e6f..f383e435db4f 100644 --- a/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts +++ b/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts @@ -119,7 +119,7 @@ describe("SchematizingSimpleTreeView", () => { const root = new Root({ content: 5 }); - const inner = getKernel(root).tryGetInnerNode() ?? assert.fail("Expected child"); + const inner = getKernel(root).getOrCreateInnerNode(); const field = inner.getBoxed(brand("content")); const child = field.boxedAt(0) ?? assert.fail("Expected child"); assert(child instanceof UnhydratedFlexTreeNode); @@ -129,7 +129,7 @@ describe("SchematizingSimpleTreeView", () => { // so this hack using internal APIs is needed a workaround to test the additional schema validation layer. // In production cases this extra validation exists to help prevent corruption when bugs // allow invalid data through the public API. - (child.mapTree as Mutable).value = "invalid value"; + (child.data as Mutable).value = "invalid value"; // Attempt to initialize with invalid content if (enableSchemaValidation || additionalAsserts) { diff --git a/packages/dds/tree/src/test/simple-tree/api/create.spec.ts b/packages/dds/tree/src/test/simple-tree/api/create.spec.ts index 4294473f8441..408dbd7d4725 100644 --- a/packages/dds/tree/src/test/simple-tree/api/create.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/create.spec.ts @@ -13,7 +13,6 @@ import { import { SchemaFactory } from "../../../simple-tree/index.js"; import { validateUsageError } from "../../utils.js"; import { singleJsonCursor } from "../../json/index.js"; -import { validateAssertionError } from "@fluidframework/test-runtime-utils/internal"; describe("simple-tree create", () => { describe("createFromCursor", () => { @@ -26,7 +25,9 @@ describe("simple-tree create", () => { const cursor = singleJsonCursor("Hello world"); assert.throws( () => createFromCursor(SchemaFactory.number, cursor), - (e: Error) => validateAssertionError(e, /Tree does not conform to schema/), + validateUsageError( + `Failed to parse tree due to occurrence of type "com.fluidframework.leaf.string" which is not defined in this context.`, + ), ); }); diff --git a/packages/dds/tree/src/test/simple-tree/api/integrationTests.spec.ts b/packages/dds/tree/src/test/simple-tree/api/integrationTests.spec.ts index 0e8e41e5582d..e48d126ed513 100644 --- a/packages/dds/tree/src/test/simple-tree/api/integrationTests.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/api/integrationTests.spec.ts @@ -47,7 +47,7 @@ describe("simple-tree API integration tests", () => { () => { array.insertAtEnd(obj); }, - validateUsageError(/already in a tree/), + validateUsageError(/more than one place/), ); }); 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 621cbd270198..da26b71a08dc 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 @@ -1789,7 +1789,9 @@ describe("treeNodeApi", () => { // Input using schema not included in the context assert.throws( () => TreeAlpha.importVerbose(SchemaFactory.number, "x"), - validateUsageError(/Tree does not conform to schema/), + validateUsageError( + /type "com.fluidframework.leaf.string" which is not defined in this context/, + ), ); }); @@ -1800,7 +1802,7 @@ describe("treeNodeApi", () => { // Input using schema not included in the context assert.throws( () => TreeAlpha.importVerbose(A, { type: B.identifier, fields: {} }), - validateUsageError(/Tree does not conform to schema/), + validateUsageError(/type "Test.B" which is not defined/), ); }); @@ -1835,7 +1837,7 @@ describe("treeNodeApi", () => { // Undefined required, not provided assert.throws( () => TreeAlpha.importVerbose(schema.optional([]), 1), - validateUsageError(/Tree does not conform to schema/), + validateUsageError(/Failed to parse tree/), ); }); @@ -1845,7 +1847,7 @@ describe("treeNodeApi", () => { // invalid assert.throws( () => TreeAlpha.importVerbose([schema.null, schema.number], "x"), - validateUsageError(/Tree does not conform to schema/), + validateUsageError(/Failed to parse tree/), ); }); diff --git a/packages/dds/tree/src/test/simple-tree/core/unhydratedFlexTree.spec.ts b/packages/dds/tree/src/test/simple-tree/core/unhydratedFlexTree.spec.ts index 06d69a77c751..e78d38f2ea4a 100644 --- a/packages/dds/tree/src/test/simple-tree/core/unhydratedFlexTree.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/core/unhydratedFlexTree.spec.ts @@ -5,21 +5,26 @@ import { strict as assert } from "node:assert"; -import { FieldKinds, type FlexTreeOptionalField } from "../../../feature-libraries/index.js"; +import { cursorForMapTreeNode, FieldKinds } from "../../../feature-libraries/index.js"; import { - deepCopyMapTree, EmptyKey, type ExclusiveMapTree, type FieldKey, + type MapTree, + type Value, } from "../../../core/index.js"; import { brand } from "../../../util/index.js"; import { UnhydratedFlexTreeNode, + type UnhydratedOptionalField, // eslint-disable-next-line import/no-internal-modules } from "../../../simple-tree/core/unhydratedFlexTree.js"; import { SchemaFactory, stringSchema } from "../../../simple-tree/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 +import { unhydratedFlexTreeFromCursor } from "../../../simple-tree/api/create.js"; +import { expectEqualCursors } from "../../utils.js"; describe("unhydratedFlexTree", () => { // #region The schema used in this test suite @@ -72,15 +77,13 @@ describe("unhydratedFlexTree", () => { arrayNodeSchemaSimple, objectSchemaSimple, ]); - const map = UnhydratedFlexTreeNode.getOrCreate(context, mapMapTree); - const arrayNode = UnhydratedFlexTreeNode.getOrCreate(context, fieldNodeMapTree); - const object = UnhydratedFlexTreeNode.getOrCreate(context, objectMapTree); - it("are cached", () => { - assert.equal(UnhydratedFlexTreeNode.getOrCreate(context, mapMapTree), map); - assert.equal(UnhydratedFlexTreeNode.getOrCreate(context, fieldNodeMapTree), arrayNode); - assert.equal(UnhydratedFlexTreeNode.getOrCreate(context, objectMapTree), object); - }); + const object = unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(objectMapTree)); + const map = object.getBoxed(objectMapKey).boxedAt(0) ?? assert.fail(); + const arrayNode = object.getBoxed(objectFieldNodeKey).boxedAt(0) ?? assert.fail(); + + assert(map instanceof UnhydratedFlexTreeNode); + assert(arrayNode instanceof UnhydratedFlexTreeNode); it("can get their type", () => { assert.equal(map.type, "Test.Map"); @@ -138,10 +141,22 @@ describe("unhydratedFlexTree", () => { it("can get the children of object nodes", () => { assert.equal(object.getBoxed("map").key, "map"); - assert.equal(object.tryGetField(objectMapKey)?.boxedAt(0), map); - assert.equal(object.tryGetField(objectFieldNodeKey)?.boxedAt(0), arrayNode); - assert.equal(object.getBoxed(objectMapKey).boxedAt(0), map); - assert.equal(object.getBoxed(objectFieldNodeKey).boxedAt(0), arrayNode); + expectEqualCursors( + object.tryGetField(objectMapKey)?.boxedAt(0)?.borrowCursor(), + map.borrowCursor(), + ); + expectEqualCursors( + object.tryGetField(objectFieldNodeKey)?.boxedAt(0)?.borrowCursor(), + arrayNode.borrowCursor(), + ); + expectEqualCursors( + object.getBoxed(objectMapKey).boxedAt(0)?.borrowCursor(), + map.borrowCursor(), + ); + expectEqualCursors( + object.getBoxed(objectFieldNodeKey).boxedAt(0)?.borrowCursor(), + arrayNode.borrowCursor(), + ); assert.equal(object.tryGetField(brand("unknown key")), undefined); assert.equal(object.getBoxed("unknown key").length, 0); assert.equal([...object.boxedIterator()].length, 2); @@ -149,10 +164,13 @@ describe("unhydratedFlexTree", () => { it("cannot be multiparented", () => { assert.throws(() => - UnhydratedFlexTreeNode.getOrCreate(context, { - type: brand("Parent of a node that already has another parent"), - fields: new Map([[brand("fieldKey"), [mapMapTree]]]), - }), + unhydratedFlexTreeFromCursor( + context, + cursorForMapTreeNode({ + type: brand("Parent of a node that already has another parent"), + fields: new Map([[brand("fieldKey"), [mapMapTree]]]), + }), + ), ); const duplicateChild: ExclusiveMapTree = { @@ -161,10 +179,13 @@ describe("unhydratedFlexTree", () => { fields: new Map(), }; assert.throws(() => { - UnhydratedFlexTreeNode.getOrCreate(context, { - type: brand("Parent with the same child twice in the same field"), - fields: new Map([[EmptyKey, [duplicateChild, duplicateChild]]]), - }); + unhydratedFlexTreeFromCursor( + context, + cursorForMapTreeNode({ + type: brand("Parent with the same child twice in the same field"), + fields: new Map([[EmptyKey, [duplicateChild, duplicateChild]]]), + }), + ); }); }); @@ -196,61 +217,71 @@ describe("unhydratedFlexTree", () => { describe("can mutate", () => { it("required fields", () => { - const mutableObjectMapTree = deepCopyMapTree(objectMapTree); - const mutableObjectMapTreeMap = mutableObjectMapTree.fields.get(objectMapKey)?.[0]; - assert(mutableObjectMapTreeMap !== undefined); - const mutableObject = UnhydratedFlexTreeNode.getOrCreate(context, mutableObjectMapTree); - const field = mutableObject.getBoxed(objectMapKey) as FlexTreeOptionalField; + const mutableObject = unhydratedFlexTreeFromCursor( + context, + cursorForMapTreeNode(objectMapTree), + ); + // Find a field to edit. In this case the one with the map in it. + const field = mutableObject.getBoxed(objectMapKey) as UnhydratedOptionalField; const oldMap = field.boxedAt(0); - assert(oldMap !== undefined); + assert(oldMap instanceof UnhydratedFlexTreeNode); assert.equal(oldMap.parentField.parent.parent, mutableObject); - const newMap = UnhydratedFlexTreeNode.getOrCreate(context, deepCopyMapTree(mapMapTree)); + + // Allocate a new node + const newMap = unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(mapMapTree)); assert.notEqual(newMap, oldMap); assert.equal(newMap.parentField.parent.parent, undefined); + // Replace the old map with a new map - field.editor.set(newMap.mapTree, false); + field.editor.set(newMap); assert.equal(oldMap.parentField.parent.parent, undefined); assert.equal(newMap.parentField.parent.parent, mutableObject); assert.equal(field.boxedAt(0), newMap); + // Replace the new map with the old map again - field.editor.set(mutableObjectMapTreeMap, false); + field.editor.set(oldMap); assert.equal(oldMap.parentField.parent.parent, mutableObject); assert.equal(newMap.parentField.parent.parent, undefined); assert.equal(field.boxedAt(0), oldMap); }); it("optional fields", () => { - const mutableMap = UnhydratedFlexTreeNode.getOrCreate( + const mutableMap = unhydratedFlexTreeFromCursor( context, - deepCopyMapTree(mapMapTree), + cursorForMapTreeNode(mapMapTree), ); - const field = mutableMap.getBoxed(mapKey) as FlexTreeOptionalField; + const field = mutableMap.getBoxed(mapKey) as UnhydratedOptionalField; const oldValue = field.boxedAt(0); const newValue = `new ${childValue}`; - field.editor.set({ ...mapChildMapTree, value: newValue }, false); + const newTree: MapTree = { ...mapChildMapTree, value: newValue }; + field.editor.set(unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(newTree))); assert.equal(field.boxedAt(0)?.value, newValue); assert.notEqual(newValue, oldValue); - field.editor.set(undefined, false); + field.editor.set(undefined); assert.equal(field.boxedAt(0)?.value, undefined); }); describe("arrays", () => { it("insert and remove", () => { - const mutableFieldNode = UnhydratedFlexTreeNode.getOrCreate( + const mutableFieldNode = unhydratedFlexTreeFromCursor( context, - deepCopyMapTree(fieldNodeMapTree), + cursorForMapTreeNode(fieldNodeMapTree), ); const field = mutableFieldNode.getBoxed(EmptyKey); assert(field.is(FieldKinds.sequence)); - const values = () => Array.from(field.boxedIterator(), (n) => n.value); + const values = (): Value[] => Array.from(field.boxedIterator(), (n): Value => n.value); assert.deepEqual(values(), [childValue]); + const treeC: MapTree = { ...mapChildMapTree, value: "c" }; + const treeD: MapTree = { ...mapChildMapTree, value: "d" }; field.editor.insert(1, [ - { ...mapChildMapTree, value: "c" }, - { ...mapChildMapTree, value: "d" }, + unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(treeC)), + unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(treeD)), ]); + const treeA: MapTree = { ...mapChildMapTree, value: "a" }; + const treeB: MapTree = { ...mapChildMapTree, value: "b" }; field.editor.insert(0, [ - { ...mapChildMapTree, value: "a" }, - { ...mapChildMapTree, value: "b" }, + unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(treeA)), + unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(treeB)), ]); assert.deepEqual(values(), ["a", "b", childValue, "c", "d"]); field.editor.remove(2, 1); @@ -259,15 +290,19 @@ describe("unhydratedFlexTree", () => { it("with a large sequence of new content", () => { // This exercises a special code path for inserting large arrays, since large arrays are treated differently to avoid overflow with `splice` + spread. - const mutableFieldNode = UnhydratedFlexTreeNode.getOrCreate(context, { - ...fieldNodeMapTree, - fields: new Map(), - }); + const mutableFieldNode = unhydratedFlexTreeFromCursor( + context, + cursorForMapTreeNode({ + ...fieldNodeMapTree, + fields: new Map(), + }), + ); const field = mutableFieldNode.getBoxed(EmptyKey); assert(field.is(FieldKinds.sequence)); - const newContent: ExclusiveMapTree[] = []; + const newContent: UnhydratedFlexTreeNode[] = []; for (let i = 0; i < 10000; i++) { - newContent.push({ ...mapChildMapTree, value: String(i) }); + const tree: MapTree = { ...mapChildMapTree, value: String(i) }; + newContent.push(unhydratedFlexTreeFromCursor(context, cursorForMapTreeNode(tree))); } field.editor.insert(0, newContent); assert.equal(field.length, newContent.length); diff --git a/packages/dds/tree/src/test/simple-tree/toMapTree.spec.ts b/packages/dds/tree/src/test/simple-tree/toMapTree.spec.ts index 2c1882bcf195..98ecbc5fc1aa 100644 --- a/packages/dds/tree/src/test/simple-tree/toMapTree.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/toMapTree.spec.ts @@ -10,14 +10,10 @@ import { validateAssertionError, } from "@fluidframework/test-runtime-utils/internal"; -import { - EmptyKey, - type ExclusiveMapTree, - type FieldKey, - type MapTree, -} from "../../core/index.js"; +import { deepCopyMapTree, EmptyKey, type FieldKey, type MapTree } from "../../core/index.js"; import { booleanSchema, + getTreeNodeForField, handleSchema, nullSchema, numberSchema, @@ -25,19 +21,15 @@ import { stringSchema, type TreeNodeSchema, type ValidateRecursiveSchema, + getKernel, } from "../../simple-tree/index.js"; import { - type ContextualFieldProvider, - type ConstantFieldProvider, - type FieldProvider, - type FieldProps, createFieldSchema, FieldKind, getDefaultProvider, // eslint-disable-next-line import/no-internal-modules } from "../../simple-tree/schemaTypes.js"; import { - addDefaultsToMapTree, getPossibleTypes, mapTreeFromNodeData, type InsertableContent, @@ -46,16 +38,18 @@ import { import { brand } from "../../util/index.js"; import { MockNodeIdentifierManager, - type NodeIdentifierManager, + type FlexTreeHydratedContextMinimal, } 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"; +// eslint-disable-next-line import/no-internal-modules +import { getUnhydratedContext } from "../../simple-tree/createContext.js"; +// eslint-disable-next-line import/no-internal-modules +import { prepareContentForHydration } from "../../simple-tree/prepareForInsertion.js"; +import { hydrate } from "./utils.js"; describe("toMapTree", () => { - let nodeKeyManager: MockNodeIdentifierManager; - beforeEach(() => { - nodeKeyManager = new MockNodeIdentifierManager(); - }); - it("string", () => { const schemaFactory = new SchemaFactory("test"); const tree = "Hello world"; @@ -68,7 +62,7 @@ describe("toMapTree", () => { fields: new Map(), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("null", () => { @@ -83,7 +77,7 @@ describe("toMapTree", () => { fields: new Map(), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("handle", () => { @@ -100,7 +94,7 @@ describe("toMapTree", () => { fields: new Map(), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("recursive", () => { @@ -150,7 +144,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Fails when referenced schema has not yet been instantiated", () => { @@ -201,7 +195,7 @@ describe("toMapTree", () => { fields: new Map(), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Simple array", () => { @@ -242,7 +236,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Complex array", () => { @@ -308,7 +302,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Recursive array", () => { @@ -363,7 +357,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Throws on `undefined` entries when null is not allowed", () => { @@ -406,7 +400,7 @@ describe("toMapTree", () => { fields: new Map(), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Simple map", () => { @@ -446,7 +440,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Complex Map", () => { @@ -526,7 +520,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Undefined map entries are omitted", () => { @@ -556,7 +550,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Throws on schema-incompatible entries", () => { @@ -610,7 +604,7 @@ describe("toMapTree", () => { fields: new Map(), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Simple object", () => { @@ -653,7 +647,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Complex object", () => { @@ -732,7 +726,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Undefined properties are omitted", () => { @@ -761,7 +755,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Object with stored field keys specified", () => { @@ -810,10 +804,11 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Populates identifier field with the default identifier provider", () => { + const nodeKeyManager = new MockNodeIdentifierManager(); const schemaFactory = new SchemaFactory("test"); const schema = schemaFactory.object("object", { a: schemaFactory.identifier, @@ -821,7 +816,16 @@ describe("toMapTree", () => { const tree = {}; - const actual = mapTreeFromNodeData(tree, schema, nodeKeyManager); + const actual = mapTreeFromNodeData(tree, schema); + const dummy = hydrate(schema, {}); + const dummyContext = getKernel(dummy).context.flexContext; + assert(dummyContext.isHydrated()); + // Do the default allocation using this context + const context: FlexTreeHydratedContextMinimal = { + checkout: dummyContext.checkout, + nodeKeyManager, + }; + prepareContentForHydration([actual], context.checkout.forest, context); const expected: MapTree = { type: brand("test.object"), @@ -839,7 +843,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Populates optional field with the default optional provider.", () => { @@ -857,90 +861,7 @@ describe("toMapTree", () => { fields: new Map(), }; - assert.deepEqual(actual, expected); - }); - - it("Populates a tree with defaults", () => { - const defaultValue = 3; - const constantProvider: ConstantFieldProvider = () => { - return defaultValue; - }; - const contextualProvider: ContextualFieldProvider = (context: NodeIdentifierManager) => { - assert.equal(context, nodeKeyManager); - return defaultValue; - }; - function createDefaultFieldProps(provider: FieldProvider): FieldProps { - return { - // By design, the public `DefaultProvider` type cannot be casted to, so we must disable type checking with `any`. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - defaultProvider: provider as any, - }; - } - - const schemaFactory = new SchemaFactory("test"); - class LeafObject extends schemaFactory.object("Leaf", { - constantValue: schemaFactory.optional( - schemaFactory.number, - createDefaultFieldProps(constantProvider), - ), - contextualValue: schemaFactory.optional( - schemaFactory.number, - createDefaultFieldProps(contextualProvider), - ), - }) {} - class RootObject extends schemaFactory.object("Root", { - object: schemaFactory.required(LeafObject), - array: schemaFactory.array(LeafObject), - map: schemaFactory.map(LeafObject), - }) {} - - const nodeData = { - object: {}, - array: [{}, {}], - map: new Map([ - ["a", {}], - ["b", {}], - ]), - }; - - // Don't pass in a context - let mapTree = mapTreeFromNodeData(nodeData, RootObject); - - const getObject = () => mapTree.fields.get(brand("object"))?.[0]; - const getArray = () => mapTree.fields.get(brand("array"))?.[0].fields.get(EmptyKey); - const getMap = () => mapTree.fields.get(brand("map"))?.[0]; - const getConstantValue = (leafObject: MapTree | undefined) => - leafObject?.fields.get(brand("constantValue"))?.[0].value; - const getContextualValue = (leafObject: MapTree | undefined) => - leafObject?.fields.get(brand("contextualValue"))?.[0].value; - - // Assert that we've populated the constant defaults... - assert.equal(getConstantValue(getObject()), defaultValue); - assert.equal(getConstantValue(getArray()?.[0]), defaultValue); - assert.equal(getConstantValue(getArray()?.[1]), defaultValue); - assert.equal(getConstantValue(getMap()?.fields.get(brand("a"))?.[0]), defaultValue); - assert.equal(getConstantValue(getMap()?.fields.get(brand("b"))?.[0]), defaultValue); - // ...but not the contextual ones - assert.equal(getContextualValue(getObject()), undefined); - assert.equal(getContextualValue(getArray()?.[0]), undefined); - assert.equal(getContextualValue(getArray()?.[1]), undefined); - assert.equal(getContextualValue(getMap()?.fields.get(brand("a"))?.[0]), undefined); - assert.equal(getContextualValue(getMap()?.fields.get(brand("b"))?.[0]), undefined); - - // This time, pass the context in - mapTree = mapTreeFromNodeData(nodeData, RootObject, nodeKeyManager); - - // Assert that all defaults are populated - assert.equal(getConstantValue(getObject()), defaultValue); - assert.equal(getConstantValue(getArray()?.[0]), defaultValue); - assert.equal(getConstantValue(getArray()?.[1]), defaultValue); - assert.equal(getConstantValue(getMap()?.fields.get(brand("a"))?.[0]), defaultValue); - assert.equal(getConstantValue(getMap()?.fields.get(brand("b"))?.[0]), defaultValue); - assert.equal(getContextualValue(getObject()), defaultValue); - assert.equal(getContextualValue(getArray()?.[0]), defaultValue); - assert.equal(getContextualValue(getArray()?.[1]), defaultValue); - assert.equal(getContextualValue(getMap()?.fields.get(brand("a"))?.[0]), defaultValue); - assert.equal(getContextualValue(getMap()?.fields.get(brand("b"))?.[0]), defaultValue); + assert.deepEqual(deepCopyMapTree(actual), expected); }); }); @@ -1122,7 +1043,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("ambiguous unions", () => { @@ -1242,7 +1163,7 @@ describe("toMapTree", () => { ]), }; - assert.deepEqual(actual, expected); + assert.deepEqual(deepCopyMapTree(actual), expected); }); it("Array containing `undefined` (throws if fallback type when not allowed by the schema)", () => { @@ -1331,22 +1252,88 @@ describe("toMapTree", () => { }); }); - describe("addDefaultsToMapTree", () => { - it("custom stored key", () => { - const f = new SchemaFactory("test"); + describe("defaults", () => { + const f = new SchemaFactory("test"); + it("ConstantFieldProvider", () => { class Test extends f.object("test", { - api: createFieldSchema(FieldKind.Required, [f.number], { + api: createFieldSchema(FieldKind.Required, [f.string], { key: "stored", - defaultProvider: getDefaultProvider(() => 5), + defaultProvider: getDefaultProvider(() => [ + new UnhydratedFlexTreeNode( + { + type: brand(stringSchema.identifier), + value: "x", + }, + new Map(), + getUnhydratedContext(SchemaFactory.string), + ), + ]), }), }) {} - const m: ExclusiveMapTree = { type: brand(Test.identifier), fields: new Map() }; - addDefaultsToMapTree(m, Test, undefined); - assert.deepEqual( - m.fields, - new Map([["stored", [{ type: f.number.identifier, fields: new Map(), value: 5 }]]]), - ); + + const node = mapTreeFromNodeData({}, Test); + const field = node.getBoxed("stored"); + assert(!field.pendingDefault); + const read = getTreeNodeForField(field); + assert.equal(read, "x"); + }); + + describe("ContextualFieldProvider", () => { + class Test extends f.object("test", { + api: createFieldSchema(FieldKind.Required, [f.string], { + key: "stored", + defaultProvider: getDefaultProvider((context) => [ + new UnhydratedFlexTreeNode( + { + type: brand(stringSchema.identifier), + value: context === "UseGlobalContext" ? "global" : "contextual", + }, + new Map(), + getUnhydratedContext(SchemaFactory.string), + ), + ]), + }), + }) {} + + it("Implicit read with global context", () => { + const node = mapTreeFromNodeData({}, Test); + const field = node.getBoxed("stored"); + assert(field.pendingDefault); + const read = getTreeNodeForField(field); + assert(!field.pendingDefault); + assert.equal(read, "global"); + }); + + it("Explicit populate with valid context", () => { + const node = mapTreeFromNodeData({}, Test); + const field = node.getBoxed("stored"); + assert(field.pendingDefault); + const dummy = hydrate(Test, new Test({ api: "dummy" })); + const context = getKernel(dummy).context.flexContext; + assert(context.isHydrated()); + field.fillPendingDefaults(context); + const read = getTreeNodeForField(field); + assert(!field.pendingDefault); + assert.equal(read, "contextual"); + }); + + // Uses a context which does not know about the schema being used. + // This helps ensure that creation of invalid defaults won't assert (a usage error would be fine). + // This test does not run the schema validation, which happens after defaults are populated, so it simply must either usage error or complete. + it("Explicit populate with invalid context", () => { + const node = mapTreeFromNodeData({}, Test); + const field = node.getBoxed("stored"); + assert(field.pendingDefault); + class Test2 extends f.object("test2", {}) {} + const dummy = hydrate(Test2, new Test2({})); + const context = getKernel(dummy).context.flexContext; + assert(context.isHydrated()); + field.fillPendingDefaults(context); + const read = getTreeNodeForField(field); + assert(!field.pendingDefault); + assert.equal(read, "contextual"); + }); }); }); }); diff --git a/packages/dds/tree/src/test/simple-tree/treeNodeValid.spec.ts b/packages/dds/tree/src/test/simple-tree/treeNodeValid.spec.ts index 3671a572866b..2d2c5c3541ee 100644 --- a/packages/dds/tree/src/test/simple-tree/treeNodeValid.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/treeNodeValid.spec.ts @@ -35,9 +35,9 @@ describe("TreeNodeValid", () => { class MockFlexNode extends UnhydratedFlexTreeNode { public constructor(public readonly simpleSchema: TreeNodeSchema) { super( + { type: brand(simpleSchema.identifier) }, + new Map(), getUnhydratedContext(simpleSchema), - { fields: new Map(), type: brand(simpleSchema.identifier) }, - undefined, ); } } diff --git a/packages/dds/tree/src/test/simple-tree/unhydratedNode.spec.ts b/packages/dds/tree/src/test/simple-tree/unhydratedNode.spec.ts index 672d6d2ae06d..cb9d824c3f72 100644 --- a/packages/dds/tree/src/test/simple-tree/unhydratedNode.spec.ts +++ b/packages/dds/tree/src/test/simple-tree/unhydratedNode.spec.ts @@ -27,6 +27,11 @@ import { TreeStatus } from "../../feature-libraries/index.js"; import { validateUsageError } from "../utils.js"; // eslint-disable-next-line import/no-internal-modules import { UnhydratedFlexTreeNode } from "../../simple-tree/core/unhydratedFlexTree.js"; +import { singleJsonCursor } from "../json/index.js"; +// eslint-disable-next-line import/no-internal-modules +import { unhydratedFlexTreeFromCursor } from "../../simple-tree/api/create.js"; +// eslint-disable-next-line import/no-internal-modules +import { getUnhydratedContext } from "../../simple-tree/createContext.js"; describe("Unhydrated nodes", () => { const schemaFactory = new SchemaFactory("undefined"); @@ -279,10 +284,24 @@ describe("Unhydrated nodes", () => { assertArray(); }); + it("flexTreeFromCursor", () => { + const tree = unhydratedFlexTreeFromCursor( + getUnhydratedContext(SchemaFactory.number), + singleJsonCursor(1), + ); + + assert.equal(tree.value, 1); + }); + it("read constant defaulted properties", () => { const defaultValue = 3; - const constantProvider: ConstantFieldProvider = () => { - return defaultValue; + const constantProvider: ConstantFieldProvider = (): UnhydratedFlexTreeNode[] => { + return [ + unhydratedFlexTreeFromCursor( + getUnhydratedContext(SchemaFactory.number), + singleJsonCursor(defaultValue), + ), + ]; }; class HasDefault extends schemaFactory.object("DefaultingLeaf", { value: schemaFactory.optional( @@ -294,12 +313,16 @@ describe("Unhydrated nodes", () => { assert.equal(defaultingLeaf.value, defaultValue); }); - // TODO: Fail instead of returning undefined, as is the case for identifiers. it("read undefined for contextual defaulted properties", () => { const defaultValue = 3; const contextualProvider: ContextualFieldProvider = (context: unknown) => { - assert.notEqual(context, undefined); - return defaultValue; + assert.equal(context, "UseGlobalContext"); + return [ + unhydratedFlexTreeFromCursor( + getUnhydratedContext(SchemaFactory.number), + singleJsonCursor(defaultValue), + ), + ]; }; class HasDefault extends schemaFactory.object("DefaultingLeaf", { value: schemaFactory.optional( @@ -308,7 +331,7 @@ describe("Unhydrated nodes", () => { ), }) {} const defaultingLeaf = new HasDefault({ value: undefined }); - assert.equal(defaultingLeaf.value, undefined); + assert.equal(defaultingLeaf.value, defaultValue); }); it("read manually provided identifiers", () => { @@ -338,7 +361,11 @@ describe("Unhydrated nodes", () => { const id = "my identifier"; const object = new TestObjectWithId({ id, autoId: undefined }); - assert.deepEqual(Object.entries(object), [["id", id]]); + assert.deepEqual(Object.entries(object), [ + ["id", id], + ["autoId", object.autoId], + ]); + assert(isStableId(object.autoId)); }); it("cannot be used twice in the same tree", () => { diff --git a/packages/dds/tree/src/test/util/utils.spec.ts b/packages/dds/tree/src/test/util/utils.spec.ts index cdd2e6aa12c4..bd1b48560cd0 100644 --- a/packages/dds/tree/src/test/util/utils.spec.ts +++ b/packages/dds/tree/src/test/util/utils.spec.ts @@ -10,6 +10,7 @@ import { capitalize, copyProperty, defineLazyCachedProperty, + iterableHasSome, mapIterable, transformObjectMap, } from "../../util/index.js"; @@ -157,4 +158,10 @@ describe("Utils", () => { assert.equal(delegateCallCount, 6); }); }); + + it("iterableHasSome", () => { + assert(!iterableHasSome([])); + assert(iterableHasSome([1])); + assert(!iterableHasSome(new Map([]))); + }); }); diff --git a/packages/dds/tree/src/util/index.ts b/packages/dds/tree/src/util/index.ts index e4a390467f43..fc6b2995fc47 100644 --- a/packages/dds/tree/src/util/index.ts +++ b/packages/dds/tree/src/util/index.ts @@ -95,6 +95,7 @@ export { defineLazyCachedProperty, copyPropertyIfDefined as copyProperty, getOrAddInMap, + iterableHasSome, } from "./utils.js"; export { ReferenceCountedBase, type ReferenceCounted } from "./referenceCounting.js"; diff --git a/packages/dds/tree/src/util/utils.ts b/packages/dds/tree/src/util/utils.ts index 97b5c89c92b2..5dc107c53bd5 100644 --- a/packages/dds/tree/src/util/utils.ts +++ b/packages/dds/tree/src/util/utils.ts @@ -98,6 +98,13 @@ export function hasSome(array: readonly T[]): array is [T, ...T[]] { return array.length > 0; } +/** + * Returns true if and only if the given iterable has at least one element. + */ +export function iterableHasSome(iterable: Iterable): boolean { + return iterable[Symbol.iterator]().next().done === false; +} + /** * Returns true if and only if the given array has exactly one element. * @param array - The array to check.