mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
fix(core): Resolve duplicate generated schema fields (#32275)
This commit is contained in:
@@ -269,6 +269,35 @@ export interface ParameterBuilderHint {
|
||||
placeholderSupported?: boolean;
|
||||
}
|
||||
|
||||
export interface NodePropertyOption {
|
||||
name: string;
|
||||
value?: string | number | boolean;
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
builderHint?: ParameterBuilderHint;
|
||||
values?: NodeProperty[];
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
options?: NodePropertyOption[];
|
||||
displayOptions?: {
|
||||
show?: Record<string, unknown[]>;
|
||||
hide?: Record<string, unknown[]>;
|
||||
};
|
||||
disabledOptions?: {
|
||||
show?: Record<string, unknown[]>;
|
||||
hide?: Record<string, unknown[]>;
|
||||
};
|
||||
typeOptions?: Record<string, unknown>;
|
||||
noDataExpression?: boolean;
|
||||
modes?: Array<{
|
||||
name: string;
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
typeOptions?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NodeProperty {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -278,14 +307,7 @@ export interface NodeProperty {
|
||||
builderHint?: ParameterBuilderHint;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
options?: Array<{
|
||||
name: string;
|
||||
value?: string | number | boolean;
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
builderHint?: ParameterBuilderHint;
|
||||
values?: NodeProperty[];
|
||||
}>;
|
||||
options?: NodePropertyOption[];
|
||||
displayOptions?: {
|
||||
show?: Record<string, unknown[]>;
|
||||
hide?: Record<string, unknown[]>;
|
||||
|
||||
@@ -339,6 +339,50 @@ describe('duplicate property declarations with mutually-exclusive displayOptions
|
||||
outputs: ['main'] as string[],
|
||||
};
|
||||
|
||||
it('emits resolveOneOfSchemas for same-name top-level conditional variants', () => {
|
||||
const node: NodeTypeDescription = {
|
||||
...baseNode,
|
||||
name: 'n8n-nodes-base.promptFork',
|
||||
displayName: 'Prompt Fork',
|
||||
version: 1,
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Prompt Type',
|
||||
name: 'promptType',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
options: [
|
||||
{ name: 'Connected Chat Trigger Node', value: 'auto' },
|
||||
{ name: 'Define below', value: 'define' },
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chatInput }}',
|
||||
displayOptions: { show: { promptType: ['auto'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: { show: { promptType: ['define'] } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const code = generateSingleVersionSchemaFile(node, 1);
|
||||
|
||||
expect(code).toContain('resolveOneOfSchemas({');
|
||||
expect(code.match(/\btext:/g)).toHaveLength(1);
|
||||
expect(code).toContain('"promptType":["auto"]');
|
||||
expect(code).toContain('"promptType":["define"]');
|
||||
});
|
||||
|
||||
// Mirrors the Summarize node: `fieldsToSplitBy` is declared twice so it can
|
||||
// carry a different label in each output mode. The two declarations are OR
|
||||
// alternatives — the field is visible when EITHER matches — so merging their
|
||||
@@ -1962,6 +2006,85 @@ describe('mapPropertyToZodSchema for fixedCollection with field-count constraint
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapPropertyToZodSchema for duplicate nested collection fields', () => {
|
||||
it('unions same-name fixedCollection fields instead of emitting duplicate object keys', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'actionsUi',
|
||||
displayName: 'Actions',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
typeOptions: { multipleValues: true },
|
||||
options: [
|
||||
{
|
||||
name: 'actionFields',
|
||||
displayName: 'Action Fields',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Action',
|
||||
name: 'action',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{ name: 'Find and Replace Text', value: 'replaceAll' },
|
||||
{ name: 'Insert', value: 'insert' },
|
||||
],
|
||||
displayOptions: { show: { object: ['text'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Action',
|
||||
name: 'action',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [{ name: 'Delete', value: 'delete' }],
|
||||
displayOptions: { show: { object: ['positionedObject'] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const schema = mapPropertyToZodSchema(prop);
|
||||
|
||||
expect(schema.match(/\baction:/g)).toHaveLength(1);
|
||||
expect(schema).toContain("z.literal('replaceAll')");
|
||||
expect(schema).toContain("z.literal('insert')");
|
||||
expect(schema).toContain("z.literal('delete')");
|
||||
});
|
||||
|
||||
it('unions same-name collection fields instead of emitting duplicate object keys', () => {
|
||||
const prop: NodeProperty = {
|
||||
name: 'options',
|
||||
displayName: 'Options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [{ name: 'List', value: 'list' }],
|
||||
displayOptions: { show: { resource: ['file'] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [{ name: 'Search', value: 'search' }],
|
||||
displayOptions: { show: { resource: ['folder'] } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const schema = mapPropertyToZodSchema(prop);
|
||||
|
||||
expect(schema.match(/\bmode:/g)).toHaveLength(1);
|
||||
expect(schema).toContain("z.literal('list')");
|
||||
expect(schema).toContain("z.literal('search')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapPropertyToZodSchema recursion for nested collection/fixedCollection', () => {
|
||||
const stringLeaf: NodeProperty = {
|
||||
name: 'leaf',
|
||||
|
||||
@@ -566,19 +566,7 @@ function generateFixedCollectionZodSchema(prop: NodeProperty): string {
|
||||
}
|
||||
|
||||
const groupName = quotePropertyName(group.name);
|
||||
const nestedProps: string[] = [];
|
||||
|
||||
for (const nestedProp of group.values) {
|
||||
if (DISPLAY_ONLY_PROPERTY_TYPES.has(nestedProp.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nestedSchema = mapNestedPropertyToZodSchema(nestedProp);
|
||||
if (nestedSchema) {
|
||||
const quotedName = quotePropertyName(nestedProp.name);
|
||||
nestedProps.push(`${quotedName}: ${nestedSchema}.optional()`);
|
||||
}
|
||||
}
|
||||
const nestedProps = generateNestedSchemaPropertyLines(group.values);
|
||||
|
||||
if (nestedProps.length > 0) {
|
||||
const innerSchema = `z.object({ ${nestedProps.join(', ')} })`;
|
||||
@@ -618,26 +606,10 @@ function generateCollectionZodSchema(prop: NodeProperty): string {
|
||||
return 'z.record(z.string(), z.unknown())';
|
||||
}
|
||||
|
||||
const nestedProps: string[] = [];
|
||||
|
||||
for (const nestedProp of prop.options) {
|
||||
// Skip if this is a group (has values array)
|
||||
if (nestedProp.values !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cast to NodeProperty since collection options are actually NodeProperty-like
|
||||
const asNodeProp = nestedProp as unknown as NodeProperty;
|
||||
if (DISPLAY_ONLY_PROPERTY_TYPES.has(asNodeProp.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nestedSchema = mapNestedPropertyToZodSchema(asNodeProp);
|
||||
if (nestedSchema) {
|
||||
const quotedName = quotePropertyName(nestedProp.name);
|
||||
nestedProps.push(`${quotedName}: ${nestedSchema}.optional()`);
|
||||
}
|
||||
}
|
||||
const nestedOptionProps = prop.options
|
||||
.filter((nestedProp) => nestedProp.values === undefined)
|
||||
.map((nestedProp) => nestedProp as unknown as NodeProperty);
|
||||
const nestedProps = generateNestedSchemaPropertyLines(nestedOptionProps);
|
||||
|
||||
if (nestedProps.length === 0) {
|
||||
return 'z.record(z.string(), z.unknown())';
|
||||
@@ -646,6 +618,44 @@ function generateCollectionZodSchema(prop: NodeProperty): string {
|
||||
return `z.object({ ${nestedProps.join(', ')} })`;
|
||||
}
|
||||
|
||||
function combineSchemaAlternatives(schemas: string[]): string {
|
||||
const uniqueSchemas = Array.from(new Set(schemas));
|
||||
if (uniqueSchemas.length === 1) {
|
||||
return uniqueSchemas[0];
|
||||
}
|
||||
|
||||
return `z.union([${uniqueSchemas.join(', ')}])`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection and fixedCollection fields can contain several UI-only variants
|
||||
* with the same name. Emit one object key and accept every variant schema,
|
||||
* otherwise JavaScript keeps only the last duplicate key.
|
||||
*/
|
||||
function generateNestedSchemaPropertyLines(properties: NodeProperty[]): string[] {
|
||||
const schemasByName = new Map<string, string[]>();
|
||||
|
||||
for (const nestedProp of properties) {
|
||||
if (DISPLAY_ONLY_PROPERTY_TYPES.has(nestedProp.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nestedSchema = mapNestedPropertyToZodSchema(nestedProp);
|
||||
if (!nestedSchema) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const schemas = schemasByName.get(nestedProp.name) ?? [];
|
||||
schemas.push(nestedSchema);
|
||||
schemasByName.set(nestedProp.name, schemas);
|
||||
}
|
||||
|
||||
return Array.from(schemasByName.entries()).map(([name, schemas]) => {
|
||||
const quotedName = quotePropertyName(name);
|
||||
return `${quotedName}: ${combineSchemaAlternatives(schemas)}.optional()`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map n8n property type to Zod schema code string
|
||||
*
|
||||
@@ -834,12 +844,23 @@ type MergeableDisplayOptions = {
|
||||
hide?: Record<string, unknown[]>;
|
||||
};
|
||||
|
||||
function cloneDisplayOptionsValues(values: Record<string, unknown[]>): Record<string, unknown[]> {
|
||||
const clone: Record<string, unknown[]> = {};
|
||||
for (const [key, optionValues] of Object.entries(values)) {
|
||||
clone[key] = [...optionValues];
|
||||
}
|
||||
return clone;
|
||||
function cloneConditionMap(
|
||||
conditions: Record<string, unknown[]> | undefined,
|
||||
): Record<string, unknown[]> | undefined {
|
||||
if (!conditions) return undefined;
|
||||
|
||||
return Object.fromEntries(Object.entries(conditions).map(([key, values]) => [key, [...values]]));
|
||||
}
|
||||
|
||||
function cloneDisplayOptions(displayOptions: MergeableDisplayOptions): MergeableDisplayOptions {
|
||||
const cloned: MergeableDisplayOptions = {};
|
||||
const show = cloneConditionMap(displayOptions.show);
|
||||
const hide = cloneConditionMap(displayOptions.hide);
|
||||
|
||||
if (show) cloned.show = show;
|
||||
if (hide) cloned.hide = hide;
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -854,13 +875,7 @@ export function mergeDisplayOptions(
|
||||
existing: MergeableDisplayOptions,
|
||||
incoming: MergeableDisplayOptions,
|
||||
): MergeableDisplayOptions {
|
||||
const merged: MergeableDisplayOptions = {};
|
||||
if (existing.show) {
|
||||
merged.show = cloneDisplayOptionsValues(existing.show);
|
||||
}
|
||||
if (existing.hide) {
|
||||
merged.hide = cloneDisplayOptionsValues(existing.hide);
|
||||
}
|
||||
const merged = cloneDisplayOptions(existing);
|
||||
|
||||
// Merge 'show' conditions
|
||||
if (incoming.show) {
|
||||
@@ -944,17 +959,163 @@ export function mergePropertiesByName(properties: NodeProperty[]): Map<string, N
|
||||
}
|
||||
// Keep the first property's other attributes (type, required, etc.)
|
||||
} else {
|
||||
// Create a shallow copy to avoid mutating the original when merging
|
||||
propsByName.set(prop.name, {
|
||||
// Create a copy to avoid mutating the original when merging
|
||||
const propCopy: NodeProperty = {
|
||||
...prop,
|
||||
options: prop.options ? [...prop.options] : undefined,
|
||||
});
|
||||
};
|
||||
if (prop.displayOptions) {
|
||||
propCopy.displayOptions = cloneDisplayOptions(prop.displayOptions);
|
||||
}
|
||||
propsByName.set(prop.name, propCopy);
|
||||
}
|
||||
}
|
||||
|
||||
return propsByName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep raw declarations around so same-name UI variants can be emitted as
|
||||
* runtime alternatives instead of being merged into a contradictory condition.
|
||||
*/
|
||||
export function collectDeclarationsByName(properties: NodeProperty[]): Map<string, NodeProperty[]> {
|
||||
const byName = new Map<string, NodeProperty[]>();
|
||||
for (const prop of properties) {
|
||||
if (DISPLAY_ONLY_PROPERTY_TYPES.has(prop.type)) {
|
||||
continue;
|
||||
}
|
||||
const declarations = byName.get(prop.name) ?? [];
|
||||
declarations.push(prop);
|
||||
byName.set(prop.name, declarations);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
type DisplayOptionsValue = NonNullable<NodeProperty['displayOptions']>;
|
||||
|
||||
interface SchemaVariant {
|
||||
prop: NodeProperty;
|
||||
displayOptions: DisplayOptionsValue;
|
||||
}
|
||||
|
||||
export function generateOneOfSchemaLine(
|
||||
variants: SchemaVariant[],
|
||||
allProperties: NodeProperty[] = [],
|
||||
): string {
|
||||
const propName = quotePropertyName(variants[0].prop.name);
|
||||
const variantStrs: string[] = [];
|
||||
|
||||
for (const { prop, displayOptions } of variants) {
|
||||
const zodSchema = mapPropertyToZodSchema(prop);
|
||||
if (!zodSchema) {
|
||||
return '';
|
||||
}
|
||||
const required = !isPropertyOptional(prop);
|
||||
const displayOptionsStr = JSON.stringify(displayOptions);
|
||||
const defaults = extractDefaultsForDisplayOptions(displayOptions, allProperties);
|
||||
const defaultsStr =
|
||||
Object.keys(defaults).length > 0 ? `, defaults: ${JSON.stringify(defaults)}` : '';
|
||||
variantStrs.push(
|
||||
`{ schema: ${zodSchema}, required: ${required}, displayOptions: ${displayOptionsStr}${defaultsStr} }`,
|
||||
);
|
||||
}
|
||||
|
||||
return `${INDENT}${propName}: resolveOneOfSchemas({ parameters, variants: [${variantStrs.join(', ')}] }),`;
|
||||
}
|
||||
|
||||
function getSchemaVariantsForDuplicateDeclarations(
|
||||
declarations: NodeProperty[],
|
||||
keysToStrip: string[],
|
||||
): { allConditional: boolean; variants: SchemaVariant[] } {
|
||||
const variants: SchemaVariant[] = [];
|
||||
let allConditional = true;
|
||||
|
||||
for (const declaration of declarations) {
|
||||
const { displayOptions, fullyDisabled } = narrowDisplayOptionsByDisabled(declaration);
|
||||
if (fullyDisabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const strippedDisplayOptions = displayOptions
|
||||
? stripDiscriminatorKeysFromDisplayOptions(displayOptions, keysToStrip)
|
||||
: undefined;
|
||||
if (!strippedDisplayOptions) {
|
||||
allConditional = false;
|
||||
break;
|
||||
}
|
||||
|
||||
variants.push({
|
||||
prop: declaration,
|
||||
displayOptions: strippedDisplayOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return { allConditional, variants };
|
||||
}
|
||||
|
||||
function hasDuplicateConditionalDeclarations(
|
||||
properties: NodeProperty[],
|
||||
keysToStrip: string[],
|
||||
): boolean {
|
||||
for (const declarations of collectDeclarationsByName(properties).values()) {
|
||||
if (declarations.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { allConditional, variants } = getSchemaVariantsForDuplicateDeclarations(
|
||||
declarations,
|
||||
keysToStrip,
|
||||
);
|
||||
if (allConditional && variants.length >= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function generateMergedSchemaLine(
|
||||
mergedProp: NodeProperty,
|
||||
declarations: NodeProperty[],
|
||||
allProperties: NodeProperty[],
|
||||
keysToStrip: string[],
|
||||
): string {
|
||||
if (declarations.length > 1) {
|
||||
const { allConditional, variants } = getSchemaVariantsForDuplicateDeclarations(
|
||||
declarations,
|
||||
keysToStrip,
|
||||
);
|
||||
|
||||
if (allConditional) {
|
||||
if (variants.length >= 2) {
|
||||
const line = generateOneOfSchemaLine(variants, allProperties);
|
||||
if (line) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
if (variants.length === 1) {
|
||||
return generateConditionalSchemaLine(
|
||||
{ ...variants[0].prop, displayOptions: variants[0].displayOptions },
|
||||
allProperties,
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const strippedDisplayOptions = mergedProp.displayOptions
|
||||
? stripDiscriminatorKeysFromDisplayOptions(mergedProp.displayOptions, keysToStrip)
|
||||
: undefined;
|
||||
if (strippedDisplayOptions) {
|
||||
return generateConditionalSchemaLine(
|
||||
{ ...mergedProp, displayOptions: strippedDisplayOptions },
|
||||
allProperties,
|
||||
);
|
||||
}
|
||||
|
||||
return generateSchemaPropertyLine(mergedProp, isPropertyOptional(mergedProp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a conditional schema property line using resolveSchema helper.
|
||||
* Used for properties that have displayOptions (conditionally shown/hidden fields).
|
||||
@@ -986,153 +1147,6 @@ export function generateConditionalSchemaLine(
|
||||
return `${INDENT}${propName}: resolveSchema({ parameters, schema: ${zodSchema}, required: ${required}, displayOptions: ${displayOptionsStr}${defaultsStr} }),`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every raw declaration for each property name, preserving order.
|
||||
* Mirrors `mergePropertiesByName`'s filtering so the keys line up with it.
|
||||
*
|
||||
* @param properties - Array of node properties, possibly with duplicates
|
||||
* @returns Map of property name to its list of declarations
|
||||
*/
|
||||
export function collectDeclarationsByName(properties: NodeProperty[]): Map<string, NodeProperty[]> {
|
||||
const byName = new Map<string, NodeProperty[]>();
|
||||
for (const prop of properties) {
|
||||
if (DISPLAY_ONLY_PROPERTY_TYPES.has(prop.type)) {
|
||||
continue;
|
||||
}
|
||||
const list = byName.get(prop.name);
|
||||
if (list) {
|
||||
list.push(prop);
|
||||
} else {
|
||||
byName.set(prop.name, [prop]);
|
||||
}
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
type DisplayOptionsValue = NonNullable<NodeProperty['displayOptions']>;
|
||||
|
||||
/** One declaration of a multiply-declared property, with its resolved displayOptions. */
|
||||
interface SchemaVariant {
|
||||
prop: NodeProperty;
|
||||
displayOptions: DisplayOptionsValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a schema property line using the resolveOneOfSchemas helper.
|
||||
* Used when a property name is declared multiple times with mutually-exclusive
|
||||
* displayOptions — the declarations are OR alternatives, so the field is visible
|
||||
* when any variant matches. resolveOneOfSchemas picks the first matching variant.
|
||||
*
|
||||
* Each variant carries its OWN schema, required flag and displayOptions: the
|
||||
* declarations can differ (e.g. an agent's `text` is required with an empty
|
||||
* default in one mode but optional with a `$json` default in another).
|
||||
*
|
||||
* @param variants - The per-declaration variants (>= 2)
|
||||
* @param allProperties - All properties at this level (used to extract defaults)
|
||||
* @returns Generated code line for the property using resolveOneOfSchemas, or ''
|
||||
*/
|
||||
export function generateOneOfSchemaLine(
|
||||
variants: SchemaVariant[],
|
||||
allProperties: NodeProperty[] = [],
|
||||
): string {
|
||||
const propName = quotePropertyName(variants[0].prop.name);
|
||||
|
||||
const variantStrs: string[] = [];
|
||||
for (const { prop, displayOptions } of variants) {
|
||||
const zodSchema = mapPropertyToZodSchema(prop);
|
||||
if (!zodSchema) {
|
||||
return '';
|
||||
}
|
||||
const required = !isPropertyOptional(prop);
|
||||
const displayOptionsStr = JSON.stringify(displayOptions);
|
||||
const defaults = extractDefaultsForDisplayOptions(displayOptions, allProperties);
|
||||
const defaultsStr =
|
||||
Object.keys(defaults).length > 0 ? `, defaults: ${JSON.stringify(defaults)}` : '';
|
||||
variantStrs.push(
|
||||
`{ schema: ${zodSchema}, required: ${required}, displayOptions: ${displayOptionsStr}${defaultsStr} }`,
|
||||
);
|
||||
}
|
||||
|
||||
return `${INDENT}${propName}: resolveOneOfSchemas({ parameters, variants: [${variantStrs.join(', ')}] }),`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the schema line for a property, accounting for duplicate declarations.
|
||||
*
|
||||
* The genuine multi-declaration case — the same property name declared two or
|
||||
* more times, each conditional on mutually-exclusive displayOptions — is the only
|
||||
* case that needs resolveOneOfSchemas. Merging those into one show/hide produces
|
||||
* a self-contradicting predicate, so we emit one variant per declaration instead.
|
||||
*
|
||||
* Each declaration's `disabledOptions` is narrowed into its displayOptions first
|
||||
* (matching the type-generation path), so the runtime visibility agrees with the
|
||||
* emitted `.d.ts`. If disabledOptions leaves only one settable duplicate
|
||||
* declaration, that declaration still owns the generated predicate.
|
||||
*
|
||||
* @param mergedProp - The merged property for this name
|
||||
* @param declarations - All raw declarations sharing this name
|
||||
* @param allProperties - All properties at this level (used to extract defaults)
|
||||
* @param keysToStrip - displayOptions keys that are implicit and should be removed
|
||||
* @returns Generated code line, INDENT-prefixed (empty string if not mappable)
|
||||
*/
|
||||
export function generateMergedSchemaLine(
|
||||
mergedProp: NodeProperty,
|
||||
declarations: NodeProperty[],
|
||||
allProperties: NodeProperty[],
|
||||
keysToStrip: string[],
|
||||
): string {
|
||||
const variants: SchemaVariant[] = [];
|
||||
let allConditional = true;
|
||||
for (const decl of declarations) {
|
||||
const { displayOptions: narrowed, fullyDisabled } = narrowDisplayOptionsByDisabled(decl);
|
||||
if (fullyDisabled) {
|
||||
// Never settable in any visible state — not a real variant
|
||||
continue;
|
||||
}
|
||||
if (!narrowed) {
|
||||
allConditional = false;
|
||||
break;
|
||||
}
|
||||
const stripped = stripDiscriminatorKeysFromDisplayOptions(narrowed, keysToStrip);
|
||||
if (!stripped) {
|
||||
// Conditions were all implicit (e.g. only @version) — not the bug case
|
||||
allConditional = false;
|
||||
break;
|
||||
}
|
||||
variants.push({ prop: decl, displayOptions: stripped });
|
||||
}
|
||||
|
||||
if (allConditional && variants.length >= 2) {
|
||||
const line = generateOneOfSchemaLine(variants, allProperties);
|
||||
if (line) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
if (allConditional && variants.length === 1 && declarations.length > 1) {
|
||||
const [{ prop, displayOptions }] = variants;
|
||||
const line = generateConditionalSchemaLine({ ...prop, displayOptions }, allProperties);
|
||||
if (line) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
// Historical behavior: a single merged schema using the merged displayOptions
|
||||
if (mergedProp.displayOptions) {
|
||||
const stripped = stripDiscriminatorKeysFromDisplayOptions(
|
||||
mergedProp.displayOptions,
|
||||
keysToStrip,
|
||||
);
|
||||
if (stripped) {
|
||||
return generateConditionalSchemaLine(
|
||||
{ ...mergedProp, displayOptions: stripped },
|
||||
allProperties,
|
||||
);
|
||||
}
|
||||
}
|
||||
return generateSchemaPropertyLine(mergedProp, isPropertyOptional(mergedProp));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema Generation Result Types
|
||||
// =============================================================================
|
||||
@@ -1308,6 +1322,9 @@ export function generateSingleVersionSchemaFile(
|
||||
const needsResolveSchema =
|
||||
hasDisplayOptions(filteredProperties) ||
|
||||
(hasAiInputs && hasConditionalSubnodeFields(aiInputTypes));
|
||||
const needsResolveOneOfSchema = hasDuplicateConditionalDeclarations(filteredProperties, [
|
||||
'@version',
|
||||
]);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -1328,6 +1345,8 @@ export function generateSingleVersionSchemaFile(
|
||||
// Add resolveSchema if we need it
|
||||
if (needsResolveSchema) {
|
||||
helpers.push('resolveSchema');
|
||||
}
|
||||
if (needsResolveSchema || needsResolveOneOfSchema) {
|
||||
helpers.push('resolveOneOfSchemas');
|
||||
}
|
||||
|
||||
@@ -1540,6 +1559,7 @@ export function generateDiscriminatorSchemaFile(
|
||||
|
||||
// Check if AI inputs have conditional fields (displayOptions)
|
||||
const hasConditionalAiInputs = hasAiInputs && hasConditionalSubnodeFields(aiInputTypes);
|
||||
const needsResolveOneOfSchema = hasDuplicateConditionalDeclarations(props, discriminatorKeys);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -1566,6 +1586,8 @@ export function generateDiscriminatorSchemaFile(
|
||||
// Add resolveSchema if properties have displayOptions OR AI inputs have displayOptions
|
||||
if (hasRemainingDisplayOptions || hasConditionalAiInputs) {
|
||||
helpers.push('resolveSchema');
|
||||
}
|
||||
if (hasRemainingDisplayOptions || hasConditionalAiInputs || needsResolveOneOfSchema) {
|
||||
helpers.push('resolveOneOfSchemas');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user