Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix: tree: make field assignment safer #23053

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/metal-sloths-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"fluid-framework": minor
"@fluidframework/tree": minor
---
---
"section": tree
---

Disallow some invalid and unsafe ObjectNode field assignments at compile time

The compile time validation of the type of values assigned to ObjectNode fields is limited by TypeScript's limitations.
Two cases which were actually possible to disallow and should be disallowed for consistency with runtime behavior and similar APIs were being allowed:

1. [Identifier fields](https://fluidframework.com/docs/api/v2/fluid-framework/schemafactory-class#identifier-property):
Identifier fields are immutable, and setting them produces a runtime error.
This changes fixes them to no longer be typed as assignable.

2. Fields with non-exact schema:
When non-exact scheme is used for a field (for example the schema is either a schema only allowing numbers or a schema only allowing strings) the field is no longer typed as assignable.
This matches how constructors and implicit node construction work.
For example when a node `Foo` has such an non-exact schema for field `bar`, you can no longer unsafely do `foo.bar = 5` just like how you could already not do `new Foo({bar: 5})`.

This fix only applies to [`SchemaFactory.object`](https://fluidframework.com/docs/api/v2/fluid-framework/schemafactory-class#object-method).
[`SchemaFactory.objectRecursive`](https://fluidframework.com/docs/api/v2/fluid-framework/schemafactory-class#objectrecursive-method) was unable to be updated to match due to TypeScript limitations on recursive types.
12 changes: 11 additions & 1 deletion packages/dds/tree/api-report/tree.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,19 @@ type ApplyKind<T, Kind extends FieldKind> = {
[FieldKind.Identifier]: T;
}[Kind];

// @public
export type ApplyKindAssignment<T, Kind extends FieldKind> = [Kind] extends [
FieldKind.Required
] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : never;

// @public
type ApplyKindInput<T, Kind extends FieldKind, DefaultsAreOptional extends boolean> = [
Kind
] extends [FieldKind.Required] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : [Kind] extends [FieldKind.Identifier] ? DefaultsAreOptional extends true ? T | undefined : T : never;

// @public
export type AssignableTreeFieldFromImplicitField<TSchemaInput extends ImplicitFieldSchema, TSchema = UnionToIntersection<TSchemaInput>> = [TSchema] extends [FieldSchema<infer Kind, infer Types>] ? ApplyKindAssignment<InsertableTreeNodeFromImplicitAllowedTypes<Types>, Kind> : [TSchema] extends [ImplicitAllowedTypes] ? InsertableTreeNodeFromImplicitAllowedTypes<TSchema> : never;

// @alpha
export function asTreeViewAlpha<TSchema extends ImplicitFieldSchema>(view: TreeView<TSchema>): TreeViewAlpha<TSchema>;

Expand Down Expand Up @@ -470,7 +478,9 @@ export const noopValidator: JsonValidator;

// @public
type ObjectFromSchemaRecord<T extends RestrictiveStringRecord<ImplicitFieldSchema>> = {
-readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
-readonly [Property in keyof T as Property extends string ? [AssignableTreeFieldFromImplicitField<T[Property]>] extends [never] ? never : Property : never]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
} & {
readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
};

// @public
Expand Down
12 changes: 11 additions & 1 deletion packages/dds/tree/api-report/tree.beta.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@ type ApplyKind<T, Kind extends FieldKind> = {
[FieldKind.Identifier]: T;
}[Kind];

// @public
export type ApplyKindAssignment<T, Kind extends FieldKind> = [Kind] extends [
FieldKind.Required
] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : never;

// @public
type ApplyKindInput<T, Kind extends FieldKind, DefaultsAreOptional extends boolean> = [
Kind
] extends [FieldKind.Required] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : [Kind] extends [FieldKind.Identifier] ? DefaultsAreOptional extends true ? T | undefined : T : never;

// @public
export type AssignableTreeFieldFromImplicitField<TSchemaInput extends ImplicitFieldSchema, TSchema = UnionToIntersection<TSchemaInput>> = [TSchema] extends [FieldSchema<infer Kind, infer Types>] ? ApplyKindAssignment<InsertableTreeNodeFromImplicitAllowedTypes<Types>, Kind> : [TSchema] extends [ImplicitAllowedTypes] ? InsertableTreeNodeFromImplicitAllowedTypes<TSchema> : never;

// @public
export enum CommitKind {
Default = 0,
Expand Down Expand Up @@ -275,7 +283,9 @@ export enum NodeKind {

// @public
type ObjectFromSchemaRecord<T extends RestrictiveStringRecord<ImplicitFieldSchema>> = {
-readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
-readonly [Property in keyof T as Property extends string ? [AssignableTreeFieldFromImplicitField<T[Property]>] extends [never] ? never : Property : never]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
} & {
readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
};

// @public
Expand Down
12 changes: 11 additions & 1 deletion packages/dds/tree/api-report/tree.legacy.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@ type ApplyKind<T, Kind extends FieldKind> = {
[FieldKind.Identifier]: T;
}[Kind];

// @public
export type ApplyKindAssignment<T, Kind extends FieldKind> = [Kind] extends [
FieldKind.Required
] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : never;

// @public
type ApplyKindInput<T, Kind extends FieldKind, DefaultsAreOptional extends boolean> = [
Kind
] extends [FieldKind.Required] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : [Kind] extends [FieldKind.Identifier] ? DefaultsAreOptional extends true ? T | undefined : T : never;

// @public
export type AssignableTreeFieldFromImplicitField<TSchemaInput extends ImplicitFieldSchema, TSchema = UnionToIntersection<TSchemaInput>> = [TSchema] extends [FieldSchema<infer Kind, infer Types>] ? ApplyKindAssignment<InsertableTreeNodeFromImplicitAllowedTypes<Types>, Kind> : [TSchema] extends [ImplicitAllowedTypes] ? InsertableTreeNodeFromImplicitAllowedTypes<TSchema> : never;

// @public
export enum CommitKind {
Default = 0,
Expand Down Expand Up @@ -270,7 +278,9 @@ export enum NodeKind {

// @public
type ObjectFromSchemaRecord<T extends RestrictiveStringRecord<ImplicitFieldSchema>> = {
-readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
-readonly [Property in keyof T as Property extends string ? [AssignableTreeFieldFromImplicitField<T[Property]>] extends [never] ? never : Property : never]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
} & {
readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
};

// @public
Expand Down
12 changes: 11 additions & 1 deletion packages/dds/tree/api-report/tree.legacy.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@ type ApplyKind<T, Kind extends FieldKind> = {
[FieldKind.Identifier]: T;
}[Kind];

// @public
export type ApplyKindAssignment<T, Kind extends FieldKind> = [Kind] extends [
FieldKind.Required
] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : never;

// @public
type ApplyKindInput<T, Kind extends FieldKind, DefaultsAreOptional extends boolean> = [
Kind
] extends [FieldKind.Required] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : [Kind] extends [FieldKind.Identifier] ? DefaultsAreOptional extends true ? T | undefined : T : never;

// @public
export type AssignableTreeFieldFromImplicitField<TSchemaInput extends ImplicitFieldSchema, TSchema = UnionToIntersection<TSchemaInput>> = [TSchema] extends [FieldSchema<infer Kind, infer Types>] ? ApplyKindAssignment<InsertableTreeNodeFromImplicitAllowedTypes<Types>, Kind> : [TSchema] extends [ImplicitAllowedTypes] ? InsertableTreeNodeFromImplicitAllowedTypes<TSchema> : never;

// @public
export enum CommitKind {
Default = 0,
Expand Down Expand Up @@ -270,7 +278,9 @@ export enum NodeKind {

// @public
type ObjectFromSchemaRecord<T extends RestrictiveStringRecord<ImplicitFieldSchema>> = {
-readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
-readonly [Property in keyof T as Property extends string ? [AssignableTreeFieldFromImplicitField<T[Property]>] extends [never] ? never : Property : never]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
} & {
readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
};

// @public
Expand Down
12 changes: 11 additions & 1 deletion packages/dds/tree/api-report/tree.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@ type ApplyKind<T, Kind extends FieldKind> = {
[FieldKind.Identifier]: T;
}[Kind];

// @public
export type ApplyKindAssignment<T, Kind extends FieldKind> = [Kind] extends [
FieldKind.Required
] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : never;

// @public
type ApplyKindInput<T, Kind extends FieldKind, DefaultsAreOptional extends boolean> = [
Kind
] extends [FieldKind.Required] ? T : [Kind] extends [FieldKind.Optional] ? T | undefined : [Kind] extends [FieldKind.Identifier] ? DefaultsAreOptional extends true ? T | undefined : T : never;

// @public
export type AssignableTreeFieldFromImplicitField<TSchemaInput extends ImplicitFieldSchema, TSchema = UnionToIntersection<TSchemaInput>> = [TSchema] extends [FieldSchema<infer Kind, infer Types>] ? ApplyKindAssignment<InsertableTreeNodeFromImplicitAllowedTypes<Types>, Kind> : [TSchema] extends [ImplicitAllowedTypes] ? InsertableTreeNodeFromImplicitAllowedTypes<TSchema> : never;

// @public
export enum CommitKind {
Default = 0,
Expand Down Expand Up @@ -270,7 +278,9 @@ export enum NodeKind {

// @public
type ObjectFromSchemaRecord<T extends RestrictiveStringRecord<ImplicitFieldSchema>> = {
-readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
-readonly [Property in keyof T as Property extends string ? [AssignableTreeFieldFromImplicitField<T[Property]>] extends [never] ? never : Property : never]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
} & {
readonly [Property in keyof T]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
};

// @public
Expand Down
2 changes: 2 additions & 0 deletions packages/dds/tree/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export {
type TreeBranch,
type TreeBranchEvents,
asTreeViewAlpha,
type AssignableTreeFieldFromImplicitField,
type ApplyKindAssignment,
} from "./simple-tree/index.js";
export {
SharedTree,
Expand Down
9 changes: 6 additions & 3 deletions packages/dds/tree/src/simple-tree/api/typesUnsafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ export type Unenforced<_DesiredExtendsConstraint> = unknown;
*/
export type ObjectFromSchemaRecordUnsafe<
T extends Unenforced<RestrictiveStringRecord<ImplicitFieldSchema>>,
> = {
-readonly [Property in keyof T]: TreeFieldFromImplicitFieldUnsafe<T[Property]>;
};
> =
// Due to https://github.com/microsoft/TypeScript/issues/43826 we can not set the desired setter type.
// Partial mitigation for this (used for non-recursive types) breaks compilation if used here, so recursive object end up allowing some unsafe assignments which will error at runtime.
{
-readonly [Property in keyof T]: TreeFieldFromImplicitFieldUnsafe<T[Property]>;
};

/**
* {@link Unenforced} version of {@link TreeNodeSchema}.
Expand Down
2 changes: 2 additions & 0 deletions packages/dds/tree/src/simple-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ export {
type FieldHasDefault,
type InsertableObjectFromSchemaRecord,
type ObjectFromSchemaRecord,
type AssignableTreeFieldFromImplicitField,
type ApplyKindAssignment,
type TreeObjectNode,
setField,
} from "./objectNode.js";
Expand Down
51 changes: 49 additions & 2 deletions packages/dds/tree/src/simple-tree/objectNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
normalizeFieldSchema,
type ImplicitAllowedTypes,
FieldKind,
type InsertableTreeNodeFromImplicitAllowedTypes,
} from "./schemaTypes.js";
import {
type TreeNodeSchema,
Expand All @@ -41,7 +42,12 @@ import {
getOrCreateInnerNode,
} from "./core/index.js";
import { mapTreeFromNodeData, type InsertableContent } from "./toMapTree.js";
import { type RestrictiveStringRecord, fail, type FlattenKeys } from "../util/index.js";
import {
type RestrictiveStringRecord,
fail,
type FlattenKeys,
type UnionToIntersection,
} from "../util/index.js";
import type { ObjectNodeSchema, ObjectNodeSchemaInternalData } from "./objectNodeTypes.js";
import { TreeNodeValid, type MostDerivedData } from "./treeNodeValid.js";
import { getUnhydratedContext } from "./createContext.js";
Expand All @@ -51,11 +57,52 @@ import { getUnhydratedContext } from "./createContext.js";
* @system @public
*/
export type ObjectFromSchemaRecord<T extends RestrictiveStringRecord<ImplicitFieldSchema>> = {
-readonly [Property in keyof T]: Property extends string
// Due to https://github.com/microsoft/TypeScript/issues/43826 we can not set the desired setter type,
// but we can at least remove the setter (by setting the key to never) when there should be no setter.
-readonly [Property in keyof T as Property extends string
? [AssignableTreeFieldFromImplicitField<T[Property]>] extends [never]
? never
: Property
: never]: Property extends string ? TreeFieldFromImplicitField<T[Property]> : unknown;
} & {
readonly [Property in keyof T]: Property extends string
? TreeFieldFromImplicitField<T[Property]>
: unknown;
};

/**
* Type of content that can be assigned to a field of the given schema.
*
* @see {@link Input}
*
* @typeparam TSchemaInput - Schema to process.
* @typeparam TSchema - Do not specify: default value used as implementation detail.
CraigMacomber marked this conversation as resolved.
Show resolved Hide resolved
* @system @public
*/
export type AssignableTreeFieldFromImplicitField<
TSchemaInput extends ImplicitFieldSchema,
TSchema = UnionToIntersection<TSchemaInput>,
> = [TSchema] extends [FieldSchema<infer Kind, infer Types>]
? ApplyKindAssignment<InsertableTreeNodeFromImplicitAllowedTypes<Types>, Kind>
: [TSchema] extends [ImplicitAllowedTypes]
? InsertableTreeNodeFromImplicitAllowedTypes<TSchema>
: never;

/**
* Suitable for assignment.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anything more we can say here?

*
* @see {@link Input}
* @system @public
*/
export type ApplyKindAssignment<T, Kind extends FieldKind> = [Kind] extends [
FieldKind.Required,
]
? T
: [Kind] extends [FieldKind.Optional]
? T | undefined
: // Unknown, non-exact and identifier fields are not assignable.
never;

/**
* A {@link TreeNode} which modules a JavaScript object.
* @remarks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,9 @@ describe("SharedTreeCore", () => {
});

const sf = new SchemaFactory("0x4a6 repro");
const TestNode = sf.objectRecursive("test node", {
class TestNode extends sf.objectRecursive("test node", {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is not required: I simply made it to get much better compile errors when debugging, and left it so other in the future can continue to have much cleaner errors if this code breaks again.

child: sf.optionalRecursive([() => TestNode, sf.number]),
});
}) {}

const tree2 = await factory.load(
dataStoreRuntime2,
Expand Down
Loading
Loading