Closes #2007. Two things, both surfaced while answering "does #1928's PR also fix #2007?". **#2007, and why it belongs in this PR.** `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf` whose branches carry no top-level `const`, so every MultiSelect option was `String(item.const ?? "")` — the empty string, twice. Mantine *throws* on duplicate option values, greying out the whole tool panel. That is a pre-existing bug, but this branch makes it reachable from a new direction: a *nullable* array of object unions previously had no top-level `type` and fell to the JSON fallback, and now collapses to `type: "array"` with `items.anyOf` — straight into the crash. Shipping the collapse without this fix would widen an existing crash, so the two go together. Both shapes are covered by tests. `toConstOptions` now returns `null` unless every branch yields a distinct, non-empty value, and the field falls through to the JSON editor — which is the honest widget for a union of object shapes anyway. Applied to the `string`/`oneOf` path as well, which had the identical `?? ""` and the same duplicate-throw waiting on it. **Copilot round 2.** Round 1's fix kept "an explicit `type` is authoritative", so `{ type: "number", enum: [1, 2] }` still collapses with its enum hoisted. The TUI's dispatcher tests `property.enum` *before* the type switch, and `toSelectOptions` stringifies, so that reached a select that submits `"1"` for `1`. The select path is now gated on `isStringEnum` (exported from core for this), and a typed non-string enum falls through to its typed field: the enum constraint is lost, the value's type is not, which is the safer of the two losses. Pre-existing for a plain numeric enum; the collapse just made the nullable form reachable too. Signed-off-by: cliffhall <cliff@futurescale.com>
180 lines
6.7 KiB
TypeScript
180 lines
6.7 KiB
TypeScript
/**
|
|
* Flattening of the two JSON Schema encodings of "this type, **or** null" into
|
|
* the plain type a form renderer can dispatch on.
|
|
*
|
|
* Shared because both form builders — the web client's `SchemaForm` and the
|
|
* TUI's `schemaToForm` — dispatch on a *single* `type` string, and so both miss
|
|
* a nullable field entirely without this step (#1928, #2015). Keeping one copy
|
|
* is what stops the two clients from disagreeing about which schemas they can
|
|
* render; the alternative was two implementations of the same subtle predicate.
|
|
*/
|
|
|
|
/**
|
|
* JSON Schema type names a form renderer can build a widget for.
|
|
*
|
|
* `"null"` is deliberately absent: it is the branch a nullable union is being
|
|
* flattened *away* from, and there is no widget for a field whose only
|
|
* permitted value is `null`.
|
|
*/
|
|
const RENDERABLE_TYPES = [
|
|
"string",
|
|
"number",
|
|
"integer",
|
|
"boolean",
|
|
"array",
|
|
"object",
|
|
] as const;
|
|
|
|
/** A `type` name the collapse is willing to produce. */
|
|
export type RenderableType = (typeof RENDERABLE_TYPES)[number];
|
|
|
|
function isRenderableType(type: unknown): type is RenderableType {
|
|
return RENDERABLE_TYPES.includes(type as RenderableType);
|
|
}
|
|
|
|
/**
|
|
* The keywords {@link normalizeNullableUnion} reads. Both clients' own schema
|
|
* types are structurally assignable to this, so neither has to adopt the
|
|
* other's — the shape is deliberately the minimum needed to *recognize* a
|
|
* nullable union, not a full JSON Schema model.
|
|
*/
|
|
export interface NullableUnionSchema {
|
|
type?: string | string[];
|
|
enum?: unknown[];
|
|
anyOf?: readonly unknown[];
|
|
}
|
|
|
|
/** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */
|
|
function toBranch(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Whether an `enum` is one that can be rendered as a string-valued dropdown.
|
|
*
|
|
* JSON Schema's `enum` is untyped — `[1, 2]`, `[true, false]`, and `[null]` are
|
|
* all legal — so a bare `{ enum: [...] }` does **not** imply strings. Guessing
|
|
* `"string"` for a numeric enum would hand non-strings to a renderer that has
|
|
* declared them `string[]`: the web `Select` would receive numbers, and the TUI
|
|
* would `String(...)` them and submit `"1"` where the server expects `1`. So
|
|
* only an all-string enum earns the inference; anything else stays on the
|
|
* fallback path, which renders the value honestly as JSON.
|
|
*
|
|
* Exported because the same question decides whether a *dispatcher* may route
|
|
* an enum to a select at all. The TUI's does, and its options are stringified,
|
|
* so an unguarded numeric enum there submits `"1"` where the server wants `1`.
|
|
*/
|
|
export function isStringEnum(value: unknown): boolean {
|
|
return (
|
|
Array.isArray(value) &&
|
|
value.length > 0 &&
|
|
value.every((member) => typeof member === "string")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The result of a collapse that actually matched. Spelled out rather than
|
|
* reusing `T` because the collapse genuinely changes two of `T`'s fields:
|
|
* `type` becomes a single renderable name (never an array, never `"null"`),
|
|
* and `anyOf` is cleared. Returning `T` for these would let a caller whose `T`
|
|
* types `type` as an array go on treating it as one after it has become a
|
|
* string, or dereference an `anyOf` that is now `undefined`.
|
|
*
|
|
* Note this cannot make the *hoist* sound: an `anyOf` branch is `unknown`, so
|
|
* any keyword lifted off it is whatever the server sent, however `T` declares
|
|
* it. {@link isStringEnum} validates the one keyword the renderers dereference
|
|
* as a typed array (`enum`); the rest reach widgets that read them defensively.
|
|
*/
|
|
export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit<
|
|
T,
|
|
"type" | "anyOf"
|
|
> & {
|
|
type?: RenderableType;
|
|
anyOf?: undefined;
|
|
nullable?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Build the collapsed schema.
|
|
*
|
|
* The assertion is needed because the spread merges an unresolved generic `T`
|
|
* with an `unknown` branch, and `NormalizedNullableUnion<T>`'s `Omit` stays
|
|
* deferred while `T` is open — so TS cannot verify the two line up even though
|
|
* every field is set right here. Isolated in this one function so the exported
|
|
* signature carries the contract and nothing else has to assert it.
|
|
*/
|
|
function collapsed<T extends NullableUnionSchema>(
|
|
schema: T,
|
|
branch: Record<string, unknown> | undefined,
|
|
type: RenderableType,
|
|
): NormalizedNullableUnion<T> {
|
|
return {
|
|
...schema,
|
|
...branch,
|
|
type,
|
|
anyOf: undefined,
|
|
nullable: true,
|
|
} as NormalizedNullableUnion<T>;
|
|
}
|
|
|
|
/**
|
|
* Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and
|
|
* FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`.
|
|
*
|
|
* Two encodings mean the same thing and are both handled:
|
|
*
|
|
* - `anyOf: [<branch>, { type: "null" }]` — the branch's *own* keywords are
|
|
* hoisted onto the result, because that is where the detail a renderer needs
|
|
* lives. A nullable enum compiles to
|
|
* `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting
|
|
* `enum` is what makes it a dropdown rather than a raw-JSON fallback (#1928).
|
|
* The branch also wins over the wrapper on any shared key, matching v1.x.
|
|
* - `type: [<T>, "null"]` — the keywords already sit at the top level, so only
|
|
* `type` collapses.
|
|
*
|
|
* Anything else — a union of two real types, a three-member `anyOf`, a branch
|
|
* whose type has no widget — is returned **by identity**, so a caller can use
|
|
* `===` to tell that nothing was recognized.
|
|
*
|
|
* @param schema The schema to normalize
|
|
* @returns A flattened copy, or `schema` itself when no nullable-union pattern
|
|
* matches
|
|
*/
|
|
export function normalizeNullableUnion<T extends NullableUnionSchema>(
|
|
schema: T,
|
|
): T | NormalizedNullableUnion<T> {
|
|
if (schema.anyOf?.length === 2) {
|
|
const branches = schema.anyOf.map(toBranch);
|
|
const nullBranch = branches.find((entry) => entry?.type === "null");
|
|
const branch = branches.find(
|
|
(entry) => entry !== null && entry.type !== "null",
|
|
);
|
|
|
|
if (nullBranch && branch) {
|
|
// A branch may carry an `enum` and no `type`; JSON Schema allows that, and
|
|
// an all-string enum is unambiguously a string field. See isStringEnum for
|
|
// why a non-string enum deliberately does not get the same treatment.
|
|
const type =
|
|
branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined);
|
|
if (isRenderableType(type)) {
|
|
return collapsed(schema, branch, type);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
Array.isArray(schema.type) &&
|
|
schema.type.length === 2 &&
|
|
schema.type.includes("null")
|
|
) {
|
|
const type = schema.type.find((member) => member !== "null");
|
|
if (isRenderableType(type)) {
|
|
return collapsed(schema, undefined, type);
|
|
}
|
|
}
|
|
|
|
return schema;
|
|
}
|