Skip to content

Name collisions from structurally named schema now error #24707

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

Merged
merged 13 commits into from
May 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
23 changes: 23 additions & 0 deletions .changeset/social-bears-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@fluidframework/tree": minor
"fluid-framework": minor
"__section": tree
---
Name collisions from structurally named schema now error

It is legal to have multiple [TreeNodeSchema](https://fluidframework.com/docs/api/fluid-framework/treenodeschema-typealias) with the same name so long as they are not used together in the same tree.
Using different schema with the same name when building otherwise identical [structurally named](https://fluidframework.com/docs/api/fluid-framework/schemafactory-class#schemafactory-remarks) in the same [SchemaFactory](https://fluidframework.com/docs/api/fluid-framework/schemafactory-class) is not valid, however.
Previously doing this would not error, and instead return the first structurally named schema with that name.
Now this case throws an informative error:

```typescript
const factory = new SchemaFactory(undefined);
class Child1 extends factory.object("Child", {}) {}
class Child2 extends factory.object("Child", {}) {}

const a = factory.map(Child1);

// Throws a UsageError with the message:
// "Structurally named schema collision: two schema named "Array<["Child"]>" were defined with different input schema."
const b = factory.array(Child2);
```
38 changes: 35 additions & 3 deletions packages/dds/tree/src/simple-tree/api/schemaFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import type { IFluidHandle } from "@fluidframework/core-interfaces";
import { assert, unreachableCase } from "@fluidframework/core-utils/internal";
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";
Expand All @@ -14,6 +15,7 @@ import type { NodeIdentifierManager } from "../../feature-libraries/index.js";
import type { TreeAlpha } from "../../shared-tree/index.js";
import {
type RestrictiveStringRecord,
compareSets,
getOrCreate,
isReadonlyArray,
} from "../../util/index.js";
Expand Down Expand Up @@ -60,6 +62,7 @@ import {
type ImplicitAnnotatedAllowedTypes,
type UnannotateImplicitAllowedTypes,
type UnannotateSchemaRecord,
normalizeAllowedTypes,
} from "../schemaTypes.js";

import { createFieldSchemaUnsafe } from "./schemaFactoryRecursive.js";
Expand Down Expand Up @@ -766,9 +769,9 @@ export class SchemaFactory<
if (allowedTypes === undefined) {
const types = nameOrAllowedTypes as (T & TreeNodeSchema) | readonly TreeNodeSchema[];
const fullName = structuralName("Map", types);
return getOrCreate(
this.structuralTypes,
return this.getStructuralType(
fullName,
types,
() =>
this.namedMap(
fullName as TName,
Expand Down Expand Up @@ -954,7 +957,7 @@ export class SchemaFactory<
if (allowedTypes === undefined) {
const types = nameOrAllowedTypes as (T & TreeNodeSchema) | readonly TreeNodeSchema[];
const fullName = structuralName("Array", types);
return getOrCreate(this.structuralTypes, fullName, () =>
return this.getStructuralType(fullName, types, () =>
this.namedArray(fullName, nameOrAllowedTypes as T, false, true),
) as TreeNodeSchemaClass<
ScopedSchemaName<TScope, string>,
Expand All @@ -978,6 +981,35 @@ export class SchemaFactory<
return out;
}

/**
* Retrieves or creates a structural {@link TreeNodeSchema} with the specified name and types.
*
* @param fullName - The name for the structural schema.
* @param types - The input schema(s) used to define the structural schema.
* @param builder - A function that builds the schema if it does not already exist.
* @returns The structural {@link TreeNodeSchema} associated with the given name and types.
* @throws `UsageError` if a schema structurally named schema with the same name is cached in `structuralTypes` but had different input types.
*/
private getStructuralType(
fullName: string,
types: TreeNodeSchema | readonly TreeNodeSchema[],
builder: () => TreeNodeSchema,
): TreeNodeSchema {
const structural = getOrCreate(this.structuralTypes, fullName, builder);
const inputTypes = new Set(normalizeAllowedTypes(types));
const outputTypes = new Set(
normalizeAllowedTypes(structural.info as TreeNodeSchema | readonly TreeNodeSchema[]),
);
// If our cached value had a different set of types then were requested, the user must have caused a collision.
const same = compareSets({ a: inputTypes, b: outputTypes });
if (!same) {
throw new UsageError(
`Structurally named schema collision: two schema named "${fullName}" were defined with different input schema.`,
);
}
return structural;
}

/**
* Define a {@link TreeNodeSchema} for a {@link (TreeArrayNode:interface)}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,47 @@ describe("schemaFactory", () => {
assert.deepEqual(getKeys(arr), [0]);
assert.deepEqual(getKeys(mapNode), ["x"]);
});

it("structural type collision: single type", () => {
const factory = new SchemaFactory("");
class Child1 extends factory.object("Child", {}) {}
class Child2 extends factory.object("Child", {}) {}

const a = factory.array(Child1);
// No error: this type is the same as the one above.
assert.equal(factory.array(Child1), a);

// Error: this type is different from the one above.
assert.throws(
() => {
factory.array(Child2);
},
validateUsageError(/collision/),
);

assert.equal(factory.array([Child1]), a);
assert.throws(
() => {
factory.array([Child2]);
},
validateUsageError(/collision/),
);
});

it("structural type collision: multi type", () => {
const factory = new SchemaFactory("");
class Child1 extends factory.object("Child", {}) {}
class Child2 extends factory.object("Child", {}) {}

const a = factory.map([Child1, SchemaFactory.null]);
assert.equal(factory.map([SchemaFactory.null, Child1]), a);
assert.throws(
() => {
factory.map([Child2, SchemaFactory.null]);
},
validateUsageError(/collision/),
);
});
});

// kind based narrowing example
Expand Down
Loading