Skip to content

Commit 62dc0b0

Browse files
Cleanup related to allowUnknownOptionalFields (#25074)
## Description Change and clarify limitations related to alpha features allowUnknownOptionalFields and importVerbose See changeset for user facing details. Internally this cleans up some debt where schema policy was being used in ways that it was not intended. ## Breaking Changes See changeset.
1 parent b15d5dd commit 62dc0b0

File tree

28 files changed

+337
-222
lines changed

28 files changed

+337
-222
lines changed

.changeset/cruel-geese-shine.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@fluidframework/tree": minor
3+
"fluid-framework": minor
4+
"__section": tree
5+
---
6+
Change and clarify limitations related to alpha features allowUnknownOptionalFields and importVerbose
7+
8+
[allowUnknownOptionalFields](https://fluidframework.com/docs/api/fluid-framework/schemafactoryobjectoptions-interface#allowunknownoptionalfields-propertysignature) currently has some limitations.
9+
To mitigate some bugs, [importVerbose](https://fluidframework.com/docs/api/fluid-framework/treealpha-interface#importverbose-methodsignature) has dropped support for unknown optional fields.
10+
Previously `importVerbose` would tolerate some unknown optional fields, but could not validate they comply with the document stored schema.
11+
This could cause some crashes, and likely document corruption.
12+
This support has been removed: now trying to create nodes containing unknown optional fields via `importVerbose` will throw a `UsageError`.
13+
There is no longer a way to create and insert nodes which contain subtrees for which there is no schema.
14+
15+
Ideally `exportVerbose` and `importVerbose` could be used to round trip data while optionally preserving unknown optional fields, but this is currently not working and thus not supported.
16+
17+
If exporting using [useStoredKeys](https://fluidframework.com/docs/api/fluid-framework/treeencodingoptions-interface#usestoredkeys-propertysignature), the unknown optional fields will be preserved but may not be able to be imported.
18+
If exporting not using `useStoredKeys`, a known bug currently causes an assertion to fail.

packages/dds/tree/src/core/schema-stored/schema.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -123,27 +123,6 @@ export interface SchemaPolicy {
123123
* and will be unable to process any changes that use those FieldKinds.
124124
*/
125125
readonly fieldKinds: ReadonlyMap<FieldKindIdentifier, FieldKindData>;
126-
127-
/**
128-
* If true, new content inserted into the tree should be validated against the stored schema.
129-
* @remarks
130-
* TODO: AB#43546: This is not information used to interpret the stored schema: this configuration should be moved elsewhere.
131-
*/
132-
readonly validateSchema: boolean;
133-
134-
/**
135-
* Whether to allow a document to be opened when a particular stored schema (identified by `identifier`)
136-
* contains optional fields that are not known to the view schema.
137-
*
138-
* @privateRemarks
139-
* Plumbing this in via `SchemaPolicy` avoids needing to walk the view schema representation repeatedly in places
140-
* that need it (schema validation, view vs stored compatibility checks).
141-
*
142-
* TODO: AB#43546
143-
* This is not information used to interpret the stored schema: it is instead about view schema, and how compatible they are with a stored schema.
144-
* SchemaCompatibilityTester should be updated to not store this table in here, and then this field should be removed.
145-
*/
146-
allowUnknownOptionalFields(identifier: TreeNodeSchemaIdentifier): boolean;
147126
}
148127

149128
/**

packages/dds/tree/src/feature-libraries/default-schema/defaultSchema.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,4 @@ import { fieldKinds } from "./defaultFieldKinds.js";
1212
*/
1313
export const defaultSchemaPolicy: FullSchemaPolicy = {
1414
fieldKinds,
15-
validateSchema: false,
16-
allowUnknownOptionalFields: () => false,
1715
};

packages/dds/tree/src/feature-libraries/default-schema/schemaChecker.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,11 @@ export function isNodeInSchema(
7979
}
8080
uncheckedFieldsFromNode.delete(fieldKey);
8181
}
82-
// The node has fields that we did not check as part of looking at every field defined in the node's schema
83-
if (
84-
uncheckedFieldsFromNode.size !== 0 &&
85-
// TODO: AB#43547: This check is wrong. If a given view schema allows an unknown optional field, that does NOT mean the stored schema should allow unknown:
86-
// In-fact, any data the view schema does not know about must still comply with the stored schema:
87-
// if this were not the case schema evolution could not add any fields since they might already have out of schema data.
88-
!schemaAndPolicy.policy.allowUnknownOptionalFields(node.type)
89-
) {
82+
// The node has fields that we did not check as part of looking at every field defined in the node's schema.
83+
// Since this is testing compatibility with a stored schema (not view schema), "allowUnknownOptionalFields" does not exist at this layer.
84+
// Code using this with a stored schema derived from a view schema rather than the document can be problematic because it may be missing unknown fields that the actual document has.
85+
// Other schema evolution features like "staged" allowed types will likely cause similar issues elsewhere in this checker.
86+
if (uncheckedFieldsFromNode.size !== 0) {
9087
return SchemaValidationError.ObjectNode_FieldNotInSchema;
9188
}
9289
} else if (schema instanceof MapNodeStoredSchema) {

packages/dds/tree/src/shared-tree/schematizeTree.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ export function ensureSchema(
219219
return false;
220220
}
221221
case UpdateType.SchemaCompatible: {
222-
checkout.updateSchema(toStoredSchema(viewSchema.viewSchemaRoot));
222+
checkout.updateSchema(toStoredSchema(viewSchema.viewSchema.root));
223223
return true;
224224
}
225225
default: {

packages/dds/tree/src/shared-tree/schematizingTreeView.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
import { assert, unreachableCase } from "@fluidframework/core-utils/internal";
1313
import { UsageError } from "@fluidframework/telemetry-utils/internal";
1414

15-
import { anchorSlot, type SchemaPolicy } from "../core/index.js";
15+
import { anchorSlot } from "../core/index.js";
1616
import {
1717
type NodeIdentifierManager,
1818
defaultSchemaPolicy,
@@ -49,11 +49,11 @@ import {
4949
HydratedContext,
5050
SimpleContextSlot,
5151
areImplicitFieldSchemaEqual,
52-
createUnknownOptionalFieldPolicy,
5352
prepareForInsertionContextless,
5453
type FieldSchema,
5554
toStoredSchema,
5655
tryDisposeTreeNode,
56+
TreeViewConfigurationAlpha,
5757
} from "../simple-tree/index.js";
5858
import {
5959
type Breakable,
@@ -90,7 +90,6 @@ export class SchematizingSimpleTreeView<
9090
* Undefined iff uninitialized or disposed.
9191
*/
9292
private currentCompatibility: SchemaCompatibilityStatus | undefined;
93-
private readonly schemaPolicy: SchemaPolicy;
9493
public readonly events: Listenable<TreeViewEvents & TreeBranchEvents> &
9594
IEmitter<TreeViewEvents & TreeBranchEvents> &
9695
HasListeners<TreeViewEvents & TreeBranchEvents> = createEmitter();
@@ -132,13 +131,10 @@ export class SchematizingSimpleTreeView<
132131
checkout.forest.anchors.slots.set(ViewSlot, this);
133132

134133
this.rootFieldSchema = normalizeFieldSchema(config.schema);
135-
this.schemaPolicy = {
136-
...defaultSchemaPolicy,
137-
validateSchema: config.enableSchemaValidation,
138-
allowUnknownOptionalFields: createUnknownOptionalFieldPolicy(this.rootFieldSchema),
139-
};
140134

141-
this.viewSchema = new SchemaCompatibilityTester(this.schemaPolicy, this.rootFieldSchema);
135+
const configAlpha = new TreeViewConfigurationAlpha({ schema: config.schema });
136+
137+
this.viewSchema = new SchemaCompatibilityTester(configAlpha);
142138
// This must be initialized before `update` can be called.
143139
this.currentCompatibility = {
144140
canView: false,
@@ -175,13 +171,13 @@ export class SchematizingSimpleTreeView<
175171
}
176172

177173
this.runSchemaEdit(() => {
178-
const schema = toStoredSchema(this.viewSchema.viewSchemaRoot);
174+
const schema = toStoredSchema(this.config.schema);
179175
const mapTree = prepareForInsertionContextless(
180176
content as InsertableContent | undefined,
181177
this.rootFieldSchema,
182178
{
183179
schema,
184-
policy: this.schemaPolicy,
180+
policy: defaultSchemaPolicy,
185181
},
186182
this,
187183
);
@@ -316,7 +312,7 @@ export class SchematizingSimpleTreeView<
316312

317313
if (compatibility.canView) {
318314
this.flexTreeContext = new Context(
319-
this.schemaPolicy,
315+
defaultSchemaPolicy,
320316
this.checkout,
321317
this.nodeKeyManager,
322318
);

packages/dds/tree/src/shared-tree/treeAlpha.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,14 @@ export interface TreeAlpha {
263263
* Construct tree content compatible with a field defined by the provided `schema`.
264264
* @param schema - The schema for what to construct. As this is an {@link ImplicitFieldSchema}, a {@link FieldSchema}, {@link TreeNodeSchema} or {@link AllowedTypes} array can be provided.
265265
* @param data - The data used to construct the field content. See {@link (TreeAlpha:interface).(exportVerbose:1)}.
266+
* @remarks
267+
* This currently does not support input containing {@link SchemaFactoryObjectOptions.allowUnknownOptionalFields| unknown optional fields}.
268+
* @privateRemarks
269+
* See TODOs in {@link TreeEncodingOptions}.
270+
*
271+
* TODO: clarify how this handles out of schema data.
272+
* Does it robustly validate? How do you use it with schema evolution features like staged allowed types and allowUnknownOptionalFields? Which errors are deferred until insertion/hydration?
273+
* Ensure whatever policy is chosen is documented, enforces, tested and applied consistently to all import code paths.
266274
*/
267275
importVerbose<const TSchema extends ImplicitFieldSchema>(
268276
schema: TSchema,

packages/dds/tree/src/simple-tree/api/configuration.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,20 @@ import type { SimpleNodeSchema, SimpleTreeSchema } from "../simpleSchema.js";
4747
*/
4848
export interface ITreeConfigurationOptions {
4949
/**
50-
* If `true`, the tree will validate new content against its stored schema at insertion time
50+
* If `true`, the tree will perform additional validation of content against its stored schema
5151
* and throw an error if the new content doesn't match the expected schema.
5252
*
5353
* @defaultValue `false`.
5454
*
55-
* @remarks Enabling schema validation has a performance penalty when inserting new content into the tree because
55+
* @remarks
56+
* Currently most cases already have some schema validation, so this is mainly for additional validation which may be useful when debugging issues,
57+
* working with untyped APIs, or when the small performance overhead is a non-issue.
58+
*
59+
* Enabling schema validation has a performance penalty when inserting new content into the tree because
5660
* additional checks are done. Enable this option only in scenarios where you are ok with that operation being a
5761
* bit slower.
62+
*
63+
* For additional validation in more cases, see {@link ForestTypeExpensiveDebug}.
5864
*/
5965
enableSchemaValidation?: boolean;
6066

packages/dds/tree/src/simple-tree/api/create.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
*/
55

66
import { assert } from "@fluidframework/core-utils/internal";
7+
import { UsageError } from "@fluidframework/telemetry-utils/internal";
78

89
import {
910
CursorLocationType,
11+
forbiddenFieldKindIdentifier,
1012
mapCursorField,
1113
mapCursorFields,
1214
type ITreeCursorSynchronous,
@@ -27,7 +29,6 @@ import {
2729
isFieldInSchema,
2830
} from "../../feature-libraries/index.js";
2931
import { getUnhydratedContext } from "../createContext.js";
30-
import { createUnknownOptionalFieldPolicy } from "../node-kinds/index.js";
3132
import type { SimpleNodeSchema, SimpleNodeSchemaBase } from "../simpleSchema.js";
3233
import { getStoredSchema } from "../toStoredSchema.js";
3334
import { unknownTypeError } from "./customTree.js";
@@ -52,11 +53,11 @@ export function createFromCursor<const TSchema extends ImplicitFieldSchema>(
5253
const schemaValidationPolicy: SchemaAndPolicy = {
5354
policy: {
5455
...defaultSchemaPolicy,
55-
allowUnknownOptionalFields: createUnknownOptionalFieldPolicy(schema),
5656
},
5757
schema: context.flexContext.schema,
5858
};
5959

60+
// TODO: AB#43548: Using a stored schema from the possibly unhydrated flex tree context does not handle schema evolution features like "allowUnknownOptionalFields".
6061
const maybeError = isFieldInSchema(
6162
mapTrees,
6263
flexSchema.rootFieldSchema,
@@ -80,27 +81,45 @@ export function createFromCursor<const TSchema extends ImplicitFieldSchema>(
8081
/**
8182
* Construct an {@link UnhydratedFlexTreeNode} from a cursor in Nodes mode.
8283
* @remarks
83-
* This does not validate the node is in schema.
84+
* This does not fully validate the node is in schema, but does throw UsageErrors for some cases of out-of-schema content.
85+
*
86+
* Does not support unknown optional fields: will throw a UsageError if the field is not in schema.
87+
* This cannot easily be fixed as this code requires a schema for each subtree to process, and none is available for unknown optional fields.
8488
*/
8589
export function unhydratedFlexTreeFromCursor(
8690
context: Context,
8791
cursor: ITreeCursorSynchronous,
8892
): UnhydratedFlexTreeNode {
8993
assert(cursor.mode === CursorLocationType.Nodes, 0xbb4 /* Expected nodes cursor */);
9094
const schema = context.schema.get(cursor.type) ?? unknownTypeError(cursor.type);
95+
// TODO: AB#43548: Using a stored schema from for unhydrated flex trees does not handle schema evolution features like "allowUnknownOptionalFields".
9196
const storedSchema = getStoredSchema(
9297
schema as SimpleNodeSchemaBase<NodeKind> as SimpleNodeSchema,
9398
);
9499
const fields = new Map(
95-
mapCursorFields(cursor, () => [
96-
cursor.getFieldKey(),
97-
createField(
98-
context.flexContext,
99-
storedSchema.getFieldSchema(cursor.getFieldKey()).kind,
100+
mapCursorFields(cursor, () => {
101+
const fieldSchema = storedSchema.getFieldSchema(cursor.getFieldKey());
102+
if (fieldSchema.kind === forbiddenFieldKindIdentifier) {
103+
// Check for unexpected fields before recursing into children:
104+
// Code which hits this case is very likely to also use an unknown type in the unexpected field, which would give a more confusing error message.
105+
// This case is detected here to improve error quality.
106+
// Also note that if using the view schema from above to suppress this error for unknownOptionalFields, that would not provide a way to handle unknown types in those fields:
107+
// they would still error, but with that more confusing message about unknown types.
108+
throw new UsageError(
109+
// Using JSON.stringify to handle quoting and escaping since both key and identifier can technically contain quotes themselves.
110+
`Field ${JSON.stringify(cursor.getFieldKey())} is not defined in the schema ${JSON.stringify(schema.identifier)}.`,
111+
);
112+
}
113+
return [
100114
cursor.getFieldKey(),
101-
mapCursorField(cursor, () => unhydratedFlexTreeFromCursor(context, cursor)),
102-
),
103-
]),
115+
createField(
116+
context.flexContext,
117+
fieldSchema.kind,
118+
cursor.getFieldKey(),
119+
mapCursorField(cursor, () => unhydratedFlexTreeFromCursor(context, cursor)),
120+
),
121+
];
122+
}),
104123
);
105124
return new UnhydratedFlexTreeNode(
106125
{ type: cursor.type, value: cursor.value },

packages/dds/tree/src/simple-tree/api/customTree.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export interface TreeEncodingOptions {
4343
* @remarks
4444
* Has no effect on {@link NodeKind}s other than {@link NodeKind.Object}.
4545
* @defaultValue false.
46+
* @privateRemarks
47+
* TODO AB#43548:
48+
* Replace this with an enum that provides three options:
49+
* - `usePropertyKeys`: use property keys. Supported for import and export.
50+
* - `allStoredKeys`: use stored keys, and include unknown optional fields. Supported for export only, at least for the short term.
51+
* - `knownStoredKeys`: use stored keys but do not include unknown optional fields. Supported for import and export.
4652
*/
4753
readonly useStoredKeys?: boolean;
4854
}

0 commit comments

Comments
 (0)